peter-toth commented on code in PR #57865:
URL: https://github.com/apache/spark/pull/57865#discussion_r3779147324


##########
sql/core/src/main/scala/org/apache/spark/sql/classic/DataFrameWriter.scala:
##########
@@ -177,7 +177,9 @@ final class DataFrameWriter[T] private[sql](ds: Dataset[T]) 
extends sql.DataFram
               val catalog = CatalogV2Util.getTableProviderCatalog(
                 supportsExtract, catalogManager, dsOptions)
 
-              (catalog.loadTable(ident), Some(catalog), Some(ident))
+              val table = CatalogV2Util.getTableForWrite(
+                catalog, ident, getWritePrivileges, dsOptions)

Review Comment:
   **Finding 2.** Base here was `catalog.loadTable(ident)` -- no write 
privileges at all. This now asks the catalog to authorize `INSERT` (append) or 
`INSERT, DELETE` (overwrite). I think that's the right fix: this was the last 
DataFrame write path that skipped the `TableWritePrivilege` hook SPARK-58370 
hardened. But it's a semantic change well beyond forwarding options -- a 
catalog that authorizes in `loadTable(ident, writePrivileges)` can now reject a
   
   ```scala
   df.write.format("...").option("catalog", "cat").option("name", 
"t").mode("overwrite").save()
   ```
   
   that succeeded before, and the failure surfaces as an authorization error 
from the connector rather than anything Spark can explain.
   
   Worth noting the earlier version of this same fix (my finding 5 on #57582, 
which was pulled back out before merge to keep that PR read-only) passed 
options only -- `CatalogV2Util.getTable(catalog, ident, options = dsOptions)` 
-- so the privileges are new here and no one has reviewed them yet.
   
   Please call it out in the "What changes were proposed" bullets and in the 
