peter-toth commented on code in PR #57865:
URL: https://github.com/apache/spark/pull/57865#discussion_r3811400888
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogV2Util.scala:
##########
@@ -519,6 +520,52 @@ private[sql] object CatalogV2Util {
catalog.asTableCatalog.loadTable(ident, context, stateOptions)
}
+ /**
+ * Loads a table for a write, forwarding the required privileges and only
the write options that
+ * the catalog declares may affect table state. The complete option map
remains on the write
+ * relation for write planning.
+ */
+ def loadTableForV2Write(
Review Comment:
**Finding 14.** Worth weighing rather than a request, and a follow-up given
the PR is approved.
The rejection is now enforced at five places: here (covering `save()`, the
schema-evolution reload and the four CTAS/RTAS fallbacks), `ResolveCatalogs`'
two new cases, `RelationResolution:250`, `DataFrameWriter:367` and
`DataFrameWriter:508`. Three of those exist only because the check has to be
*positioned* relative to something else — after the `V1Table` dispatch on the
two `DataFrameWriter` paths, and before `TimeTravelSpec.fromOptions` in
`RelationResolution` — and the copy inside `loadTableForV2Write` is already
redundant for `loadTableForInsert`, since `ResolveCatalogs` has rejected every
reachable CTAS/RTAS long before execution.
A single check over the analyzed plan collapses all five, and the
positioning falls out for free because the V1 fallbacks are not V2 write
commands:
```scala
// CheckAnalysis, or a small rule after ResolveCatalogs
case w: V2WriteCommand => w.table match {
case DataSourceV2Relation(_, _, Some(catalog), Some(ident), options, _) =>
CatalogV2Util.rejectTimeTravelOptionsForWrite(catalog, ident, options)
case _ =>
}
case c: V2CreateTableAsSelectPlan => c.name match {
case ResolvedIdentifier(catalog, ident) =>
CatalogV2Util.rejectTimeTravelOptionsForWrite(
catalog, ident, new CaseInsensitiveStringMap(c.writeOptions.asJava))
case _ =>
}
```
`insertInto` / `saveAsTable` on a V1 target produce `InsertIntoStatement` /
`SaveAsV1TableCommand`, so they are skipped structurally instead of by call
ordering, and a write entry point added later cannot forget the check — which
is the failure mode findings 3 and 9 were both instances of. It would also
settle the divergence @szehon-ho noted above: `insertInto` now reports
`TABLE_OR_VIEW_NOT_FOUND` on a missing target while `saveAsTable`, `save()` and
`DataFrameWriterV2` report `UNSUPPORTED_FEATURE.TIME_TRAVEL`, and a plan-level
check makes all four report the missing table.
The counter-arguments, honestly:
1. It is not literally one case. DELETE / UPDATE / MERGE arrive as
`DeleteFromTable` and the row-level plans, which are not `V2WriteCommand`s, so
the match grows and the "can't forget it" property weakens.
2. The error moves from relation resolution to after analysis, so what wins
changes: `writeTo("temp_view").option("versionAsOf", …)` would report the
temp-view error rather than `UNSUPPORTED_FEATURE.TIME_TRAVEL`, and the
temp-view case in `time travel is rejected for resolved and newly created V2
write targets` would have to change with it.
3. The check can only fire once analysis has completed, so on the `save()`
path the connector's `loadTable` — with `INSERT` / `INSERT, DELETE` requested —
has already run by the time we reject. Today it rejects first and the catalog
is never called. For a catalog that authorizes, audit-logs or pins state in
`loadTable`, that is a new side effect on a query that fails anyway.
Not asking you to rework it here. The thing I would like on the record is
that five sites now have to stay in sync, and the two that are order-sensitive
are not obviously so from the code.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2DataFrameSessionCatalogSuite.scala:
##########
@@ -98,6 +98,25 @@ class DataSourceV2DataFrameSessionCatalogSuite
verifyTable("t", df)
}
}
+
+ test("SPARK-58389: time travel options are ignored for V1 table writes") {
+ withTable("t") {
+ sql("CREATE TABLE t(c BIGINT) USING csv")
+ val df = spark.range(1).toDF("c")
+
+ df.write
+ .format(v2Format)
+ .option("versionAsOf", "1")
+ .insertInto("t")
+ df.write
+ .format("csv")
Review Comment:
**Finding 13.** The `insertInto` half of this test does execute the branch
it is meant to cover. The `saveAsTable` half never enters
`saveAsTableCommand(catalog, …)` at all.
`format("csv")` makes `lookupV2Provider()` return `None` —
`DataSource.lookupDataSourceV2:782` short-circuits on
`useV1Sources.contains(d.shortName())` and csv is in
`spark.sql.sources.useV1SourceList` — and `InMemoryTableSessionCatalog` is a
`DelegatingCatalogExtension`, i.e. a `CatalogExtension`. So `canUseV2` at
`DataFrameWriter.scala:468` is false and `saveAsTableCommand(tableName)` falls
to the `AsTableIdentifier` branch, straight into `saveAsV1TableCommand`. The
new `case Some(_: V1Table) => return` at `:502` and the reject-after-V1
ordering never run.
Measured on this head. Body of the four-arg `saveAsTableCommand` replaced by
`throw new IllegalStateException("PROBE: …")`, test still green:
```
[info] - SPARK-58389: time travel options are ignored for V1 table writes (2
seconds, 436 ms)
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
```
Same probe with `.format("csv")` swapped for `.format(v2Format)` — that is
the path in:
```
[info] - SPARK-58389: time travel options are ignored for V1 table writes
*** FAILED ***
[info] java.lang.IllegalStateException: PROBE: catalog saveAsTableCommand
reached
```
For contrast, a probe on `insertIntoCommand`'s `case _: V1Table` (`:364`)
does fire, so that half is effective as written:
```
[info] java.lang.IllegalStateException: PROBE: insertInto V1Table branch
reached
```
The catch is that `format(v2Format)` is not a working fix by itself — I ran
both modes without the probe and each fails for an unrelated fixture reason:
```
mode(Append) org.apache.spark.sql.AnalysisException: The format of the
existing table
spark_catalog.default.t is `CSVDataSourceV2`. It doesn't
match the specified
format `FakeV2ProviderWithCustomSchema`.
(PreprocessTableCreation, rules.scala:186)
mode(Overwrite) org.apache.spark.SparkException: [INTERNAL_ERROR]
org.apache.spark.sql.connector.FakeV2ProviderWithCustomSchema does not allow
create table as select.
(DataSource.writeAndRead:557)
```
Reaching the branch needs a format that `lookupDataSourceV2` returns *and*
whose V1 write path works against an existing csv table, which this suite has
no fixture for. `SPARK-49246: saveAsTable with v1 format` just above has the
same gap for the same reason, so this is not a regression you introduced. But
the ordering fix is specifically about `saveAsTable`, and both the
migration-guide bullet ("catalog-backed Data Source V2 writes") and the
scaladoc clause ("for V1 tables the options are ignored") lean on it, so one
test that actually executes it would be worth having. The Hive session-catalog
suites are the other place `canUseV2` is true with a V1 target. If it can't be
built, dropping the `saveAsTable` lines and naming the test after `insertInto`
would at least stop it reading as coverage it does not provide — the same
clarification you just made to the matrix test above.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/AppendDataTransactionSuite.scala:
##########
@@ -19,14 +19,70 @@ 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,
+ TableContext,
+ TableWritePrivilege,
+ TimeTravel,
+ Txn,
+ TxnTableCatalog}
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
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
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 (context, _) => context.writePrivileges() == expectedPrivileges
+ }
+ 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(targetLoadOption) === targetLoadValue)
+ assert(options.asCaseSensitiveMap().containsKey(targetLoadOption))
+ assert(options.get(targetWriteOption) === null)
+ assert(options.size() === 1)
+ }
+
+ assert(table.lastWriteInfo != null, "the V2 table did not receive
LogicalWriteInfo")
+ assert(table.lastWriteInfo.options().get(targetLoadOption) ===
targetLoadValue)
+
assert(table.lastWriteInfo.options().asCaseSensitiveMap().containsKey(targetLoadOption))
+ assert(table.lastWriteInfo.options().get(targetWriteOption) ===
targetWriteValue)
+ }
+
+ test("transaction catalog honors time travel context") {
Review Comment:
**Finding 16.** Seven of the nine new tests carry the ticket id
(`SPARK-58389: …`); this one and `explicit time travel specs on internal write
targets use qualified names`
(`sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2OptionSuite.scala:1180`)
don't. Prefixing both with `SPARK-58389: ` keeps grep-by-ticket working across
the whole change.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogV2Util.scala:
##########
@@ -519,6 +520,52 @@ private[sql] object CatalogV2Util {
catalog.asTableCatalog.loadTable(ident, context, stateOptions)
}
+ /**
+ * Loads a table for a write, forwarding the required privileges and only
the write options that
+ * the catalog declares may affect table state. The complete option map
remains on the write
+ * relation for write planning.
+ */
+ def loadTableForV2Write(
+ catalog: CatalogPlugin,
+ ident: Identifier,
+ writePrivileges: Set[TableWritePrivilege],
+ options: CaseInsensitiveStringMap): Table = {
+ rejectTimeTravelOptionsForWrite(catalog, ident, options)
+ loadTableForWrite(catalog, ident, writePrivileges, options)
+ }
+
+ /**
+ * Loads a table for a write without validating the complete write option
map. This is used by
+ * callers that must inspect whether the loaded table falls back to V1
before applying V2-only
+ * option validation.
+ */
+ def loadTableForWrite(
Review Comment:
**Finding 15.** The two loaders differ only in whether they validate, but
the names say "V2 write" versus "write" — both load a table for a V2 write, so
at a call site you cannot tell which one skips the check without reading both
scaladocs. The lenient one also has the stricter-sounding name of the pair.
Naming them for the actual difference would read at the call site, e.g.
`loadTableForWrite` (validating) plus
`loadTableForWriteSkippingOptionValidation`, or one method with an explicit
flag. Alternatively drop `loadTableForV2Write` and have its three callers do
what `DataFrameWriter` already does — call `rejectTimeTravelOptionsForWrite`
next to the load — which makes the validation visible at every site instead of
hidden inside one of two near-identical helpers.
Separately, while you are in here: this method is now a near-duplicate of
`getTable:507`. Same body (`new TableContext`, `extractTableStateOptions`,
`loadTable(ident, context, stateOptions)`), differing only in taking
`Set[TableWritePrivilege]` instead of the comma-joined marker string. If
`getTable` took the set and `parseWritePrivileges` moved to its single
`RelationResolution` caller, there would be one loader instead of three.
--
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]