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


##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2OptionSuite.scala:
##########
@@ -789,6 +861,112 @@ class DataSourceV2OptionSuite extends DatasourceV2SQLBase 
{
     }
   }
 
+  test("SPARK-58392: loadRelation receives only state options and scans keep 
all options") {

Review Comment:
   **Finding 14.** The whole point of the projection is that references sharing 
a table state reuse one `Table` while keeping their own scan options, and this 
PR moves that door from `loadTable` to `loadRelation`. The three tests that pin 
it — `same table state shares one Table while preserving each reference's 
options` (`:1100`), `different table-state option values establish separate 
table pins` (`:1300`), `SPARK-58389: repeated references with the same options 
load the table once` (`:1588`) — all go through `loadTable`, and none has a 
`loadRelation` analog. Every new test here uses exactly one reference, so the 
call count can never distinguish "projected correctly" from "projected slightly 
differently from the pin key" (finding 13).
   
   Measured on this head with `StateAwareV2InMemoryRelationCatalog`:
   
   - ``t WITH ('snapshot'='s1', `split-size`=5) a JOIN t WITH ('snapshot'='s1', 
`split-size`=9) b`` -> **1** `loadRelation` call, `{snapshot=s1}`; two 
relations carrying `{split-size=5, snapshot=s1}` and `{split-size=9, 
snapshot=s1}`; one shared `Table` instance.
   - `t WITH ('snapshot'='s1') a JOIN t WITH ('snapshot'='s2') b` -> **2** 
calls, `{snapshot=s1}` and `{snapshot=s2}`.
   
   Both are a handful of lines on the fixture you already have. The second 
needs per-call assertions rather than `assertOnlySnapshotRelationOptions`.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RelationResolution.scala:
##########
@@ -291,7 +291,9 @@ class RelationResolution(
                 case mc: RelationCatalog
                     if finalTimeTravelSpec.isEmpty && writePrivileges == null 
=>
                   try {
-                    Some(mc.loadRelation(ident))
+                    val stateOptions =
+                      CatalogV2Util.extractTableStateOptions(mc, finalOptions)

Review Comment:
   **Finding 13.** `tableKey` is already forced by the time we get here: the 
branch guard gives `writePrivileges == null`, and `RelationCatalog extends 
TableCatalog`, so `tableCache.get(tableKey)` at `:274` ran. And 
`toTableCacheKey` computes `CatalogV2Util.extractTableStateOptions(catalog, 
finalOptions)` for exactly this `catalog` / `finalOptions` pair (`:630`). So 
this is a second, independent computation of the value that *is* the pin key — 
the two have to agree or Spark pins a table under one projection while having 
loaded it with another. Reusing the key's own projection makes that structural 
instead of a coincidence, and it is what the description already claims happens 
("passes only the catalog-declared `tableStateOptionKeys()` projection 
(`tableKey.stateOptions`)"):
   
   ```scala
                     try {
                       Some(mc.loadRelation(ident, tableKey.stateOptions))
                     } catch {
   ```
   



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/RelationCatalog.java:
##########
@@ -134,6 +139,30 @@ public interface RelationCatalog extends TableCatalog, 
ViewCatalog {
    */
   Relation loadRelation(Identifier ident) throws NoSuchTableException;
 
+  /**
+   * Load the relation for an identifier that may resolve to either a table or 
a view, forwarding
+   * the user-specified options that may affect table state.
+   * <p>
+   * Behaves like {@link #loadRelation(Identifier)} but also receives 
table-state options. The
+   * default implementation ignores {@code stateOptions} and delegates to
+   * {@link #loadRelation(Identifier)}; catalogs that want to receive 
table-state options while
+   * reading a relation must override {@link #tableStateOptionKeys()} and this 
method.
+   * <p>
+   * Spark calls this for a plain read only -- no time travel and no write 
privileges, both of
+   * which apply to tables only and route through
+   * {@link TableCatalog#loadTable(Identifier, TableContext, 
CaseInsensitiveStringMap)} instead.
+   *
+   * @param ident the identifier
+   * @param stateOptions options declared to affect table state

Review Comment:
   **Finding 17.** `stateOptions` is documented purely in table terms, and its 
declaration hook is `TableCatalog#tableStateOptionKeys()` — a table-scoped name 
— but this method can return a `View`, and Spark applies the same projection to 
a view lookup. Measured on this head: reading a view with `.option("snapshot", 
"outer")` calls this with `{snapshot=outer}`. The PR description covers it 
("both result kinds receive the same table-state projection"), but the javadoc 
doesn't, so a connector whose views have their own state selector would read 
this and reasonably expect either a separate declaration hook or different 
filtering for views. Same gap as finding 11 — the description is not where 
implementors look. One sentence closes it:
   
   ```java
      * @param stateOptions options declared to affect table state by
      *                     {@link #tableStateOptionKeys()}. Spark applies the 
same table-state
      *                     projection when the identifier turns out to be a 
{@link View}: the kind
      *                     is only known once this call returns, so there is 
no separate
      *                     view-state declaration.
   ```
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2OptionSuite.scala:
##########
@@ -789,6 +861,112 @@ class DataSourceV2OptionSuite extends DatasourceV2SQLBase 
{
     }
   }
 
+  test("SPARK-58392: loadRelation receives only state options and scans keep 
all options") {
+    registerCatalog("testrelcat", classOf[StateAwareV2InMemoryRelationCatalog])
+    val t1 = "testrelcat.ns1.ns2.table"
+    withTable(t1) {
+      sql(s"CREATE TABLE $t1 (id bigint, data string) USING parquet")
+
+      val relCatalog =
+        catalog("testrelcat").asInstanceOf[StateAwareV2InMemoryRelationCatalog]
+      relCatalog.resetLoadRelationCalls()
+      val df = spark.read
+        .option("SnApShOt", "s1")
+        .option("split-size", "5")
+        .table(t1)
+      val relations = df.queryExecution.analyzed.collect { case r: 
DataSourceV2Relation => r }
+
+      assertOnlySnapshotRelationOptions(relCatalog, "s1", expectedCalls = 1)
+      assert(relations.size === 1)
+      assert(relations.head.options.size() === 2)
+      assert(relations.head.options.get("snapshot") === "s1")
+      assert(relations.head.options.get("split-size") === "5")
+    }
+  }
+
+  test("SPARK-58392: loadRelation uses the table-state projection for a view") 
{

Review Comment:
   **Finding 6.** R2 closed my original "no view-side coverage" finding with a 
test that was stronger than what I asked for — [`SPARK-58392: options are 
forwarded for a view over a V2 
table`](https://github.com/apache/spark/blob/58ae83ffcbb45ebf83f72248ec3d73f3261e7e00/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2OptionSuite.scala#L526):
 a view whose body reads a V2 table in the same catalog, asserting three loads 
and that the outer read option reached only the view load while the body's own 
`WITH` option reached only the two table loads. The redesign replaced it with 
`CREATE VIEW v AS SELECT 1 AS x`, a body with no relation at all, so all that 
is left is "the projection reaches one view load".
   
   The property still holds — I measured it on this head against 
`StateAwareV2InMemoryRelationCatalog`:
   
   ```
   CREATE VIEW v AS SELECT * FROM t WITH ('snapshot' = 'inner')
   spark.read.option("snapshot", "outer").option("split-size", 
"5").table(v).collect()
     -> loadRelationCalls = [{snapshot=outer}, {snapshot=inner}, 
{snapshot=inner}]
   ```
   
   The outer read's `snapshot` reaches only the view load, and the body's own 
`snapshot` reaches the body's table loads (analysis + execution refresh). Worth 
pinning: `createRelation` takes a visibly different branch for `View` 
(`createDataSourceV1Scan(V1Table.toCatalogTable(...))`, 
`RelationResolution.scala:448`) and the body's relations are resolved in a 
nested pass, so nothing else in the suite reaches either. Suggest restoring the 
R2 test's structure with `snapshot` in place of `split-size` and those three 
expected bags — note it needs per-call assertions rather than 
`assertOnlySnapshotRelationOptions`, which requires every recorded bag to carry 
the same value. Keeping the `SELECT 1 AS x` case alongside it is fine.
   



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/RelationCatalog.java:
##########
@@ -168,7 +197,11 @@ default TableSummary[] listRelationSummaries(String[] 
namespace)
    * <p>
    * The default implementation derives from {@link #loadRelation}: a {@link 
View} is rejected as
    * not-a-table; a {@link Table} is returned. Override only if a tables-only 
path is materially
-   * cheaper than the unified one.
+   * cheaper than the unified one -- note that reads do not reach this method: 
they go to
+   * {@link #loadRelation(Identifier, CaseInsensitiveStringMap)}, directly 
from the resolver or
+   * via {@link #loadTable(Identifier, TableContext, 
CaseInsensitiveStringMap)}. What remains
+   * here is write-privilege loads, DDL and miscellaneous lookups and reloads, 
including

Review Comment:
   **Finding 15.** My R3 finding-11 comment listed "the `V2TableReference` 
reload (`RelationResolution.scala:510`)" and you wrote that in, so this one is 
mine to correct. Only the *non-cacheable* `WriteTargetContext` branch reaches 
`loadTable(Identifier)` (`RelationResolution.scala:570`). The two cacheable 
contexts — `TemporaryViewContext` and `TransactionContext` 
(`V2TableReference.scala:97`, `:103`) — go through `getOrLoadRelation` -> 
`CatalogV2Util.getTable` -> `:230` -> `loadRelation(ident, stateOptions)`, 
exactly like every other read. And that one branch isn't a "write-privilege 
load" either: `RelationResolution.scala:567-569` says it carries no privileges. 
So:
   
   ```java
      * here is write-privilege loads, DDL and miscellaneous lookups, and the 
non-cacheable
      * {@code V2TableReference} write-target reload.
   ```
   
   (Not asking you to change that reload's behavior — that stays with the 
follow-up the comment there points at.)
   



-- 
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