Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package org.apache.spark.sql.connector.catalog;

import org.apache.spark.SparkIllegalArgumentException;
import org.apache.spark.annotation.Evolving;
import org.apache.spark.sql.connector.expressions.Transform;
import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException;
Expand Down Expand Up @@ -194,6 +195,50 @@ default Table loadTable(Identifier ident, long timestamp) throws NoSuchTableExce
throw QueryCompilationErrors.noSuchTableError(name(), ident);
}

/**
* Load table metadata by {@link Identifier identifier} from the catalog, forwarding all
* user-specified options.
* <p>
* The default implementation ignores {@code options} and delegates to the existing
* {@code loadTable} overloads based on {@code context}. Catalogs that want to receive the user
* options while reading a table must override this method.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 3. This tells connectors to override the method to receive the options, but not what they take over by doing so. The default body right below is also the dispatch point for loadTable(ident, writePrivileges) -- the call a catalog uses to authorize a write, and the one SPARK-58370 hardened after an authorization bypass -- and for time travel. A connector that overrides this to grab the options and returns a table without looking at context silently loses both, and nothing in Spark will catch it: CatalogV2Util.getTable has no other dispatch site now.

Suggest spelling the contract out:

   * The default implementation ignores {@code options} and delegates to the existing
   * {@code loadTable} overloads based on {@code context}. Catalogs that want to receive the user
   * options while reading a table must override this method.
   * <p>
   * An override replaces that dispatch and must honor {@code context} itself: apply the time
   * travel in {@link TableContext#timeTravel()}, and authorize the requested
   * {@link TableContext#writePrivileges()} as it would in
   * {@link #loadTable(Identifier, Set)}. Spark does not re-check either afterwards.

* <p>
* An override replaces that dispatch and must honor {@code context} itself: apply the time
* travel in {@link TableContext#timeTravel()}, and authorize the requested
* {@link TableContext#writePrivileges()} as it would in {@link #loadTable(Identifier, Set)}.
* Spark does not re-check either afterwards.
*
* @param ident a table identifier
* @param context the parsed load parameters (time travel, write privileges)
* @param options all options passed to the read, including any keys that are also parsed into
* {@code context}
* @return the table's metadata
* @throws NoSuchTableException If the table doesn't exist
*
* @since 4.3.0
*/
default Table loadTable(
Identifier ident,
TableContext context,
CaseInsensitiveStringMap options) throws NoSuchTableException {

@aokolnychyi aokolnychyi Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is my understanding correct that every option the user actually provided surfaces in options, even if it makes into a structured representation like time travel? The only thing removed is Spark's internal write-privilege marker, which the user never provided?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right. Every user-provided option is forwarded, including keys we also parse into context (e.g. versionAsOf). The only thing stripped is the internal REQUIRED_WRITE_PRIVILEGES marker, which users never set.

if (context.timeTravel().isPresent()) {
TimeTravel timeTravel = context.timeTravel().get();
if (timeTravel instanceof TimeTravel.AsOfVersion v) {
return loadTable(ident, v.version());
} else if (timeTravel instanceof TimeTravel.AsOfTimestamp ts) {
return loadTable(ident, ts.micros());
} else {
throw new SparkIllegalArgumentException(
"INTERNAL_ERROR",
Map.of("message", "Unsupported time travel spec: " + timeTravel));
}
} else if (!context.writePrivileges().isEmpty()) {
return loadTable(ident, context.writePrivileges());
} else {
return loadTable(ident);
}
}

