From a8c9950dbe7e820b0b3ee11b05b32c07e999e55f Mon Sep 17 00:00:00 2001 From: Max Gekk Date: Tue, 30 Jun 2026 00:06:39 +0200 Subject: [PATCH 1/7] [SPARK-57758][SQL] Restore O(1) built-in function resolution in the analyzer After SPARK-54807, every UnresolvedFunction made FunctionResolution build an ordered candidate search path, allocate Seqs, and iterate candidates (each doing a name parse + registry lookup), recomputed per function node and per analyze call. Under Spark Connect, which re-analyzes the whole growing plan on every AnalyzePlan, this produced a multi-fold analysis-time regression. This restores the fast common case without changing resolution precedence: - Per-analysis-pass cache (ThreadLocal keyed by AnalysisContext identity) of the resolution search path plus a derived "built-in precedes session" flag, so the path is computed once per pass / view body instead of once per function node. - Built-in-only fast-path in resolveFunction / resolveTableFunction for single-part, non-internal names: when system.builtin precedes system.session in the effective path, resolve directly against the in-memory registry and return on hit; a miss falls through to the unchanged candidate loop. Built-ins always precede persistent catalogs, and the gate excludes the session-first cases where a temp/UDF may shadow a built-in, so precedence is preserved. The FORBIDDEN_OPERATION masking noted in the JIRA is tracked separately in SPARK-57759 and is intentionally left unchanged here. --- .../analysis/FunctionResolution.scala | 83 ++++++++++++++++++- .../sql/FunctionQualificationSuite.scala | 31 +++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala index 4f6aee03967cb..59dff41b10b3d 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala @@ -111,9 +111,60 @@ class FunctionResolution( * aligned with relation order. */ private[analysis] def sqlResolutionPathEntriesForAnalysis: Seq[Seq[String]] = - catalogManager.resolutionPathEntriesForAnalysis( - AnalysisContext.get.resolutionPathEntries, - AnalysisContext.get.catalogAndNamespace) + currentResolutionPathCache.pathEntries + + /** + * Per-analysis-pass cache of the resolution search path and the derived + * "built-in precedes session" flag. + * + * Computing the path (reading the [[AnalysisContext]] thread-local, the live + * [[CatalogManager]], and several confs, then allocating `Seq`s) used to run once per + * [[UnresolvedFunction]] -- and, under Spark Connect, once per node on every re-analysis of + * the growing plan. That per-node recomputation is the SPARK-57758 regression. + * + * The path is stable within a single analysis pass: `SET PATH` / `USE` / conf changes happen + * between passes, and each pass (and each view / SQL-function body) runs under a fresh + * [[AnalysisContext]] object (see [[AnalysisContext.reset]], `withAnalysisContext`, + * `withNewAnalysisContext`). We therefore key the cache on the identity of the current + * [[AnalysisContext]] and recompute when it changes, which also avoids any staleness. + * + * A [[ThreadLocal]] is used because a single [[FunctionResolution]] instance is shared across + * concurrent query threads, each with its own [[AnalysisContext]] thread-local. + */ + private case class ResolutionPathCache( + context: AnalysisContext, + pathEntries: Seq[Seq[String]], + builtinBeforeSession: Boolean) + + private val resolutionPathCache = new ThreadLocal[ResolutionPathCache]() + + private def currentResolutionPathCache: ResolutionPathCache = { + val context = AnalysisContext.get + val cached = resolutionPathCache.get() + if (cached != null && (cached.context eq context)) { + cached + } else { + val pathEntries = catalogManager.resolutionPathEntriesForAnalysis( + context.resolutionPathEntries, context.catalogAndNamespace) + val fresh = ResolutionPathCache( + context, pathEntries, computeBuiltinBeforeSession(pathEntries)) + resolutionPathCache.set(fresh) + fresh + } + } + + /** + * True when `system.builtin` appears in the effective path and no `system.session` entry + * precedes it. In that case a single-part name that resolves to a built-in cannot be shadowed + * by a session/temporary function, and built-ins always precede persistent catalogs in every + * `sessionOrder`, so the built-in fast-path in [[resolveFunction]] / [[resolveTableFunction]] + * cannot change resolution precedence. + */ + private def computeBuiltinBeforeSession(pathEntries: Seq[Seq[String]]): Boolean = { + val builtinIdx = pathEntries.indexWhere(CatalogManager.isSystemBuiltinPathEntry) + val sessionIdx = pathEntries.indexWhere(CatalogManager.isSystemSessionPathEntry) + builtinIdx >= 0 && (sessionIdx < 0 || builtinIdx < sessionIdx) + } private def resolutionCandidates(nameParts: Seq[String]): Seq[Seq[String]] = { if (nameParts.size == 1) { @@ -189,6 +240,22 @@ class FunctionResolution( } } + // Fast-path (SPARK-57758): an unqualified, non-internal name that resolves to a built-in + // is by far the common case. When `system.builtin` precedes `system.session` in the + // effective path, a built-in hit cannot be shadowed by a session/temporary function (and a + // built-in always precedes persistent catalogs in every `sessionOrder`), so it can be + // resolved with a single registry lookup instead of building and iterating the candidate + // search path. A miss falls through to the full candidate resolution below. + if (unresolvedFunc.nameParts.size == 1 && !unresolvedFunc.isInternal && + currentResolutionPathCache.builtinBeforeSession) { + val builtin = v1SessionCatalog.resolveScalarFunctionByIdentifier( + FunctionRegistry.builtinFunctionIdentifier(unresolvedFunc.nameParts.head), + unresolvedFunc.arguments) + if (builtin.isDefined) { + return validateFunction(builtin.get, unresolvedFunc.arguments.length, unresolvedFunc) + } + } + val candidates = resolutionCandidates(unresolvedFunc.nameParts) for (nameParts <- candidates) { resolveFunctionCandidate(nameParts, unresolvedFunc) match { @@ -263,6 +330,16 @@ class FunctionResolution( def resolveTableFunction( nameParts: Seq[String], arguments: Seq[Expression]): Option[LogicalPlan] = { + // Fast-path (SPARK-57758): see `resolveFunction`. Short-circuit a single-part name to a + // built-in table function when `system.builtin` precedes `system.session` in the path; a + // miss (including a built-in scalar of the same name) falls through to the candidate loop, + // which preserves the NOT_A_TABLE_FUNCTION semantics. + if (nameParts.size == 1 && currentResolutionPathCache.builtinBeforeSession) { + val builtin = v1SessionCatalog.resolveTableFunctionByIdentifier( + FunctionRegistry.builtinFunctionIdentifier(nameParts.head), arguments) + if (builtin.isDefined) return builtin + } + val candidates = resolutionCandidates(nameParts) for (nameParts <- candidates) { resolveTableFunctionCandidate(nameParts, arguments) match { diff --git a/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala b/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala index 3aade2ddf09a8..cec80aa8f755b 100644 --- a/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala +++ b/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala @@ -1290,6 +1290,37 @@ class FunctionQualificationSuite extends SharedSparkSession { checkAnswer(sql("SELECT system.builtin.abs(-5)"), Row(5)) } } + + test("SECTION 17a: SPARK-57758 built-in fast-path respects dynamic session order") { + withTempView("t") { + sql("CREATE TEMPORARY VIEW t AS SELECT 1 AS id") + // Register a temp function shadowing builtin `abs` (overrideIfExists allows any order). + spark.udf.register("abs", (x: Int) => x + 100) + try { + // Default order (second): builtin precedes session, so the fast-path returns the builtin + // and the temp is reachable only via `session.` qualification. + withSQLConf("spark.sql.functionResolution.sessionOrder" -> "second") { + checkAnswer(sql("SELECT abs(-5) FROM t"), Row(5)) + checkAnswer(sql("SELECT session.abs(-5) FROM t"), Row(95)) + } + // first: session precedes builtin, so the fast-path is bypassed and the temp shadows the + // builtin for unqualified names. Same session, so this also exercises per-pass recompute. + withSQLConf("spark.sql.functionResolution.sessionOrder" -> "first") { + checkAnswer(sql("SELECT abs(-5) FROM t"), Row(95)) + checkAnswer(sql("SELECT builtin.abs(-5) FROM t"), Row(5)) + } + } finally { + spark.sessionState.catalog.dropTempFunction("abs", ignoreIfNotExists = true) + } + } + } + + test("SECTION 17b: SPARK-57758 built-in table-function fast-path") { + // Built-in table function resolves via the fast path. + checkAnswer(sql("SELECT * FROM range(3)"), Seq(Row(0), Row(1), Row(2))) + // Extension table functions (stored as builtins) also resolve unqualified. + checkAnswer(sql("SELECT * FROM test_ext_table_func()"), Seq(Row(0L), Row(1L), Row(2L))) + } } /** From 4f5bea53a34e12d188574fc3e38fd88bfbbf019c Mon Sep 17 00:00:00 2001 From: Max Gekk Date: Tue, 30 Jun 2026 00:43:35 +0200 Subject: [PATCH 2/7] [SPARK-57758][SQL][FOLLOWUP] Gate the built-in fast-path on system.builtin being first in the path The fast-path safety gate previously only checked that system.builtin precedes system.session. A custom `SET PATH ., system.builtin` places a catalog/schema entry before system.builtin, where an unqualified name found in that schema must win over the built-in; the old gate still enabled the fast-path there, silently returning the built-in. Require system.builtin to be the first path entry instead. This keeps the fast-path on for every default sessionOrder mode and disables it only when another entry precedes system.builtin. Adds SECTION 17c regression test. Co-authored-by: Isaac --- .../analysis/FunctionResolution.scala | 47 ++++++++++--------- .../sql/FunctionQualificationSuite.scala | 28 +++++++++++ 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala index 59dff41b10b3d..e4eebd07de26a 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala @@ -134,7 +134,7 @@ class FunctionResolution( private case class ResolutionPathCache( context: AnalysisContext, pathEntries: Seq[Seq[String]], - builtinBeforeSession: Boolean) + builtinFastPathSafe: Boolean) private val resolutionPathCache = new ThreadLocal[ResolutionPathCache]() @@ -147,24 +147,27 @@ class FunctionResolution( val pathEntries = catalogManager.resolutionPathEntriesForAnalysis( context.resolutionPathEntries, context.catalogAndNamespace) val fresh = ResolutionPathCache( - context, pathEntries, computeBuiltinBeforeSession(pathEntries)) + context, pathEntries, computeBuiltinFastPathSafe(pathEntries)) resolutionPathCache.set(fresh) fresh } } /** - * True when `system.builtin` appears in the effective path and no `system.session` entry - * precedes it. In that case a single-part name that resolves to a built-in cannot be shadowed - * by a session/temporary function, and built-ins always precede persistent catalogs in every - * `sessionOrder`, so the built-in fast-path in [[resolveFunction]] / [[resolveTableFunction]] - * cannot change resolution precedence. + * True when `system.builtin` is the first entry of the effective resolution path. In that case a + * single-part name that resolves to a built-in cannot be shadowed by any earlier entry -- neither + * a `system.session` entry (a temporary/session function) nor a catalog/schema entry placed + * before `system.builtin` by a custom `SET PATH` -- so the built-in fast-path in + * [[resolveFunction]] / [[resolveTableFunction]] cannot change resolution precedence. A miss + * still falls through to the full candidate loop, so non-built-in names are unaffected. + * + * In every default `spark.sql.functionResolution.sessionOrder` mode `system.builtin` is the first + * entry (`second` / `last`) except `first`, where `system.session` precedes it and the fast-path + * is correctly disabled; only a custom `SET PATH` can place another entry before + * `system.builtin`. */ - private def computeBuiltinBeforeSession(pathEntries: Seq[Seq[String]]): Boolean = { - val builtinIdx = pathEntries.indexWhere(CatalogManager.isSystemBuiltinPathEntry) - val sessionIdx = pathEntries.indexWhere(CatalogManager.isSystemSessionPathEntry) - builtinIdx >= 0 && (sessionIdx < 0 || builtinIdx < sessionIdx) - } + private def computeBuiltinFastPathSafe(pathEntries: Seq[Seq[String]]): Boolean = + pathEntries.headOption.exists(CatalogManager.isSystemBuiltinPathEntry) private def resolutionCandidates(nameParts: Seq[String]): Seq[Seq[String]] = { if (nameParts.size == 1) { @@ -241,13 +244,13 @@ class FunctionResolution( } // Fast-path (SPARK-57758): an unqualified, non-internal name that resolves to a built-in - // is by far the common case. When `system.builtin` precedes `system.session` in the - // effective path, a built-in hit cannot be shadowed by a session/temporary function (and a - // built-in always precedes persistent catalogs in every `sessionOrder`), so it can be - // resolved with a single registry lookup instead of building and iterating the candidate - // search path. A miss falls through to the full candidate resolution below. + // is by far the common case. When `system.builtin` is the first entry of the effective path, + // a built-in hit cannot be shadowed by any earlier entry (a session/temporary function, or a + // catalog/schema placed before `system.builtin` via `SET PATH`), so it can be resolved with a + // single registry lookup instead of building and iterating the candidate search path. A miss + // falls through to the full candidate resolution below. if (unresolvedFunc.nameParts.size == 1 && !unresolvedFunc.isInternal && - currentResolutionPathCache.builtinBeforeSession) { + currentResolutionPathCache.builtinFastPathSafe) { val builtin = v1SessionCatalog.resolveScalarFunctionByIdentifier( FunctionRegistry.builtinFunctionIdentifier(unresolvedFunc.nameParts.head), unresolvedFunc.arguments) @@ -331,10 +334,10 @@ class FunctionResolution( nameParts: Seq[String], arguments: Seq[Expression]): Option[LogicalPlan] = { // Fast-path (SPARK-57758): see `resolveFunction`. Short-circuit a single-part name to a - // built-in table function when `system.builtin` precedes `system.session` in the path; a - // miss (including a built-in scalar of the same name) falls through to the candidate loop, - // which preserves the NOT_A_TABLE_FUNCTION semantics. - if (nameParts.size == 1 && currentResolutionPathCache.builtinBeforeSession) { + // built-in table function when `system.builtin` is the first entry of the path; a miss + // (including a built-in scalar of the same name) falls through to the candidate loop, which + // preserves the NOT_A_TABLE_FUNCTION semantics. + if (nameParts.size == 1 && currentResolutionPathCache.builtinFastPathSafe) { val builtin = v1SessionCatalog.resolveTableFunctionByIdentifier( FunctionRegistry.builtinFunctionIdentifier(nameParts.head), arguments) if (builtin.isDefined) return builtin diff --git a/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala b/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala index cec80aa8f755b..7b15f3276030d 100644 --- a/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala +++ b/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala @@ -1321,6 +1321,34 @@ class FunctionQualificationSuite extends SharedSparkSession { // Extension table functions (stored as builtins) also resolve unqualified. checkAnswer(sql("SELECT * FROM test_ext_table_func()"), Seq(Row(0L), Row(1L), Row(2L))) } + + test("SECTION 17c: SPARK-57758 fast-path is bypassed when a catalog precedes system.builtin") { + // SPARK-57758 regression guard: the built-in fast-path is safe only when `system.builtin` is + // the FIRST path entry. A custom `SET PATH` can place a catalog/schema before `system.builtin`, + // and an unqualified name found there must win over the built-in -- the fast-path must not + // short-circuit to the built-in. (The default `sessionOrder` modes always keep catalogs after + // `system.builtin`, so only a custom SET PATH exercises this.) + withSQLConf(SQLConf.PATH_ENABLED.key -> "true") { + withDatabase("path_abs_shadow") { + sql("CREATE DATABASE path_abs_shadow") + // Persistent function shadowing the built-in `abs`. + sql("CREATE FUNCTION path_abs_shadow.abs(x INT) RETURNS INT RETURN x * 10") + try { + // Catalog before system.builtin: the persistent `abs` must win (50), not the + // built-in (5). Pre-fix, the fast-path returned the built-in here. + sql("SET PATH = spark_catalog.path_abs_shadow, system.builtin") + checkAnswer(sql("SELECT abs(5)"), Row(50)) + // system.builtin first: the fast-path is enabled and resolves the built-in (abs(-5) = 5), + // confirming the optimization still applies when builtin leads the path. + sql("SET PATH = system.builtin, spark_catalog.path_abs_shadow") + checkAnswer(sql("SELECT abs(-5)"), Row(5)) + } finally { + sql("SET PATH = DEFAULT_PATH") + sql("DROP FUNCTION IF EXISTS path_abs_shadow.abs") + } + } + } + } } /** From 43c295b66393e6a5467b7a165106f4e7e588020f Mon Sep 17 00:00:00 2001 From: Max Gekk Date: Tue, 30 Jun 2026 00:54:08 +0200 Subject: [PATCH 3/7] [SPARK-57758][SQL][FOLLOWUP] Reword the computeBuiltinFastPathSafe Scaladoc Replace the self-contradictory "every ... mode ... except first" phrasing with a direct statement of which sessionOrder modes put system.builtin first. Co-authored-by: Isaac --- .../spark/sql/catalyst/analysis/FunctionResolution.scala | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala index e4eebd07de26a..1db582676beed 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala @@ -161,10 +161,9 @@ class FunctionResolution( * [[resolveFunction]] / [[resolveTableFunction]] cannot change resolution precedence. A miss * still falls through to the full candidate loop, so non-built-in names are unaffected. * - * In every default `spark.sql.functionResolution.sessionOrder` mode `system.builtin` is the first - * entry (`second` / `last`) except `first`, where `system.session` precedes it and the fast-path - * is correctly disabled; only a custom `SET PATH` can place another entry before - * `system.builtin`. + * The default `spark.sql.functionResolution.sessionOrder` modes `second` and `last` put + * `system.builtin` first; only `first` puts `system.session` before it, where the fast-path is + * correctly disabled. Only a custom `SET PATH` can place another entry before `system.builtin`. */ private def computeBuiltinFastPathSafe(pathEntries: Seq[Seq[String]]): Boolean = pathEntries.headOption.exists(CatalogManager.isSystemBuiltinPathEntry) From e420a5cfaedfe186fde76dcb45262703b674061d Mon Sep 17 00:00:00 2001 From: Max Gekk Date: Tue, 30 Jun 2026 00:58:40 +0200 Subject: [PATCH 4/7] [SPARK-57758][SQL][FOLLOWUP] Add table-function regression test for the catalog-before-builtin path SECTION 17d mirrors 17c for the table-function fast-path: a persistent table function in a schema placed before system.builtin via SET PATH must win over the built-in TVF of the same name, while system.builtin-first still fast-paths to the built-in. Co-authored-by: Isaac --- .../sql/FunctionQualificationSuite.scala | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala b/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala index 7b15f3276030d..4d42d839cdd53 100644 --- a/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala +++ b/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala @@ -1349,6 +1349,32 @@ class FunctionQualificationSuite extends SharedSparkSession { } } } + + test("SECTION 17d: SPARK-57758 table-function fast-path is bypassed when a catalog precedes " + + "system.builtin") { + // Table-function counterpart of SECTION 17c: the table-function fast-path shares the same + // `builtinFastPathSafe` gate, so a persistent table function in a schema placed before + // `system.builtin` via `SET PATH` must win over the built-in TVF of the same name. + withSQLConf(SQLConf.PATH_ENABLED.key -> "true") { + withDatabase("path_range_shadow") { + sql("CREATE DATABASE path_range_shadow") + // Persistent table function shadowing the built-in `range` (ignores its argument). + sql("CREATE FUNCTION path_range_shadow.range(n INT) RETURNS TABLE(id INT) RETURN SELECT 99") + try { + // Catalog before system.builtin: the persistent `range` must win (one row [99]), not the + // built-in `range(1)` (one row [0]). Pre-fix, the fast-path returned the built-in here. + sql("SET PATH = spark_catalog.path_range_shadow, system.builtin") + checkAnswer(sql("SELECT * FROM range(1)"), Row(99)) + // system.builtin first: the fast-path resolves the built-in `range(1)` (one row [0]). + sql("SET PATH = system.builtin, spark_catalog.path_range_shadow") + checkAnswer(sql("SELECT * FROM range(1)"), Row(0)) + } finally { + sql("SET PATH = DEFAULT_PATH") + sql("DROP FUNCTION IF EXISTS path_range_shadow.range") + } + } + } + } } /** From f433c3c4ccaca1c0f09d7048adea4a79a215722a Mon Sep 17 00:00:00 2001 From: Max Gekk Date: Tue, 30 Jun 2026 01:12:52 +0200 Subject: [PATCH 5/7] [SPARK-57758][SQL][FOLLOWUP] Avoid pinning the resolution-path cache context; unit-test the fast-path gate - Hold the per-pass ResolutionPathCache's AnalysisContext via a WeakReference so a finished pass's context (and its relationCache plan graph) is not pinned on the pooled thread until the next query overwrites the entry, matching the AnalysisContext.reset() lifecycle. A cleared reference reads as a cache miss and recomputes. - Tighten the cache Scaladoc: the keying is stale-free only because the cached values derive from the context's immutable fields; other context fields mutate under stable identity, so nothing derived from them may be cached under this key. - Extract the fast-path gate to the pure CatalogManager.isBuiltinFirstOnPath predicate and add a direct unit test over representative path shapes, guarding the gate (which has no behavioral signature) against silent regression. Co-authored-by: Isaac --- .../analysis/FunctionResolution.scala | 21 ++++++++++++---- .../connector/catalog/CatalogManager.scala | 9 +++++++ .../catalog/CatalogManagerSuite.scala | 24 +++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala index 1db582676beed..98333a0df9609 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala @@ -17,6 +17,7 @@ package org.apache.spark.sql.catalyst.analysis +import java.lang.ref.WeakReference import java.util.concurrent.atomic.AtomicBoolean import scala.util.control.NonFatal @@ -126,13 +127,23 @@ class FunctionResolution( * between passes, and each pass (and each view / SQL-function body) runs under a fresh * [[AnalysisContext]] object (see [[AnalysisContext.reset]], `withAnalysisContext`, * `withNewAnalysisContext`). We therefore key the cache on the identity of the current - * [[AnalysisContext]] and recompute when it changes, which also avoids any staleness. + * [[AnalysisContext]] and recompute when it changes. This is stale-free only because the cached + * values derive solely from the context's immutable fields (`resolutionPathEntries`, + * `catalogAndNamespace`); a scope change always allocates a new context. Other context fields + * (e.g. `relationCache`, `referredTempFunctionNames`) DO mutate under a stable identity, so + * nothing derived from them may be cached under this key. + * + * The reference to the [[AnalysisContext]] is held weakly: during a pass the context is strongly + * reachable via its own thread-local, but [[AnalysisContext.reset]] clears that thread-local + * between passes. Keying weakly lets the finished pass's context (and its `relationCache` plan + * graph) be collected rather than pinned on this pooled thread until the next query overwrites + * the entry; a cleared reference simply reads as a miss and recomputes. * * A [[ThreadLocal]] is used because a single [[FunctionResolution]] instance is shared across * concurrent query threads, each with its own [[AnalysisContext]] thread-local. */ private case class ResolutionPathCache( - context: AnalysisContext, + contextRef: WeakReference[AnalysisContext], pathEntries: Seq[Seq[String]], builtinFastPathSafe: Boolean) @@ -141,13 +152,13 @@ class FunctionResolution( private def currentResolutionPathCache: ResolutionPathCache = { val context = AnalysisContext.get val cached = resolutionPathCache.get() - if (cached != null && (cached.context eq context)) { + if (cached != null && (cached.contextRef.get eq context)) { cached } else { val pathEntries = catalogManager.resolutionPathEntriesForAnalysis( context.resolutionPathEntries, context.catalogAndNamespace) val fresh = ResolutionPathCache( - context, pathEntries, computeBuiltinFastPathSafe(pathEntries)) + new WeakReference(context), pathEntries, computeBuiltinFastPathSafe(pathEntries)) resolutionPathCache.set(fresh) fresh } @@ -166,7 +177,7 @@ class FunctionResolution( * correctly disabled. Only a custom `SET PATH` can place another entry before `system.builtin`. */ private def computeBuiltinFastPathSafe(pathEntries: Seq[Seq[String]]): Boolean = - pathEntries.headOption.exists(CatalogManager.isSystemBuiltinPathEntry) + CatalogManager.isBuiltinFirstOnPath(pathEntries) private def resolutionCandidates(nameParts: Seq[String]): Seq[Seq[String]] = { if (nameParts.size == 1) { diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogManager.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogManager.scala index 4dd8af5eb37ef..19b01ffc3b83c 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogManager.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogManager.scala @@ -588,6 +588,15 @@ private[sql] object CatalogManager extends Logging { parts.head.equalsIgnoreCase(SYSTEM_CATALOG_NAME) && parts(1).equalsIgnoreCase(BUILTIN_NAMESPACE) + /** + * True when `system.builtin` is the first entry of `pathEntries`. This is the path-shape + * condition under which a built-in function found by an unqualified single-part name cannot be + * shadowed by any earlier path entry -- the precise property the function-resolution built-in + * fast-path relies on. Pure predicate over the path shape; callers decide how to use it. + */ + def isBuiltinFirstOnPath(pathEntries: Seq[Seq[String]]): Boolean = + pathEntries.headOption.exists(isSystemBuiltinPathEntry) + /** * Extract `system.builtin` / `system.session` entries from a resolved PATH, mapped to * [[SessionCatalog.SessionFunctionKind]] in path order. Pure data conversion -- callers diff --git a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogManagerSuite.scala b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogManagerSuite.scala index 199e43d39bbe1..b5f388b8ce870 100644 --- a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogManagerSuite.scala +++ b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogManagerSuite.scala @@ -188,6 +188,30 @@ class CatalogManagerSuite extends SparkFunSuite with SQLHelper { assert(e.getMessage.contains("default.v_broken")) } + test("isBuiltinFirstOnPath: SPARK-57758 gate for the built-in function fast-path") { + // The function-resolution built-in fast-path is safe (cannot be shadowed) only when + // `system.builtin` is the FIRST path entry. The fast-path has no behavioral signature -- the + // slow candidate loop yields identical results -- so this pure-predicate test guards the gate + // directly: a regression that silently stopped the fast-path from firing (re-introducing the + // SPARK-57758 perf regression) or fired it when a catalog precedes `system.builtin` + // (re-introducing the precedence bug) would leave the behavioral SQL tests green. + val builtin = Seq("system", "builtin") + val session = Seq("system", "session") + val catalog = Seq("spark_catalog", "some_schema") + // Default `sessionOrder` modes `second` / `last` keep `system.builtin` first -> safe. + assert(CatalogManager.isBuiltinFirstOnPath(Seq(builtin, session))) + assert(CatalogManager.isBuiltinFirstOnPath(Seq(builtin))) + assert(CatalogManager.isBuiltinFirstOnPath(Seq(builtin, catalog, session))) + // Case-insensitive match on the well-known entry. + assert(CatalogManager.isBuiltinFirstOnPath(Seq(Seq("System", "Builtin"), session))) + // `first` mode (session before builtin) and custom `SET PATH` shapes that place any entry + // before `system.builtin` -> not safe. + assert(!CatalogManager.isBuiltinFirstOnPath(Seq(session, builtin))) + assert(!CatalogManager.isBuiltinFirstOnPath(Seq(catalog, builtin))) + assert(!CatalogManager.isBuiltinFirstOnPath(Seq(catalog))) + assert(!CatalogManager.isBuiltinFirstOnPath(Seq.empty)) + } + // --------------------------------------------------------------------------- // Direct unit tests for [[PathElement.validateNoStaticDuplicates]]. The end-to-end // `SetPathSuite` exercises this via SQL, but the duplicate-detection rules From 9cb1be0e13090eb4fbd19f8a9ffa8c3d8a30be60 Mon Sep 17 00:00:00 2001 From: Max Gekk Date: Tue, 30 Jun 2026 07:59:35 +0200 Subject: [PATCH 6/7] [SPARK-57758][SQL][FOLLOWUP] Move the resolution-path memo onto AnalysisContext; add invariant tests Per review feedback, simplify the per-pass memo and lock in two subtle invariants: - Move the resolution-path memo from a separate ThreadLocal[ResolutionPathCache] on FunctionResolution onto AnalysisContext itself (a lazily-filled body field). Because the memo now shares the context's per-pass lifetime -- a fresh context (reset / withNewAnalysisContext / the copy or construction for a view or SQL-function body) starts with an empty memo and is collected with the context -- the ThreadLocal, the WeakReference (no pinning of a finished pass's relationCache plan graph), and the eq-identity key all go away. builtinFastPathSafe now reads the memoized path (O(1) per UnresolvedFunction). - SECTION 17e: the fast-path raises the built-in's argument error rather than falling through to a same-named session function, matching the slow candidate loop (both fail on the same candidate when system.builtin leads). - SECTION 17f: the per-pass memo recomputes for a SQL-function body whose pinned path differs from the caller, so a single statement yields both resolutions (neither context reuses the other's memo). - Cross-reference comment between the fast-path and resolveFunctionCandidate's system.builtin. branch, which must stay equivalent. Co-authored-by: Isaac --- .../sql/catalyst/analysis/Analyzer.scala | 24 ++++++ .../analysis/FunctionResolution.scala | 78 ++++++------------- .../sql/FunctionQualificationSuite.scala | 59 ++++++++++++++ 3 files changed, 107 insertions(+), 54 deletions(-) diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala index 6cd985fd01fa0..ae30bf51c58d7 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala @@ -182,6 +182,30 @@ case class AnalysisContext( def getSinglePassResolverBridgeState: Option[AnalyzerBridgeState] = singlePassResolverBridgeState + + /** + * Per-pass memo of the SQL resolution search path (SPARK-57758). Function resolution computes + * the ordered path once per analysis pass and reuses it for every [[UnresolvedFunction]], + * instead of rebuilding and re-iterating it per node -- the cost that, under Spark Connect's + * repeated re-analysis of the growing plan, scaled with plan size x analyze calls. + * + * The memo lives on the context so it shares the context's per-pass lifetime. `SET PATH` / + * `USE` / conf changes all produce a fresh context ([[reset]], [[withNewAnalysisContext]], or + * the `copy` / construction for a view or SQL-function body), and a body-level field is not + * carried over by `copy`, so a new pass automatically starts with an empty memo and the memo is + * collected with the context. It is therefore safe without an identity key or weak reference, + * but only for values derived from this context's immutable fields (the path derives from + * `resolutionPathEntries` / `catalogAndNamespace`); never memoize anything derived from the + * mutable fields above (`relationCache`, `referredTempFunctionNames`, ...). + */ + private var resolutionPathMemo: Seq[Seq[String]] = _ + + def memoizedResolutionPath(compute: => Seq[Seq[String]]): Seq[Seq[String]] = { + if (resolutionPathMemo == null) { + resolutionPathMemo = compute + } + resolutionPathMemo + } } object AnalysisContext { diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala index 98333a0df9609..543e9da670a6e 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala @@ -17,7 +17,6 @@ package org.apache.spark.sql.catalyst.analysis -import java.lang.ref.WeakReference import java.util.concurrent.atomic.AtomicBoolean import scala.util.control.NonFatal @@ -111,56 +110,17 @@ class FunctionResolution( * directly, matching [[RelationResolution.relationResolutionEntries]] so routine order stays * aligned with relation order. */ - private[analysis] def sqlResolutionPathEntriesForAnalysis: Seq[Seq[String]] = - currentResolutionPathCache.pathEntries - - /** - * Per-analysis-pass cache of the resolution search path and the derived - * "built-in precedes session" flag. - * - * Computing the path (reading the [[AnalysisContext]] thread-local, the live - * [[CatalogManager]], and several confs, then allocating `Seq`s) used to run once per - * [[UnresolvedFunction]] -- and, under Spark Connect, once per node on every re-analysis of - * the growing plan. That per-node recomputation is the SPARK-57758 regression. - * - * The path is stable within a single analysis pass: `SET PATH` / `USE` / conf changes happen - * between passes, and each pass (and each view / SQL-function body) runs under a fresh - * [[AnalysisContext]] object (see [[AnalysisContext.reset]], `withAnalysisContext`, - * `withNewAnalysisContext`). We therefore key the cache on the identity of the current - * [[AnalysisContext]] and recompute when it changes. This is stale-free only because the cached - * values derive solely from the context's immutable fields (`resolutionPathEntries`, - * `catalogAndNamespace`); a scope change always allocates a new context. Other context fields - * (e.g. `relationCache`, `referredTempFunctionNames`) DO mutate under a stable identity, so - * nothing derived from them may be cached under this key. - * - * The reference to the [[AnalysisContext]] is held weakly: during a pass the context is strongly - * reachable via its own thread-local, but [[AnalysisContext.reset]] clears that thread-local - * between passes. Keying weakly lets the finished pass's context (and its `relationCache` plan - * graph) be collected rather than pinned on this pooled thread until the next query overwrites - * the entry; a cleared reference simply reads as a miss and recomputes. - * - * A [[ThreadLocal]] is used because a single [[FunctionResolution]] instance is shared across - * concurrent query threads, each with its own [[AnalysisContext]] thread-local. - */ - private case class ResolutionPathCache( - contextRef: WeakReference[AnalysisContext], - pathEntries: Seq[Seq[String]], - builtinFastPathSafe: Boolean) - - private val resolutionPathCache = new ThreadLocal[ResolutionPathCache]() - - private def currentResolutionPathCache: ResolutionPathCache = { + private[analysis] def sqlResolutionPathEntriesForAnalysis: Seq[Seq[String]] = { + // Per-analysis-pass memo (SPARK-57758): computing the path (reading the live [[CatalogManager]] + // and several confs, then allocating `Seq`s) used to run once per [[UnresolvedFunction]], and + // under Spark Connect once per node on every re-analysis of the growing plan. The path is + // stable within a pass (`SET PATH` / `USE` / conf changes happen between passes, each under a + // fresh [[AnalysisContext]]), so it is memoized on the current context, which shares the pass's + // lifetime. See [[AnalysisContext.memoizedResolutionPath]] for why this needs no identity key. val context = AnalysisContext.get - val cached = resolutionPathCache.get() - if (cached != null && (cached.contextRef.get eq context)) { - cached - } else { - val pathEntries = catalogManager.resolutionPathEntriesForAnalysis( + context.memoizedResolutionPath { + catalogManager.resolutionPathEntriesForAnalysis( context.resolutionPathEntries, context.catalogAndNamespace) - val fresh = ResolutionPathCache( - new WeakReference(context), pathEntries, computeBuiltinFastPathSafe(pathEntries)) - resolutionPathCache.set(fresh) - fresh } } @@ -175,9 +135,12 @@ class FunctionResolution( * The default `spark.sql.functionResolution.sessionOrder` modes `second` and `last` put * `system.builtin` first; only `first` puts `system.session` before it, where the fast-path is * correctly disabled. Only a custom `SET PATH` can place another entry before `system.builtin`. + * + * Reads the per-pass memoized path ([[sqlResolutionPathEntriesForAnalysis]]), so the check is + * O(1) per [[UnresolvedFunction]]. */ - private def computeBuiltinFastPathSafe(pathEntries: Seq[Seq[String]]): Boolean = - CatalogManager.isBuiltinFirstOnPath(pathEntries) + private def builtinFastPathSafe: Boolean = + CatalogManager.isBuiltinFirstOnPath(sqlResolutionPathEntriesForAnalysis) private def resolutionCandidates(nameParts: Seq[String]): Seq[Seq[String]] = { if (nameParts.size == 1) { @@ -198,6 +161,12 @@ class FunctionResolution( private def resolveFunctionCandidate( nameParts: Seq[String], unresolvedFunc: UnresolvedFunction): Option[Expression] = { + // NOTE: the `system.builtin.` case here is the same registry lookup the built-in + // fast-path in `resolveFunction` performs directly (both go through + // `identifierFromSystemNameParts` / `builtinFunctionIdentifier` -> + // `resolveScalarFunctionByIdentifier`). The two must stay equivalent; a change to built-in + // scalar resolution has to touch both. `resolveTableFunctionCandidate` / `resolveTableFunction` + // mirror this for table functions. if (isSystemCatalogQualified(nameParts)) { v1SessionCatalog.identifierFromSystemNameParts(nameParts).flatMap { ident => val expr = v1SessionCatalog.resolveScalarFunctionByIdentifier( @@ -258,9 +227,10 @@ class FunctionResolution( // a built-in hit cannot be shadowed by any earlier entry (a session/temporary function, or a // catalog/schema placed before `system.builtin` via `SET PATH`), so it can be resolved with a // single registry lookup instead of building and iterating the candidate search path. A miss - // falls through to the full candidate resolution below. + // falls through to the full candidate resolution below. This lookup is equivalent to the + // `system.builtin.` branch of `resolveFunctionCandidate`; keep the two in sync. if (unresolvedFunc.nameParts.size == 1 && !unresolvedFunc.isInternal && - currentResolutionPathCache.builtinFastPathSafe) { + builtinFastPathSafe) { val builtin = v1SessionCatalog.resolveScalarFunctionByIdentifier( FunctionRegistry.builtinFunctionIdentifier(unresolvedFunc.nameParts.head), unresolvedFunc.arguments) @@ -347,7 +317,7 @@ class FunctionResolution( // built-in table function when `system.builtin` is the first entry of the path; a miss // (including a built-in scalar of the same name) falls through to the candidate loop, which // preserves the NOT_A_TABLE_FUNCTION semantics. - if (nameParts.size == 1 && currentResolutionPathCache.builtinFastPathSafe) { + if (nameParts.size == 1 && builtinFastPathSafe) { val builtin = v1SessionCatalog.resolveTableFunctionByIdentifier( FunctionRegistry.builtinFunctionIdentifier(nameParts.head), arguments) if (builtin.isDefined) return builtin diff --git a/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala b/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala index 4d42d839cdd53..6633dbb80f1db 100644 --- a/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala +++ b/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala @@ -1375,6 +1375,65 @@ class FunctionQualificationSuite extends SharedSparkSession { } } } + + test("SECTION 17e: SPARK-57758 built-in fast-path raises the built-in's argument error rather " + + "than falling through to a same-named session function") { + // Invariant: the fast-path and the slow candidate loop must fail on the SAME candidate. + // `resolveScalarFunctionByIdentifier` returns None only when the built-in is absent; an + // existing built-in invoked with bad arity throws. So with `system.builtin` first, an + // unqualified `abs(1, 2)` must hit the built-in `abs` (1-arg) and raise its argument error -- + // it must NOT silently fall through to a compatible 2-arg session `abs`. The slow loop would + // also hit `system.builtin.abs` first and throw, so the two paths stay equivalent. + withTempView("t") { + sql("CREATE TEMPORARY VIEW t AS SELECT 1 AS id") + spark.udf.register("abs", (x: Int, y: Int) => x + y) + try { + withSQLConf("spark.sql.functionResolution.sessionOrder" -> "second") { + // The 2-arg session function is reachable via explicit `session.` qualification... + checkAnswer(sql("SELECT session.abs(1, 2) FROM t"), Row(3)) + // ...but the unqualified name resolves to the built-in `abs` first and raises its + // argument error instead of resolving the 2-arg session function. + intercept[AnalysisException](sql("SELECT abs(1, 2) FROM t").collect()) + } + } finally { + spark.sessionState.catalog.dropTempFunction("abs", ignoreIfNotExists = true) + } + } + } + + test("SECTION 17f: SPARK-57758 per-pass memo recomputes for a SQL-function body whose pinned " + + "path differs from the caller") { + // The subtlest recompute trigger: a SQL-function body runs under a FRESH AnalysisContext + // carrying the function's own stored resolution path (not the caller's). The memo lives on the + // context, so the body computes its own path within the same analysis pass as the outer query. + // Here the body's `abs` is pinned to the built-in (created under a builtin-first path) while + // the caller resolves the unqualified `abs` to a shadowing persistent function (catalog-first) + // -- so a single statement must yield both resolutions, proving neither context reuses the + // other's memo. + withSQLConf(SQLConf.PATH_ENABLED.key -> "true") { + withDatabase("path_body") { + sql("CREATE DATABASE path_body") + // Persistent `abs` shadowing the built-in for the caller's catalog-first path. + sql("CREATE FUNCTION path_body.abs(x INT) RETURNS INT RETURN x * 10") + try { + // Create the function body while `system.builtin` leads -> the body's unqualified `abs` + // is pinned to the built-in. + sql("SET PATH = system.builtin, spark_catalog.path_body") + sql("CREATE FUNCTION path_body.use_abs(x INT) RETURNS INT RETURN abs(x)") + // Caller path puts the catalog first -> a top-level unqualified `abs` resolves to the + // persistent `abs` (x * 10), while the body keeps resolving its `abs` to the built-in. + sql("SET PATH = spark_catalog.path_body, system.builtin") + checkAnswer( + sql("SELECT abs(-5) AS top, path_body.use_abs(-5) AS body"), + Row(-50, 5)) + } finally { + sql("SET PATH = DEFAULT_PATH") + sql("DROP FUNCTION IF EXISTS path_body.use_abs") + sql("DROP FUNCTION IF EXISTS path_body.abs") + } + } + } + } } /** From df49fe1681313885e0fb67112bafc8f244ccc243 Mon Sep 17 00:00:00 2001 From: Max Gekk Date: Tue, 30 Jun 2026 08:25:36 +0200 Subject: [PATCH 7/7] [SPARK-57758][SQL][FOLLOWUP] Document the resolution-path memo body-var invariant and clarify the 17b smoke test - Add an INVARIANT note on AnalysisContext.resolutionPathMemo: it must stay a body var (never a constructor parameter), since .copy() deliberately does not carry it -- that is what gives a SQL-function-body / outer-plan context a fresh memo. Promoting it to a parameter would copy a stale path across that boundary and silently mis-resolve (SECTION 17f guards this). - Clarify SECTION 17b's comment: with system.builtin leading the default path it yields identical rows whether or not the fast-path fires, so it is a smoke test; the gate's on/off signal is asserted by CatalogManagerSuite.isBuiltinFirstOnPath and SECTION 17c/17d/17e. Co-authored-by: Isaac --- .../org/apache/spark/sql/catalyst/analysis/Analyzer.scala | 6 ++++++ .../org/apache/spark/sql/FunctionQualificationSuite.scala | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala index ae30bf51c58d7..b472003f9ab78 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala @@ -197,6 +197,12 @@ case class AnalysisContext( * but only for values derived from this context's immutable fields (the path derives from * `resolutionPathEntries` / `catalogAndNamespace`); never memoize anything derived from the * mutable fields above (`relationCache`, `referredTempFunctionNames`, ...). + * + * INVARIANT: keep this a body `var`, never a constructor parameter. `.copy()` (used by + * `withAnalysisContext(function)` and `withOuterPlan`) deliberately does not carry a body + * field, which is what gives a SQL-function-body / outer-plan context a fresh memo. Promoting + * it to a parameter would copy a stale path across that boundary and silently mis-resolve + * (SECTION 17f of `FunctionQualificationSuite` is the regression guard). */ private var resolutionPathMemo: Seq[Seq[String]] = _ diff --git a/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala b/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala index 6633dbb80f1db..185956229699b 100644 --- a/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala +++ b/sql/core/src/test/scala/org/apache/spark/sql/FunctionQualificationSuite.scala @@ -1316,7 +1316,11 @@ class FunctionQualificationSuite extends SharedSparkSession { } test("SECTION 17b: SPARK-57758 built-in table-function fast-path") { - // Built-in table function resolves via the fast path. + // Smoke test that built-in / extension table functions resolve unqualified. Under the default + // path `system.builtin` leads, so the slow loop's first candidate is already the built-in -- + // this yields the same rows whether or not the fast-path fires, and so does not by itself + // distinguish the gate's on/off behavior. That signal is asserted by + // CatalogManagerSuite.isBuiltinFirstOnPath and by SECTION 17c/17d/17e. checkAnswer(sql("SELECT * FROM range(3)"), Seq(Row(0), Row(1), Row(2))) // Extension table functions (stored as builtins) also resolve unqualified. checkAnswer(sql("SELECT * FROM test_ext_table_func()"), Seq(Row(0L), Row(1L), Row(2L)))