Skip to content

Commit dc1baf8

Browse files
committed
[Spark] Route Delta loadTable through the Delta REST API
When `deltaRestApi.enabled` is set on a UC catalog, AbstractDeltaCatalog routes table loads through UCDeltaCatalogClientImpl, backed by UCDeltaTokenBasedRestClient. Non-Delta tables fall back to the legacy delegate. Signed-off-by: Yi Li <yi.li@databricks.com>
1 parent 901dfb6 commit dc1baf8

5 files changed

Lines changed: 573 additions & 4 deletions

File tree

spark/src/main/scala/org/apache/spark/sql/delta/catalog/AbstractDeltaCatalog.scala

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import org.apache.spark.sql.execution.datasources.{DataSource, PartitioningUtils
6363
import org.apache.spark.sql.internal.SQLConf
6464
import org.apache.spark.sql.sources.InsertableRelation
6565
import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
66+
import org.apache.spark.sql.util.CaseInsensitiveStringMap
6667

6768

6869
/**
@@ -83,6 +84,15 @@ class AbstractDeltaCatalog extends DelegatingCatalogExtension
8384

8485
val spark = SparkSession.active
8586

87+
/** Non-null when the catalog opted into the Delta REST API path via `deltaRestApi.enabled`. */
88+
private[catalog] var deltaCatalogClient: AbstractDeltaCatalogClient = null
89+
90+
override def initialize(name: String, options: CaseInsensitiveStringMap): Unit = {
91+
super.initialize(name, options)
92+
deltaCatalogClient =
93+
AbstractDeltaCatalogClient.fromCatalogOptionsIfEnabled(name, options, super.loadTable)
94+
}
95+
8696
private lazy val isUnityCatalog: Boolean = {
8797
val delegateField = classOf[DelegatingCatalogExtension].getDeclaredField("delegate")
8898
delegateField.setAccessible(true)
@@ -290,7 +300,11 @@ class AbstractDeltaCatalog extends DelegatingCatalogExtension
290300
"DeltaCatalog", "loadTable") {
291301
setVariantBlockingConfigIfUC()
292302
try {
293-
val table = super.loadTable(ident)
303+
val table = if (deltaCatalogClient != null) {
304+
deltaCatalogClient.loadTable(ident)
305+
} else {
306+
super.loadTable(ident)
307+
}
294308

295309
ServerSidePlannedTable.tryCreate(spark, ident, table, isUnityCatalog).foreach { sspt =>
296310
return sspt
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright (2026) The Delta Lake Project Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.apache.spark.sql.delta.catalog
18+
19+
import org.apache.spark.internal.Logging
20+
import org.apache.spark.sql.connector.catalog.{Identifier, Table}
21+
import org.apache.spark.sql.util.CaseInsensitiveStringMap
22+
23+
/**
24+
* Backend hook used by [[AbstractDeltaCatalog]] for catalog-specific table loading; isolates
25+
* catalog-client and credential-vending plumbing from `AbstractDeltaCatalog`.
26+
*/
27+
private[catalog] trait AbstractDeltaCatalogClient {
28+
29+
/**
30+
* @throws org.apache.spark.sql.catalyst.analysis.NoSuchTableException if the catalog has
31+
* no record of this identifier
32+
*/
33+
def loadTable(ident: Identifier): Table
34+
}
35+
36+
/** Builds a [[AbstractDeltaCatalogClient]] from catalog options. */
37+
private[catalog] trait AbstractDeltaCatalogClientFactory {
38+
def fromCatalogOptions(
39+
catalogName: String,
40+
options: CaseInsensitiveStringMap,
41+
fallbackLoadTable: Identifier => Table): AbstractDeltaCatalogClient
42+
}
43+
44+
private[catalog] object AbstractDeltaCatalogClient extends Logging {
45+
46+
private val UC_DELTA_REST_API_ENABLED_KEY: String = "deltaRestApi.enabled"
47+
private val UC_DELTA_CATALOG_CLIENT_IMPL_CLASS_NAME: String =
48+
"org.apache.spark.sql.delta.catalog.UCDeltaCatalogClientImpl"
49+
50+
/**
51+
* Returns a [[AbstractDeltaCatalogClient]] when the catalog opted in via `deltaRestApi.enabled`,
52+
* else `null`. The concrete impl is loaded reflectively so [[AbstractDeltaCatalog]] doesn't
53+
* compile-depend on it; environments that don't ship [[UCDeltaCatalogClientImpl]] degrade
54+
* to `null`.
55+
*/
56+
def fromCatalogOptionsIfEnabled(
57+
catalogName: String,
58+
options: CaseInsensitiveStringMap,
59+
fallbackLoadTable: Identifier => Table): AbstractDeltaCatalogClient = {
60+
if (options.getBoolean(UC_DELTA_REST_API_ENABLED_KEY, false)) {
61+
val factory = try {
62+
// scalastyle:off classforname
63+
val cls = Class.forName(UC_DELTA_CATALOG_CLIENT_IMPL_CLASS_NAME + "$")
64+
// scalastyle:on classforname
65+
cls.getField("MODULE$").get(null).asInstanceOf[AbstractDeltaCatalogClientFactory]
66+
} catch {
67+
case _: ClassNotFoundException =>
68+
logWarning(s"'$UC_DELTA_REST_API_ENABLED_KEY' is true but " +
69+
s"$UC_DELTA_CATALOG_CLIENT_IMPL_CLASS_NAME is not on the classpath; skipping it.")
70+
return null
71+
}
72+
factory.fromCatalogOptions(catalogName, options, fallbackLoadTable)
73+
} else {
74+
null
75+
}
76+
}
77+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/*
2+
* Copyright (2026) The Delta Lake Project Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.apache.spark.sql.delta.catalog
18+
19+
import java.net.URI
20+
import java.util.concurrent.atomic.AtomicLong
21+
import java.util.function.Supplier
22+
23+
import scala.jdk.CollectionConverters._
24+
25+
import io.delta.storage.commit.{TableIdentifier => StorageTableIdentifier}
26+
import io.delta.storage.commit.uccommitcoordinator.{
27+
UCDeltaClient,
28+
UCDeltaModels,
29+
UCDeltaTokenBasedRestClient
30+
}
31+
import io.delta.storage.commit.uccommitcoordinator.UCDeltaModels.TableInfo
32+
import io.delta.storage.commit.uccommitcoordinator.exceptions.{
33+
CredentialFetchFailedException,
34+
UnsupportedTableFormatException,
35+
NoSuchTableException => StorageNoSuchTableException
36+
}
37+
import org.apache.hadoop.conf.Configuration
38+
39+
import org.apache.spark.internal.{Logging, MDC}
40+
import org.apache.spark.sql.SparkSession
41+
import org.apache.spark.sql.catalyst.TableIdentifier
42+
import org.apache.spark.sql.catalyst.analysis.NoSuchTableException
43+
import org.apache.spark.sql.catalyst.catalog.{
44+
CatalogStorageFormat,
45+
CatalogTable,
46+
CatalogTableType
47+
}
48+
import org.apache.spark.sql.connector.catalog.{Identifier, Table, V1Table}
49+
import org.apache.spark.sql.delta.coordinatedcommits.UCTokenBasedRestClientFactory
50+
import org.apache.spark.sql.delta.logging.DeltaLogKeys
51+
import org.apache.spark.sql.delta.sources.DeltaSQLConf
52+
import org.apache.spark.sql.types.{DataType, StructType}
53+
import org.apache.spark.sql.util.CaseInsensitiveStringMap
54+
55+
/**
56+
* [[AbstractDeltaCatalogClient]] backed by a [[UCDeltaClient]]; translates between Spark/Delta types
57+
* and the storage-side UC types.
58+
*/
59+
private[catalog] class UCDeltaCatalogClientImpl(
60+
catalogName: String,
61+
ucClient: UCDeltaClient,
62+
serverSidePlanningEnabled: Boolean = false,
63+
fallbackLoadTable: Identifier => Table = UCDeltaCatalogClientImpl.defaultFallbackLoadTable)
64+
extends AbstractDeltaCatalogClient with Logging {
65+
66+
override def loadTable(ident: Identifier): Table = {
67+
UCDeltaCatalogClientImpl.LOAD_TABLE_INVOCATIONS.incrementAndGet()
68+
val tid = toStorageTableIdent(ident)
69+
val info =
70+
try ucClient.loadTable(tid)
71+
catch {
72+
case _: StorageNoSuchTableException => throw new NoSuchTableException(ident)
73+
case e: UnsupportedTableFormatException =>
74+
logInfo(log"Table ${MDC(DeltaLogKeys.TABLE_NAME, ident)} is not in Delta format; " +
75+
log"falling back to the legacy catalog path. Cause: " +
76+
log"${MDC(DeltaLogKeys.EXCEPTION, e.getMessage)}")
77+
return fallbackLoadTable(ident)
78+
case e: CredentialFetchFailedException if serverSidePlanningEnabled =>
79+
logWarning(
80+
s"Credential fetch failed for ${fullQualifiedTableName(tid)}; enabling " +
81+
s"server-side planning fallback. Cause: ${e.getMessage}")
82+
enableServerSidePlanningConfig(ident)
83+
e.getTableInfoWithoutCredentials
84+
}
85+
UCDeltaCatalogClientImpl.SUCCESSFUL_DELTA_REST_API_LOADS.incrementAndGet()
86+
toV1Table(ident, info)
87+
}
88+
89+
private def enableServerSidePlanningConfig(ident: Identifier): Unit = {
90+
SparkSession.getActiveSession match {
91+
case Some(spark) =>
92+
spark.conf.set(DeltaSQLConf.ENABLE_SERVER_SIDE_PLANNING.key, "true")
93+
logInfo(log"Server-side planning enabled for table " +
94+
log"${MDC(DeltaLogKeys.TABLE_NAME, ident)}; Delta will read via SSP with empty creds.")
95+
case None =>
96+
logWarning(log"Server-side planning requested for table " +
97+
log"${MDC(DeltaLogKeys.TABLE_NAME, ident)} but no active SparkSession found.")
98+
}
99+
}
100+
101+
// ---------- conversions ----------
102+
103+
private def toStorageTableIdent(ident: Identifier): StorageTableIdentifier = {
104+
val ns = ident.namespace()
105+
require(
106+
ns.length == 1,
107+
s"UC identifiers must be of the form <schema>.<table>; got namespace ${ns.mkString(".")}")
108+
new StorageTableIdentifier(Array(catalogName, ns(0)), ident.name())
109+
}
110+
111+
/** Three-part dotted name from a `[catalog, schema]` + `name` storage identifier. */
112+
private def fullQualifiedTableName(t: StorageTableIdentifier): String = {
113+
val ns = t.getNamespace
114+
s"${ns(0)}.${ns(1)}.${t.getName}"
115+
}
116+
117+
private def toV1Table(ident: Identifier, info: TableInfo): V1Table = {
118+
val m = info.getMetadata
119+
val properties = Option(m.getConfiguration)
120+
.map(_.asScala.toMap)
121+
.getOrElse(Map.empty[String, String])
122+
val partitionColumns = Option(m.getPartitionColumns)
123+
.map(_.asScala.toSeq)
124+
.getOrElse(Seq.empty[String])
125+
val schema = Option(m.getSchemaString)
126+
.map(DataType.fromJson(_).asInstanceOf[StructType])
127+
.getOrElse(new StructType())
128+
val storage = CatalogStorageFormat.empty.copy(
129+
locationUri = Some(new URI(info.getLocation)),
130+
properties = properties ++ info.getStorageProperties.asScala.toMap)
131+
val catalogTable = CatalogTable(
132+
identifier = TableIdentifier(ident.name(), ident.namespace().headOption, Some(catalogName)),
133+
tableType = fromUcTableType(info.getTableType),
134+
storage = storage,
135+
schema = schema,
136+
provider = Option(m.getProvider).map(_.toLowerCase(java.util.Locale.ROOT)),
137+
partitionColumnNames = partitionColumns,
138+
comment = Option(m.getDescription),
139+
createTime = if (m.getCreatedTime != null) m.getCreatedTime else 0L,
140+
tracksPartitionsInCatalog = false)
141+
V1Table(catalogTable)
142+
}
143+
144+
private def fromUcTableType(t: UCDeltaModels.TableType): CatalogTableType = t match {
145+
case UCDeltaModels.TableType.MANAGED => CatalogTableType.MANAGED
146+
case UCDeltaModels.TableType.EXTERNAL => CatalogTableType.EXTERNAL
147+
}
148+
}
149+
150+
object UCDeltaCatalogClientImpl extends AbstractDeltaCatalogClientFactory with Logging {
151+
/** Bumped at every loadTable entry, regardless of outcome. */
152+
val LOAD_TABLE_INVOCATIONS: AtomicLong = new AtomicLong(0L)
153+
154+
/**
155+
* Bumped only when loadTable returned a Delta table from the Delta REST API (no fallback,
156+
* no rethrow). Use this for "Delta REST actually served the load" assertions.
157+
*/
158+
val SUCCESSFUL_DELTA_REST_API_LOADS: AtomicLong = new AtomicLong(0L)
159+
160+
private[catalog] val RenewCredentialEnabledKey: String = "renewCredential.enabled"
161+
private[catalog] val CredScopedFsEnabledKey: String = "credScopedFs.enabled"
162+
private[catalog] val ServerSidePlanningEnabledKey: String = "serverSidePlanning.enabled"
163+
164+
private[catalog] val defaultFallbackLoadTable: Identifier => Table = ident =>
165+
throw new IllegalStateException(
166+
s"Non-Delta table $ident cannot be served via the Delta REST API path and no " +
167+
"fallback catalog was configured.")
168+
169+
/**
170+
* Builds a [[UCDeltaCatalogClientImpl]] from catalog options. The `deltaRestApi.enabled`
171+
* gate is the caller's responsibility ([[AbstractDeltaCatalogClient.fromCatalogOptionsIfEnabled]]).
172+
* {@code fallbackLoadTable} is invoked when UC reports {@code UnsupportedTableFormatException}.
173+
*/
174+
override def fromCatalogOptions(
175+
catalogName: String,
176+
options: CaseInsensitiveStringMap,
177+
fallbackLoadTable: Identifier => Table
178+
): UCDeltaCatalogClientImpl = {
179+
val uri = Option(options.get("uri")).getOrElse(throw new IllegalArgumentException(
180+
s"'uri' is required when 'deltaRestApi.enabled' is true (catalog '$catalogName')"))
181+
val authConfigs = extractAuthConfigs(options, catalogName)
182+
val appVersions = UCTokenBasedRestClientFactory.defaultAppVersionsAsJava
183+
val renewCredEnabled = options.getBoolean(RenewCredentialEnabledKey, true)
184+
val credScopedFsEnabled = options.getBoolean(CredScopedFsEnabledKey, false)
185+
val sspEnabled = options.getBoolean(ServerSidePlanningEnabledKey, false)
186+
val hadoopConfSupplier: Supplier[Configuration] =
187+
() => SparkSession.getActiveSession
188+
.map(_.sparkContext.hadoopConfiguration)
189+
.getOrElse(new Configuration())
190+
val restClient = UCDeltaTokenBasedRestClient.create(
191+
uri,
192+
authConfigs,
193+
appVersions,
194+
renewCredEnabled,
195+
credScopedFsEnabled,
196+
hadoopConfSupplier)
197+
new UCDeltaCatalogClientImpl(catalogName, restClient, sspEnabled, fallbackLoadTable)
198+
}
199+
200+
/**
201+
* `auth.*` sub-keys (prefix stripped) feed `TokenProvider.create`. Legacy bare `token`
202+
* is translated to `{type=static, token=<value>}`, only when no `auth.*` is present.
203+
*/
204+
private[catalog] def extractAuthConfigs(
205+
options: CaseInsensitiveStringMap,
206+
catalogName: String): java.util.Map[String, String] = {
207+
val authConfigs = new java.util.HashMap[String, String]()
208+
val authPrefix = "auth."
209+
// CaseInsensitiveStringMap.entrySet() returns keys already lowercased.
210+
options.entrySet().asScala.foreach { e =>
211+
val key = e.getKey
212+
if (key.startsWith(authPrefix)) {
213+
authConfigs.put(key.substring(authPrefix.length), e.getValue)
214+
}
215+
}
216+
if (authConfigs.isEmpty) {
217+
Option(options.get("token")).foreach { tok =>
218+
authConfigs.put("type", "static")
219+
authConfigs.put("token", tok)
220+
}
221+
}
222+
if (authConfigs.isEmpty) {
223+
throw new IllegalArgumentException(
224+
s"auth configuration is required when 'deltaRestApi.enabled' is true " +
225+
s"(catalog '$catalogName'). Set either 'auth.type' (with the corresponding " +
226+
s"auth.* keys) or the legacy 'token' option.")
227+
}
228+
authConfigs
229+
}
230+
}

0 commit comments

Comments
 (0)