/**
* Load a {@link Changelog} for the given table, representing the row-level changes within the
* range specified by {@code context}.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.apache.spark.sql.connector.catalog;

import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;

import org.apache.spark.SparkIllegalArgumentException;
import org.apache.spark.annotation.Evolving;
import org.apache.spark.sql.util.CaseInsensitiveStringMap;

/**
* Encapsulates the parsed, Spark-recognized parameters of a table load request, passed from the
* analyzer / DataFrame API to the catalog's
* {@link TableCatalog#loadTable(Identifier, TableContext, CaseInsensitiveStringMap)} method.
* <p>
* A load is either a read (optionally with time travel) or a write (carrying write privileges);
* time travel and write privileges are mutually exclusive.
*
* @since 4.3.0
*/
@Evolving
public class TableContext {

// null means no time travel.
private final TimeTravel timeTravel;
// Never null; an empty set means no write privileges (i.e. a read).
private final Set<TableWritePrivilege> writePrivileges;

public TableContext(TimeTravel timeTravel, Set<TableWritePrivilege> privileges) {
this.timeTravel = timeTravel;
this.writePrivileges = privileges == null ? Set.of() : Set.copyOf(privileges);
if (timeTravel != null && !writePrivileges.isEmpty()) {
throw new SparkIllegalArgumentException(
"INTERNAL_ERROR",
Map.of("message", "Cannot set both time travel and write privileges"));
}
}

/** Returns the time-travel spec, or empty for a current-version read. */
public Optional<TimeTravel> timeTravel() {
return Optional.ofNullable(timeTravel);
}

/** Returns the requested write privileges; empty for a read. */
public Set<TableWritePrivilege> writePrivileges() {
return writePrivileges;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof TableContext that)) return false;
return Objects.equals(timeTravel, that.timeTravel)
&& writePrivileges.equals(that.writePrivileges);
}

@Override
public int hashCode() {
return Objects.hash(timeTravel, writePrivileges);
}