user-facing-change section, so connector authors reading the release notes know 
an unauthorized `save()` on this path can now fail.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RelationResolution.scala:
##########
@@ -507,7 +507,15 @@ class RelationResolution(
    */
   private def loadRelation(ref: V2TableReference): LogicalPlan = {
     val resolvedCatalog = 
catalogManager.catalog(ref.catalog.name).asTableCatalog
-    val table = resolvedCatalog.loadTable(ref.identifier)
+    val table = ref.context match {
+      case V2TableReference.WriteTargetContext =>
+        // WriteTargetContext is currently used by transactional streaming 
writes and does not
+        // retain required write privileges. Keep the legacy load unchanged; 
options and
+        // privileges must be handled together in a follow-up.
+        resolvedCatalog.loadTable(ref.identifier)
+      case _ =>
+        CatalogV2Util.getTable(resolvedCatalog, ref.identifier, options = 
ref.options)

Review Comment:
   **Finding 1.** This is the `V2TableReference` re-resolution path that #57582 
explicitly declared out of scope ("still loads via the single-arg `loadTable` 
... forwarding options there is better done as a separate change"). Landing it 
here is fine by me, but it isn't a write path: `WriteTargetContext` is excluded 
just above, so the only contexts that reach this branch are 
`TemporaryViewContext` (DataFrame temp views, `views.scala:761`) and 
`TransactionContext` (`UnresolveRelationsInTransaction.scala:64`) -- both reads.
   
   The observable change: a connector that overrides `loadTable(Identifier, 
TableContext, CaseInsensitiveStringMap)` is now invoked for those reloads, 
where before the analyzer called the single-arg `loadTable(Identifier)` 
directly and the override never ran. That is the same class of change as the 
"one `loadTable` call per distinct option bag" note #57582 put in its 
user-facing section.
   
   Under a title that says "for writes", and a description whose bullets list 
writer entry points plus "internal target reloads (the post-schema-evolution 
reload after `catalog.alterTable` and staging/non-staging CTAS/RTAS fallback 
loads)", this is invisible. Please add it to the bullet list and to the 
user-facing-change section -- or split it out if you'd rather keep this PR 
strictly to writes.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/classic/DataFrameWriterV2.scala:
##########
@@ -221,10 +223,16 @@ final class DataFrameWriterV2[T] private[sql](table: 
String, ds: Dataset[T])
 
   private[sql] def overwritePartitionsCommand(): LogicalPlan = {
     OverwritePartitionsDynamic.byName(
-      UnresolvedRelation(tableName).requireWritePrivileges(Set(INSERT, 
DELETE)),
+      createUnresolvedWriteTarget(Set(INSERT, DELETE)),
       logicalPlan, options.toMap, _withSchemaEvolution)
   }
 
+  private def createUnresolvedWriteTarget(
+      privileges: Set[TableWritePrivilege]): UnresolvedRelation = {
+    val tableOptions = new CaseInsensitiveStringMap(options.toMap.asJava)
+    UnresolvedRelation(tableName, 
tableOptions).requireWritePrivileges(privileges)

Review Comment:
   **Finding 3.** Putting the writer's options on the `UnresolvedRelation` also 
feeds them to the *option form* of time travel. 
`RelationResolution.resolveRelation:163` runs 
`TimeTravelSpec.fromOptions(u.options, ...)` with the default keys 
`versionAsOf` / `timestampAsOf` (`spark.sql.timeTravelVersionKey` / 
`spark.sql.timeTravelTimestampKey`), and `tryResolvePersistent:240` then throws 
because the write-privilege marker is present.
   
   I ran it to be sure. On this head:
   
   ```
   org.apache.spark.sql.AnalysisException: [UNSUPPORTED_FEATURE.TIME_TRAVEL] 
The feature is not
   supported: Time travel on the relation: `cat`.`ns1`.`test_table`. SQLSTATE: 
0A000
   ```
   
   for `spark.table(t).writeTo(t).option("versionAsOf", "0").append()`; with 
only this file reverted to base, the same call completes normally. So it's a 
hard break of code that used to work, and #57582 documented exactly this shape 
for `INSERT INTO t WITH ('versionAsOf' = ...)`.
   
   Two things make it more than a doc line. Reusing one option map for a read 
and a write is a common pattern. And the behaviour is now inconsistent between 
the two DataFrame writers: `insertInto` and `saveAsTable` build the 
`DataSourceV2Relation` directly (`DataFrameWriter.scala:360`, `:492`) and never 
parse the options, so `df.write.option("versionAsOf", "0").insertInto(t)` stays 
silently ignored. If rejecting it is the intent, 
`CatalogV2Util.getTableForWrite` is the one place that would cover all three.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/AppendDataTransactionSuite.scala:
##########
@@ -19,14 +19,41 @@ package org.apache.spark.sql.connector
 
 import org.apache.spark.sql.AnalysisException
 import org.apache.spark.sql.Row
-import org.apache.spark.sql.connector.catalog.{Aborted, Committed}
+import org.apache.spark.sql.connector.catalog.{Aborted, Committed, 
TableWritePrivilege, Txn}
 import org.apache.spark.sql.functions._
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.internal.SQLConf.PartitionOverwriteMode
 import org.apache.spark.sql.sources
 
 class AppendDataTransactionSuite extends RowLevelOperationSuiteBase {
 
+  private val targetLoadOption = "targetLoadOption"
+  private val targetLoadValue = "loadValue"
+  private val targetWriteOption = "targetWriteOption"
+  private val targetWriteValue = "writeValue"
+  private val targetOptionsClause =
+    s"WITH (`$targetLoadOption` = '$targetLoadValue', " +
+      s"`$targetWriteOption` = '$targetWriteValue')"
+
+  private def assertTargetLoadAndWriteOptions(
+      txn: Txn,
+      expectedPrivileges: java.util.Set[TableWritePrivilege],
+      minTargetLoads: Int = 1): Unit = {
+    val targetLoads = txn.catalog.loadTableCalls.filter {
+      case (_, options) => options.get(targetLoadOption) == targetLoadValue
+    }
+    assert(targetLoads.size >= minTargetLoads,
+      s"expected at least $minTargetLoads target loads with write options")
+    targetLoads.foreach { case (context, options) =>
+      assert(context.writePrivileges() === expectedPrivileges)
+      assert(options.get(targetWriteOption) === targetWriteValue)
+    }
+
+    assert(table.lastWriteInfo != null, "the V2 table did not receive 
LogicalWriteInfo")
+    assert(table.lastWriteInfo.options().get(targetLoadOption) === 
targetLoadValue)

Review Comment:
   **Finding 5.** `CaseInsensitiveStringMap.get` is case-insensitive, so these 
pass whether or not the key casing survived. This suite is the only place with 
a mixed-case fixture key (`targetLoadOption`); `DataSourceV2OptionSuite`, 
`SupportsCatalogOptionsSuite` and the row-level suites all use lowercase 
`load-option` / `write-option`. So the only test that actually pins casing is 
the new `CatalogV2UtilSuite` one, and it stops at `UnresolvedRelation` -- 
nothing covers the rest of the path (`clearWritePrivileges` -> relation options 
-> `V2Writes.mergeOptions`'s `asCaseSensitiveMap` -> `LogicalWriteInfo`), which 
is exactly where the user-facing guarantee lives.
   
   Two lines here close it, and they fail on base for this suite's SQL cases 
(`clearWritePrivileges` lowercased, so the connector saw `targetloadoption`):
   
   ```scala
   
assert(table.lastWriteInfo.options().asCaseSensitiveMap().containsKey(targetLoadOption))
   ```
   
   plus the catalog side, inside the `targetLoads.foreach` above:
   
   ```scala
   assert(options.asCaseSensitiveMap().containsKey(targetLoadOption))
   ```
   



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/txns.scala:
##########
@@ -196,6 +198,14 @@ class TxnTableCatalog(delegate: 
InMemoryRowLevelOperationTableCatalog) extends T
     })
   }
 
+  override def loadTable(
+      ident: Identifier,
+      context: TableContext,
+      options: CaseInsensitiveStringMap): Table = {
+    loadTableCalls += ((context, options))
+    loadTable(ident)

Review Comment:
   **Finding 6.** This drops `context` on the floor. 
`BasicInMemoryTableCatalog` records and then calls `super.loadTable(ident, 
context, options)`, with a comment saying why -- "defers to the default 
dispatch in TableCatalog (rather than reimplementing it here)". As written the 
fixture models the anti-pattern the `loadTable` javadoc warns about ("An 
override replaces that dispatch and must honor `context` itself"), and it is 
what keeps finding 4 invisible: a time-travel load inside a transaction quietly 
returns the latest pinned table instead of failing or honoring the version.
   
   One caveat on the obvious fix, which I hit when trying it: delegating alone 
turns a time-travel read inside a transaction into `TABLE_OR_VIEW_NOT_FOUND`, 
because `TxnTableCatalog` doesn't implement `loadTable(ident, version)` / 
`(ident, timestamp)` either, so the default dispatch reaches `TableCatalog`'s 
throwing overload. Loud beats silent, and no existing test in the suite 
regresses (17/17 pass), but the complete fix is to delegate *and* serve the 
version overloads from the pinned delegate:
   
   ```scala
   override def loadTable(
       ident: Identifier,
       context: TableContext,
       options: CaseInsensitiveStringMap): Table = {
     loadTableCalls += ((context, options))
     super.loadTable(ident, context, options)
   }
   ```
   
   If you'd rather keep the fixture as-is, a comment saying it deliberately 
ignores time travel would at least stop the next reader from trusting it.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RelationResolution.scala:
##########
@@ -507,7 +507,15 @@ class RelationResolution(
    */
   private def loadRelation(ref: V2TableReference): LogicalPlan = {
     val resolvedCatalog = 
catalogManager.catalog(ref.catalog.name).asTableCatalog
-    val table = resolvedCatalog.loadTable(ref.identifier)
+    val table = ref.context match {
+      case V2TableReference.WriteTargetContext =>
+        // WriteTargetContext is currently used by transactional streaming 
writes and does not
+        // retain required write privileges. Keep the legacy load unchanged; 
options and
+        // privileges must be handled together in a follow-up.

Review Comment:
   **Finding 4.** Not caused by this PR -- I checked base -- but it lives in 
the method you're changing, so flagging it while we're here. Take it or file it 
separately, your call.
   
   `V2TableReference` doesn't retain the time-travel spec, and 
`UnresolveRelationsInTransaction.scala:63` has no `r.timeTravelSpec.isEmpty` 
guard (unlike `prepareTemporaryViewPlan`, `views.scala:759`). So a 
time-travelled relation that is already resolved when a transactional write is 
built -- which is any `Dataset` whose `logicalPlan` is the analyzed plan, e.g. 
after a `select` -- gets unresolved, comes back through here with the spec 
gone, and silently reloads the latest snapshot. `validateNoChanges` won't catch 
it: same `tableId`, same columns.
   
   Measured in `AppendDataTransactionSuite` (pin version `0`, append one more 
row, then use the pinned read as the source of a transactional append into a 
second table):
   
   ```
   outside-txn-source-rows=1
   rows-written=2   sink=[1,100,hr],[2,200,software]
   ```
   
   Same result with `RelationResolution.scala` reverted to base, so the wrong 
data is pre-existing. What this PR adds is a second call site with the same 
mismatch: `getTable` is called with `timeTravel = None` while `ref.options` 
still says `versionAsOf = 0`, and the javadoc you just updated tells the 
connector to trust the context. (Base already had one such call from 
`V2TableRefreshUtil`, on the same spec-stripped relation.)
   
   The guard fixes it -- I applied it and got `rows-written=1`, with the other 
17 tests in the suite still passing:
   
   ```scala
   case r: DataSourceV2Relation
       if isLoadedFromCatalog(r, catalog) && r.timeTravelSpec.isEmpty =>
     V2TableReference.createForTransaction(r)
   ```
   
   Trade-off worth naming: the guard also stops the transaction from tracking 
that read, which is what the rule exists for. Carrying the spec on 
`V2TableReference` and passing it into `getTable` keeps both.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to