Skip to content

Commit 789bcb3

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 98a0d51 commit 789bcb3

13 files changed

Lines changed: 936 additions & 26 deletions

File tree

build.sbt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,14 @@ lazy val storage = (project in file("storage"))
12121212
ExclusionRule(organization = "com.fasterxml.jackson.datatype"),
12131213
ExclusionRule(organization = "com.fasterxml.jackson.dataformat")
12141214
),
1215+
// For UCDeltaTokenBasedRestClient credential vending via UCCredentialHadoopConfs.
1216+
"io.unitycatalog" % "unitycatalog-hadoop" % unityCatalogVersion excludeAll(
1217+
ExclusionRule(organization = "org.openapitools"),
1218+
ExclusionRule(organization = "com.fasterxml.jackson.core"),
1219+
ExclusionRule(organization = "com.fasterxml.jackson.module"),
1220+
ExclusionRule(organization = "com.fasterxml.jackson.datatype"),
1221+
ExclusionRule(organization = "com.fasterxml.jackson.dataformat")
1222+
),
12151223

12161224
// Test Deps
12171225
"org.scalatest" %% "scalatest" % scalaTestVersion % "test",

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: DeltaCatalogClient = null
89+
90+
override def initialize(name: String, options: CaseInsensitiveStringMap): Unit = {
91+
super.initialize(name, options)
92+
deltaCatalogClient =
93+
UCDeltaCatalogClientImpl.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: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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.sql.connector.catalog.{Identifier, Table}
20+
21+
/**
22+
* Backend hook used by [[AbstractDeltaCatalog]] for catalog-specific table loading; isolates
23+
* catalog-client and credential-vending plumbing from `AbstractDeltaCatalog`.
24+
*/
25+
private[catalog] trait DeltaCatalogClient {
26+
27+
/**
28+
* @throws org.apache.spark.sql.catalyst.analysis.NoSuchTableException if the catalog has
29+
* no record of this identifier
30+
*/
31+
def loadTable(ident: Identifier): Table
32+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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
21+
import java.util.concurrent.atomic.AtomicLong
22+
import java.util.function.Supplier
23+
24+
import scala.jdk.CollectionConverters._
25+
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.logging.DeltaLogKeys
50+
import org.apache.spark.sql.delta.sources.DeltaSQLConf
51+
import org.apache.spark.sql.types.{DataType, StructType}
52+
import org.apache.spark.sql.util.CaseInsensitiveStringMap
53+
54+
/**
55+
* [[DeltaCatalogClient]] backed by a [[UCDeltaClient]]; translates between Spark/Delta types
56+
* and the storage-side UC types.
57+
*/
58+
private[catalog] class UCDeltaCatalogClientImpl(
59+
catalogName: String,
60+
ucClient: UCDeltaClient,
61+
serverSidePlanningEnabled: Boolean = false,
62+
fallbackLoadTable: Identifier => Table = UCDeltaCatalogClientImpl.defaultFallbackLoadTable)
63+
extends DeltaCatalogClient with Logging {
64+
65+
override def loadTable(ident: Identifier): Table = {
66+
UCDeltaCatalogClientImpl.LOAD_TABLE_INVOCATIONS.incrementAndGet()
67+
val (catalog, schema, table) = parseIdent(ident)
68+
val info =
69+
try ucClient.loadTable(catalog, schema, table)
70+
catch {
71+
case _: StorageNoSuchTableException => throw new NoSuchTableException(ident)
72+
case e: UnsupportedTableFormatException =>
73+
logInfo(log"Table ${MDC(DeltaLogKeys.TABLE_NAME, ident)} is not in Delta format; " +
74+
log"falling back to the legacy catalog path. Cause: " +
75+
log"${MDC(DeltaLogKeys.EXCEPTION, e.getMessage)}")
76+
return fallbackLoadTable(ident)
77+
case e: CredentialFetchFailedException if serverSidePlanningEnabled =>
78+
logWarning(
79+
s"Credential fetch failed for $catalog.$schema.$table; enabling server-side " +
80+
s"planning fallback. Cause: ${e.getMessage}")
81+
enableServerSidePlanningConfig(ident)
82+
e.getTableInfoWithoutCredentials
83+
}
84+
if (info.getStorageProperties.isEmpty && serverSidePlanningEnabled) {
85+
// Unrecognized scheme (e.g. file://) returns empty creds without throwing; enable SSP
86+
// so Delta can still read.
87+
enableServerSidePlanningConfig(ident)
88+
}
89+
UCDeltaCatalogClientImpl.SUCCESSFUL_DELTA_REST_API_LOADS.incrementAndGet()
90+
toV1Table(ident, info)
91+
}
92+
93+
private def enableServerSidePlanningConfig(ident: Identifier): Unit = {
94+
SparkSession.getActiveSession match {
95+
case Some(spark) =>
96+
spark.conf.set(DeltaSQLConf.ENABLE_SERVER_SIDE_PLANNING.key, "true")
97+
logInfo(log"Server-side planning enabled for table " +
98+
log"${MDC(DeltaLogKeys.TABLE_NAME, ident)}; Delta will read via SSP with empty creds.")
99+
case None =>
100+
logWarning(log"Server-side planning requested for table " +
101+
log"${MDC(DeltaLogKeys.TABLE_NAME, ident)} but no active SparkSession found.")
102+
}
103+
}
104+
105+
// ---------- conversions ----------
106+
107+
private def parseIdent(ident: Identifier): (String, String, String) = {
108+
val ns = ident.namespace()
109+
require(
110+
ns.length == 1,
111+
s"UC identifiers must be of the form <schema>.<table>; got namespace ${ns.mkString(".")}")
112+
(catalogName, ns(0), ident.name())
113+
}
114+
115+
private def toV1Table(ident: Identifier, info: TableInfo): V1Table = {
116+
val m = info.getMetadata
117+
val properties = Option(m.getConfiguration)
118+
.map(_.asScala.toMap)
119+
.getOrElse(Map.empty[String, String])
120+
val partitionColumns = Option(m.getPartitionColumns)
121+
.map(_.asScala.toSeq)
122+
.getOrElse(Seq.empty[String])
123+
val schema = Option(m.getSchemaString)
124+
.map(DataType.fromJson(_).asInstanceOf[StructType])
125+
.getOrElse(new StructType())
126+
val storage = CatalogStorageFormat.empty.copy(
127+
locationUri = Some(new URI(info.getLocation)),
128+
properties = properties ++ info.getStorageProperties.asScala.toMap)
129+
val catalogTable = CatalogTable(
130+
identifier = TableIdentifier(ident.name(), ident.namespace().headOption, Some(catalogName)),
131+
tableType = fromUcTableType(info.getTableType),
132+
storage = storage,
133+
schema = schema,
134+
provider = Option(m.getProvider).map(_.toLowerCase(java.util.Locale.ROOT)),
135+
partitionColumnNames = partitionColumns,
136+
comment = Option(m.getDescription),
137+
createTime = if (m.getCreatedTime != null) m.getCreatedTime else 0L,
138+
tracksPartitionsInCatalog = false)
139+
V1Table(catalogTable)
140+
}
141+
142+
private def fromUcTableType(t: UCDeltaModels.TableType): CatalogTableType = t match {
143+
case UCDeltaModels.TableType.MANAGED => CatalogTableType.MANAGED
144+
case UCDeltaModels.TableType.EXTERNAL => CatalogTableType.EXTERNAL
145+
}
146+
}
147+
148+
object UCDeltaCatalogClientImpl extends Logging {
149+
/** Bumped at every loadTable entry, regardless of outcome. */
150+
val LOAD_TABLE_INVOCATIONS: AtomicLong = new AtomicLong(0L)
151+
152+
/**
153+
* Bumped only when loadTable returned a Delta table from the Delta REST API (no fallback,
154+
* no rethrow). Use this for "Delta REST actually served the load" assertions.
155+
*/
156+
val SUCCESSFUL_DELTA_REST_API_LOADS: AtomicLong = new AtomicLong(0L)
157+
158+
private[catalog] val UCDeltaRestApiEnabledKey: String = "deltaRestApi.enabled"
159+
private[catalog] val RenewCredentialEnabledKey: String = "renewCredential.enabled"
160+
private[catalog] val CredScopedFsEnabledKey: String = "credScopedFs.enabled"
161+
private[catalog] val ServerSidePlanningEnabledKey: String = "serverSidePlanning.enabled"
162+
163+
private[catalog] val defaultFallbackLoadTable: Identifier => Table = ident =>
164+
throw new IllegalStateException(
165+
s"Non-Delta table $ident cannot be served via the Delta REST API path and no " +
166+
"fallback catalog was configured.")
167+
168+
/**
169+
* Returns {@code null} when [[UCDeltaRestApiEnabledKey]] is not set; otherwise builds
170+
* a client (requires {@code uri} + {@code token}). {@code fallbackLoadTable} is invoked when
171+
* UC reports {@code UnsupportedTableFormatException}.
172+
*/
173+
private[catalog] def fromCatalogOptionsIfEnabled(
174+
catalogName: String,
175+
options: CaseInsensitiveStringMap,
176+
fallbackLoadTable: Identifier => Table = defaultFallbackLoadTable
177+
): UCDeltaCatalogClientImpl = {
178+
if (!options.getBoolean(UCDeltaRestApiEnabledKey, false)) {
179+
return null
180+
}
181+
val client = fromCatalogOptions(catalogName, options, fallbackLoadTable)
182+
logInfo(log"Delta REST API loadTable path enabled for catalog " +
183+
log"${MDC(DeltaLogKeys.CATALOG, catalogName)}; Delta table loads will route through " +
184+
log"UCDeltaCatalogClientImpl, non-Delta tables will fall back to the legacy delegate.")
185+
client
186+
}
187+
188+
private def fromCatalogOptions(
189+
catalogName: String,
190+
options: CaseInsensitiveStringMap,
191+
fallbackLoadTable: Identifier => Table
192+
): UCDeltaCatalogClientImpl = {
193+
val uri = Option(options.get("uri")).getOrElse(throw new IllegalArgumentException(
194+
s"'uri' is required when '$UCDeltaRestApiEnabledKey' is true " +
195+
s"(catalog '$catalogName')"))
196+
val token = Option(options.get("token")).getOrElse(throw new IllegalArgumentException(
197+
s"'token' is required when '$UCDeltaRestApiEnabledKey' is true " +
198+
s"(catalog '$catalogName')"))
199+
val appVersions = new util.HashMap[String, String]()
200+
val renewCredEnabled = options.getBoolean(RenewCredentialEnabledKey, true)
201+
val credScopedFsEnabled = options.getBoolean(CredScopedFsEnabledKey, false)
202+
val sspEnabled = options.getBoolean(ServerSidePlanningEnabledKey, false)
203+
val hadoopConfSupplier: Supplier[Configuration] =
204+
() => SparkSession.getActiveSession
205+
.map(_.sparkContext.hadoopConfiguration)
206+
.getOrElse(new Configuration())
207+
val restClient = UCDeltaTokenBasedRestClient.forStaticToken(
208+
uri,
209+
token,
210+
appVersions,
211+
renewCredEnabled,
212+
credScopedFsEnabled,
213+
hadoopConfSupplier)
214+
new UCDeltaCatalogClientImpl(catalogName, restClient, sspEnabled, fallbackLoadTable)
215+
}
216+
}

0 commit comments

Comments
 (0)