@Override
public String toString() {
return "TableContext{timeTravel=" + timeTravel +
", writePrivileges=" + writePrivileges + "}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.apache.spark.sql.connector.catalog;

import org.apache.spark.annotation.Evolving;

/**
* A time-travel specification for reading a table as of a specific version or point in time.
*
* @since 4.3.0
*/
@Evolving
public sealed interface TimeTravel permits TimeTravel.AsOfVersion, TimeTravel.AsOfTimestamp {

/**
* Time travel to a specific version of the table.
*
* @param version the version identifier (connector-defined)
*/
record AsOfVersion(String version) implements TimeTravel {}

/**
* Time travel to a specific point in time.
*
* @param micros microseconds since 1970-01-01 00:00:00 UTC
*/
record AsOfTimestamp(long micros) implements TimeTravel {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@ object FakeV2SessionCatalog extends TableCatalog with FunctionCatalog with Suppo
* @param nestedViewDepth The nested depth in the view resolution, this enables us to limit the
* depth of nested views.
* @param maxNestedViewDepth The maximum allowed depth of nested view resolution.
* @param relationCache A mapping from qualified table names and time travel spec to resolved
* relations. This can ensure that the table is resolved only once if a table
* is used multiple times in a query.
* @param relationCache A mapping from (qualified table name, time travel spec, options) to
* resolved relations. This can ensure that the table is resolved only once if
* a table is used multiple times in a query with the same options.
* @param referredTempViewNames All the temp view names referred by the current view we are
* resolving. It's used to make sure the relation resolution is
* consistent between view creation and view resolution. For example,
Expand All @@ -154,8 +154,7 @@ case class AnalysisContext(
resolutionPathEntries: Option[Seq[Seq[String]]] = None,
nestedViewDepth: Int = 0,
maxNestedViewDepth: Int = -1,
relationCache: mutable.Map[(Seq[String], Option[TimeTravelSpec]), LogicalPlan] =
mutable.Map.empty,
relationCache: mutable.Map[RelationCacheKey, LogicalPlan] = mutable.Map.empty,
referredTempViewNames: Seq[Seq[String]] = Seq.empty,
// 1. If we are resolving a view, this field will be restored from the view metadata,
// by calling `AnalysisContext.withAnalysisContext(viewDesc)`.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.apache.spark.sql.catalyst.analysis

import org.apache.spark.sql.util.CaseInsensitiveStringMap

/**
* Key for the per-query relation cache in [[AnalysisContext]], shared by [[RelationResolution]].
*
* Options are part of the key because a catalog's options-aware `loadTable` can return a different
* `Table` depending on them, so two references to the same identifier with different options must
* not share a cached relation.
*/
private[sql] case class RelationCacheKey(
nameParts: Seq[String],
timeTravelSpec: Option[TimeTravelSpec],
options: CaseInsensitiveStringMap)
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,10 @@ class RelationResolution(
with LookupCatalog
with SQLConfHelper {

type CacheKey = (Seq[String], Option[TimeTravelSpec])

val v1SessionCatalog = catalogManager.v1SessionCatalog

private def relationCache: mutable.Map[CacheKey, LogicalPlan] = AnalysisContext.get.relationCache
private def relationCache: mutable.Map[RelationCacheKey, LogicalPlan] =
AnalysisContext.get.relationCache

/**
* If we are resolving database objects (relations, functions, etc.) inside views, we may need to
Expand Down Expand Up @@ -236,24 +235,26 @@ class RelationResolution(
finalTimeTravelSpec: Option[TimeTravelSpec]): Option[LogicalPlan] = {
expandIdentifier(identifier) match {
case CatalogAndIdentifier(catalog, ident) =>
val key = toCacheKey(catalog, ident, finalTimeTravelSpec)
val planId = u.getTagValue(LogicalPlan.PLAN_ID_TAG)
val writePrivileges = u.options.get(UnresolvedRelation.REQUIRED_WRITE_PRIVILEGES)
val finalOptions = u.clearWritePrivileges.options
// Time travel applies to reads only; reject it on a write target (reachable via the option
// form, e.g. `INSERT INTO t WITH ('versionAsOf' = ...)`) with a user-facing error.
if (finalTimeTravelSpec.nonEmpty && writePrivileges != null) {
throw QueryCompilationErrors.timeTravelUnsupportedError(toSQLId(identifier))
}
val key = toCacheKey(catalog, ident, finalTimeTravelSpec, finalOptions)
// A reference that requires write privileges is never served from the per-query relation
// cache. The catalog authorizes the write in `loadTable(ident, writePrivileges)` below, and
// a cache hit would skip that call entirely. The hit happens whenever the write target is
// also read in the same statement -- the target is resolved after its query (see
// `ResolveRelations`), so it finds the relation the query already put in the cache, e.g.
// for `INSERT INTO t SELECT * FROM t`.
//
// The cache key includes the options, so a hit means the options already match and each
// reference's own bag is honored without re-applying it here.
val cached = if (writePrivileges == null) relationCache.get(key) else None
cached
// The per-query relation cache is not keyed by options. When the same table is referenced
// more than once in a single statement with different dynamic options (e.g. a self-join,
// or a second reference sharing the target's cache entry), a cache hit would otherwise
// reuse the first reference's options and silently drop this reference's. Re-apply this
// reference's options to the cached relation so each reference honors its own bag.
.map(applyOptions(_, finalOptions))
.map(adaptCachedRelation(_, planId))
.orElse {
// For a `RelationCatalog` with no time-travel / write privileges, the single-RPC
Expand All @@ -280,7 +281,8 @@ class RelationResolution(
catalog,
ident,
finalTimeTravelSpec,
Option(writePrivileges))
Option(writePrivileges),
finalOptions)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 2. The Table loaded here with finalOptions is discarded ~30 lines below whenever the table sits in the CacheManager -- RelationResolution.scala:313-323:

val sharedRelationCacheMatch = for {
  t <- table
  if finalTimeTravelSpec.isEmpty && writePrivileges == null && !u.isStreaming
  cached <- lookupSharedRelationCache(catalog, ident, t)
} yield {
  val updatedRelation = cached.copy(options = finalOptions)   // keeps cached.table

Neither side of that lookup considers options: CacheManager.lookupCachedTable matches by name (CacheManager.scala:438, plus timeTravelSpec.isEmpty), and isSameTable compares rel.table.id == table.id (CatalogV2Util.scala:542). So for any table someone has cache()d, a read carrying options gets the Table that was built for the cached read's options, with this read's options merely stamped onto the relation -- the exact failure mode the per-query cache change fixes, and the "shared relation cache" half of #57582 (comment). The new PlanResolutionSuite test ("shared relation cache hit re-applies the current read's options", assert(resolved.table == cachedTable)) currently locks that behavior in.

Reusing the cached table also buys nothing once the options differ: the resulting plan carries them, so it no longer matches the cached entry's fingerprint -- your own DataSourceV2OptionSuite test asserts exactly that (lookupCachedData(spark.read.option("split-size", "5").table(t1)).isEmpty). So the connector pays for the load and Spark throws the result away for no cache reuse.

Suggest gating the reuse on the options matching and letting the freshly loaded table win otherwise:

val sharedRelationCacheMatch = for {
  t <- table
  if finalTimeTravelSpec.isEmpty && writePrivileges == null && !u.isStreaming
  cached <- lookupSharedRelationCache(catalog, ident, t)
  if cached.options == finalOptions
} yield {
  val nameParts = ident.toQualifiedNameParts(catalog)
  val aliasedRelation = SubqueryAlias(nameParts, cached)
  relationCache.update(key, aliasedRelation)
  adaptCachedRelation(aliasedRelation, planId)
}

That keeps SPARK-54022's guarantee for the case it was written for (same read, same options -> same table version as the cached plan) and drops it only where the cached plan could not have been reused anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the catch! I originally looked at this a little, but decided it might not be an issue because the table returned should have the options correctly reflected, and that would hit the cache correctly. But you're right that I didn't realize the same-table check inside the cache lookup is based only on the ID, and there's no guarantee the ID reflects the options; even if the options substantially change the table content, the ID might still be the same. So agreed, it's much safer to include the options in the lookup.

This may give up reuse in some edge cases where the cache could actually have been reused safely, e.g. purely read-tuning options that don't change which table is read or how it's structured; but that's still better than risking the wrong table from the cache.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though I just realized that I need to make sure all the cache/refresh will include options, and in my current commit it's not guaranteed; will need to amend this PR one more time to include it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is updated and ready to review, please take a look. Thank you!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually more subtle than this, given that Spark relies on the shared relation cache to pin the versions of the tables. Let me think.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only option I can think of right now is defining TableCatalog$tableStateOptions as a method that would list what options we have to pass to load. In case of Iceberg, this will be branch, for example. Then we will have to key our caching semantic on those keys alone.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if I tend to overcomplicate this... Still thinking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that ideally we should separate the connector specific options from the options only matter to Spark to avoid unnecessary cache misses

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my point can be addressed in a follow up, we need to discuss it a bit more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 9 (Non-blocking, and I think it belongs in the follow-up @aokolnychyi parked rather than in this PR): separate from the state-vs-cosmetic split, the lookup behind both new guards can fail to find a matching entry even when one exists.

CacheManager.lookupCachedTable matches cache entries by name only and returns just one of them -- the head of the matches (CacheManager.scala:438-449) -- and since cachedData is prepended on each cache (CacheManager.scala:165), that is the most recently cached entry. The new guards then compare that one candidate's options against the current read's (RelationResolution.scala:320, V2TableRefreshUtil.scala:93) and reuse its Table only when they are equal; nothing goes back to check the other name matches. A table has more than one entry because cachedData is keyed by the normalized plan and the plan carries options (CacheManager.scala:145), so spark.table(t).cache() and spark.read.option("split-size", "5").table(t).cache() are two entries. A read with split-size=5 therefore gets the pin only if that entry happens to be the newer of the two.

Reproduced on 3377cd99 -- same query both times, only the caching order swapped:

// cache both spark.table(t) and spark.read.option("split-size", "5").table(t)
val df = spark.read.option("split-size", "5").table(t).filter("id > 0")
df.queryExecution.analyzed
inMemoryCatalog.resetLoadTableCalls()
df.collect()
inMemoryCatalog.loadTableCalls.map(_._2.get("split-size")).filter(_ != null)

// split-size entry cached last  -> List()   pinned to the cached table, no reload
// option-free entry cached last -> List(5)  matching entry never offered, reloads latest

So today whether a query keeps its session-pinned version is decided by which of that table's cache entries was written most recently. It can also cost an outright cache hit, once the table has moved on from what was cached -- which is the situation the pin exists for. Cache spark.read.option("split-size", "5").table(t), let the table get a new version, then re-run that exact query with a newer cache entry for t present: the lookup offers the newer entry, the guard rejects it, the relation is loaded fresh at the new version, and sameResult then fails against the v=old entry that would have matched. With the caching order swapped, the same query is served from the cache.

TableCatalog#tableStateOptions would shrink the key but not close this: with two entries differing only in state options, the lookup still offers just one of them. The multi-reference case makes it concrete -- one query reading t on branch=b1 and t on branch=b2, with both branches cached, resolves each reference against the same single newest candidate (tryResolvePersistent runs per reference and there is no memo across them), so exactly one of the two gets pinned to its cached snapshot and which one flips with the caching order. Closing it means making the shared-cache lookup options-aware instead of filtering after the fact -- RelationCache.lookup returns Option[LogicalPlan] today, so it would need to expose all name matches (or take the options) and let the caller pick the one that matches.

On your split-size point: you're right, and it corrects something I asserted earlier in this thread. I argued the old cached.copy(options = finalOptions) branch "buys nothing" once the options differ, on the grounds that the resulting plan can't match the cached entry's fingerprint anyway. That holds for the useCachedData hit, but the branch was also providing version pinning, which is independent of it -- so for a purely cosmetic option the guard does give up something real.

} else {
None
}
Expand Down Expand Up @@ -308,14 +310,17 @@ class RelationResolution(
// we don't share-cache views.
val table: Option[Table] = relation.collect { case t: Table => t }

// Reuse a cached relation only when this read's options match: the lookup is by name
// and `Table.id`, so a differing-options read would otherwise get the cached read's
// `Table`.
val sharedRelationCacheMatch = for {
t <- table
if finalTimeTravelSpec.isEmpty && writePrivileges == null && !u.isStreaming
cached <- lookupSharedRelationCache(catalog, ident, t)
if cached.options == finalOptions
} yield {
val updatedRelation = cached.copy(options = finalOptions)
val nameParts = ident.toQualifiedNameParts(catalog)
val aliasedRelation = SubqueryAlias(nameParts, updatedRelation)
val aliasedRelation = SubqueryAlias(nameParts, cached)
relationCache.update(key, aliasedRelation)
adaptCachedRelation(aliasedRelation, planId)
}
Expand Down Expand Up @@ -370,19 +375,6 @@ class RelationResolution(
CatalogV2Util.lookupCachedRelation(sharedRelationCache, catalog, ident, table, conf)
}

/**
* Re-applies `options` to the relation in a cached plan. Every `relationCache` entry holds a
* single relation for its own identifier (a view's body is still unresolved when it is cached),
* so this cannot reach another table's relation.
*/
private def applyOptions(
cached: LogicalPlan,
options: CaseInsensitiveStringMap): LogicalPlan = cached transform {
case r: DataSourceV2Relation => r.copy(options = options)
case r: UnresolvedCatalogRelation => r.copy(options = options)
case r: StreamingRelationV2 => r.copy(extraOptions = options)
}

private def adaptCachedRelation(cached: LogicalPlan, planId: Option[Long]): LogicalPlan = {
val plan = cached transform {
case multi: MultiInstanceRelation =>
Expand Down Expand Up @@ -496,7 +488,7 @@ class RelationResolution(
}

private def getOrLoadRelation(ref: V2TableReference): LogicalPlan = {
val key = toCacheKey(ref.catalog, ref.identifier)
val key = toCacheKey(ref.catalog, ref.identifier, None, ref.options)
relationCache.get(key) match {
case Some(cached) =>
adaptCachedRelation(cached, ref)
Expand Down Expand Up @@ -556,8 +548,10 @@ class RelationResolution(
private def toCacheKey(
catalog: CatalogPlugin,
ident: Identifier,
timeTravelSpec: Option[TimeTravelSpec] = None): CacheKey = {
((catalog.name +: ident.namespace :+ ident.name).toImmutableArraySeq, timeTravelSpec)
timeTravelSpec: Option[TimeTravelSpec],
options: CaseInsensitiveStringMap): RelationCacheKey = {
RelationCacheKey(
(catalog.name +: ident.namespace :+ ident.name).toImmutableArraySeq, timeTravelSpec, options)
}

private def cloneWithPlanId(plan: LogicalPlan, planId: Option[Long]): LogicalPlan = {
Expand Down
Loading