Skip to content

Commit a85d763

Browse files
committed
Support schema tracking log at analysis level
1 parent 3d721b1 commit a85d763

11 files changed

Lines changed: 835 additions & 68 deletions

File tree

spark-unified/src/main/scala/io/delta/internal/ApplyV2Streaming.scala

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import scala.jdk.OptionConverters._
2121

2222
import io.delta.spark.internal.v2.catalog.SparkTable
2323
import io.delta.spark.internal.v2.utils.ScalaUtils
24-
import org.apache.spark.sql.delta.DeltaV2Mode
24+
import org.apache.spark.sql.delta.{DeltaOptions, DeltaV2Mode}
2525
import org.apache.spark.sql.delta.sources.DeltaSourceUtils
2626

2727
import org.apache.spark.sql.SparkSession
@@ -71,6 +71,16 @@ class ApplyV2Streaming(
7171
deltaV2Mode.isStreamingReadsEnabled(s.dataSource.catalogTable.toJava)
7272
}
7373

74+
/** True when schema-tracking is set on extraOptions but not yet on the SparkTable. */
75+
private def needsSchemaTrackingRebuild(
76+
table: SparkTable, extraOptions: CaseInsensitiveStringMap): Boolean = {
77+
val tableOptions = new CaseInsensitiveStringMap(table.getOptions)
78+
(extraOptions.containsKey(DeltaOptions.SCHEMA_TRACKING_LOCATION) ||
79+
extraOptions.containsKey(DeltaOptions.SCHEMA_TRACKING_LOCATION_ALIAS)) &&
80+
!tableOptions.containsKey(DeltaOptions.SCHEMA_TRACKING_LOCATION) &&
81+
!tableOptions.containsKey(DeltaOptions.SCHEMA_TRACKING_LOCATION_ALIAS)
82+
}
83+
7484
override def apply(plan: LogicalPlan): LogicalPlan = plan.resolveOperators {
7585
case s: StreamingRelation if shouldApplyV2Streaming(s) =>
7686
// catalogTable is guaranteed to be defined because shouldApplyV2Streaming checks it
@@ -102,5 +112,18 @@ class ApplyV2Streaming(
102112
identifier = Some(ident),
103113
// Keep this None to force the V2 path; we don't want to fall back to V1 here.
104114
v1Relation = None)
115+
116+
// TODO(#5319): remove this rule after Spark supports directly create table reflect trackingLog
117+
case s @ StreamingRelationV2(_, _, table: SparkTable, extraOptions, _, _, _, _)
118+
if needsSchemaTrackingRebuild(table, extraOptions) =>
119+
val merged = new java.util.HashMap[String, String]()
120+
merged.putAll(table.getOptions)
121+
merged.putAll(extraOptions.asCaseSensitiveMap())
122+
val rebuilt = if (table.getCatalogTable.isPresent) {
123+
new SparkTable(table.getIdentifier, table.getCatalogTable.get, merged)
124+
} else {
125+
new SparkTable(table.getIdentifier, table.getTablePath.toString, merged)
126+
}
127+
s.copy(table = rebuilt, output = toAttributes(rebuilt.schema))
105128
}
106129
}

spark-unified/src/test/scala/io/delta/internal/ApplyV2StreamingSuite.scala

Lines changed: 226 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,25 @@ import java.util.{HashMap => JHashMap}
2121

2222
import scala.jdk.CollectionConverters._
2323

24+
import io.delta.kernel.internal.SnapshotImpl
2425
import io.delta.spark.internal.v2.catalog.SparkTable
26+
import io.delta.spark.internal.v2.snapshot.PathBasedSnapshotManager
2527
import io.delta.storage.commit.uccommitcoordinator.UCCommitCoordinatorClient
28+
import org.apache.spark.sql.delta.DeltaLog
2629
import org.apache.spark.sql.catalyst.TableIdentifier
2730
import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
2831
import org.apache.spark.sql.catalyst.catalog.CatalogTable
2932
import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat, CatalogTableType}
3033
import org.apache.spark.sql.catalyst.streaming.StreamingRelationV2
34+
import org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes
35+
import org.apache.spark.sql.delta.DeltaOptions
3136
import org.apache.spark.sql.delta.Relocated.StreamingRelation
32-
import org.apache.spark.sql.delta.sources.DeltaSQLConf
37+
import org.apache.spark.sql.delta.sources.{DeltaSourceMetadataTrackingLog, DeltaSQLConf, PersistedMetadata}
3338
import org.apache.spark.sql.delta.test.DeltaSQLCommandTest
3439
import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
3540
import org.apache.spark.sql.execution.datasources.DataSource
3641
import org.apache.spark.sql.connector.catalog.Identifier
37-
import org.apache.spark.sql.types.StructType
42+
import org.apache.spark.sql.types.{StringType, StructType}
3843
import org.apache.spark.sql.util.CaseInsensitiveStringMap
3944

4045
class ApplyV2StreamingSuite extends DeltaSQLCommandTest {
@@ -140,4 +145,223 @@ class ApplyV2StreamingSuite extends DeltaSQLCommandTest {
140145
}
141146
}
142147
}
148+
149+
// ---------------------------------------------------------------------------
150+
// Rebuild StreamingRelationV2 if provided schema tracking log provided
151+
// ---------------------------------------------------------------------------
152+
153+
/** The data-schema seeded into the tracking log by [[seedSchemaLogWithExtraColumn]]. */
154+
private val seededFieldNames: Seq[String] = Seq("id", "extra")
155+
156+
private def buildStreamingRelationV2(
157+
table: SparkTable, extraOptions: Map[String, String]): StreamingRelationV2 = {
158+
StreamingRelationV2(
159+
source = None,
160+
sourceName = "delta",
161+
table = table,
162+
extraOptions = new CaseInsensitiveStringMap(extraOptions.asJava),
163+
output = toAttributes(table.schema),
164+
catalog = None,
165+
identifier = Some(table.getIdentifier),
166+
v1Relation = None)
167+
}
168+
169+
/**
170+
* Pre-seed the schema-tracking log at `schemaLogPath` with a 2-column schema
171+
* (`id LONG, extra STRING`) that differs from the underlying snapshot's 1-column schema
172+
*/
173+
private def seedSchemaLogWithExtraColumn(tablePath: String, schemaLogPath: String): Unit = {
174+
val deltaLog = DeltaLog.forTable(spark, tablePath)
175+
val snapshotManager =
176+
new PathBasedSnapshotManager(tablePath, deltaLog.newDeltaHadoopConf())
177+
val tableId =
178+
snapshotManager.loadLatestSnapshot.asInstanceOf[SnapshotImpl].getMetadata.getId
179+
val trackingLog = DeltaSourceMetadataTrackingLog.create(
180+
spark, schemaLogPath, tableId, tablePath, parameters = Map.empty[String, String])
181+
val customSchemaJson =
182+
"""{"type":"struct","fields":[
183+
|{"name":"id","type":"long","nullable":true,"metadata":{}},
184+
|{"name":"extra","type":"string","nullable":true,"metadata":{}}]}""".stripMargin
185+
val emptyPartitionJson = """{"type":"struct","fields":[]}"""
186+
val seededEntry = PersistedMetadata(
187+
tableId,
188+
deltaCommitVersion = 0L,
189+
dataSchemaJson = customSchemaJson,
190+
partitionSchemaJson = emptyPartitionJson,
191+
sourceMetadataPath = tablePath + "/_delta_log/_streaming_metadata")
192+
trackingLog.writeNewMetadata(seededEntry, replaceCurrent = false)
193+
}
194+
195+
/** Asserts the table's schema matches the entry written by [[seedSchemaLogWithExtraColumn]]. */
196+
private def assertSchemaMatchesSeededLogEntry(table: SparkTable): Unit = {
197+
assert(table.schema.fieldNames.toSeq == seededFieldNames)
198+
assert(table.schema.fields(1).dataType == StringType)
199+
}
200+
201+
/**
202+
* Build a catalog-backed SparkTable rooted at `tableLocationUri`. Mirrors the common production
203+
* path through DeltaCatalog and is the default for tests that do not specifically distinguish
204+
* between path-based and catalog-based construction.
205+
*/
206+
private def buildCatalogBasedSparkTable(
207+
tableLocationUri: URI, options: JHashMap[String, String]): SparkTable = {
208+
val catalogTable = createCatalogTable(tableLocationUri, ucManaged = false)
209+
val identifier = Identifier.of(
210+
catalogTable.identifier.database.toArray, catalogTable.identifier.table)
211+
new SparkTable(identifier, catalogTable, options)
212+
}
213+
214+
test("schema-tracking rebuild: path-based SparkTable picks up the persisted schema") {
215+
withTempDir { tableDir =>
216+
withTempDir { schemaLogDir =>
217+
val tablePath = tableDir.getCanonicalPath
218+
createDeltaTable(tablePath) // snapshot schema: id BIGINT
219+
val schemaLogPath = schemaLogDir.getCanonicalPath
220+
seedSchemaLogWithExtraColumn(tablePath, schemaLogPath)
221+
222+
val identifier = Identifier.of(Array("default"), "tbl")
223+
val table = new SparkTable(identifier, tablePath)
224+
assert(!table.getOptions.containsKey(DeltaOptions.SCHEMA_TRACKING_LOCATION))
225+
226+
val plan = buildStreamingRelationV2(
227+
table, Map(DeltaOptions.SCHEMA_TRACKING_LOCATION -> schemaLogPath))
228+
val result = applyRule(plan).asInstanceOf[StreamingRelationV2]
229+
val rebuiltTable = result.table.asInstanceOf[SparkTable]
230+
231+
assert(rebuiltTable ne table, "rebuild should produce a new SparkTable")
232+
assert(rebuiltTable.getOptions.containsKey(DeltaOptions.SCHEMA_TRACKING_LOCATION))
233+
assert(rebuiltTable.getOptions.get(DeltaOptions.SCHEMA_TRACKING_LOCATION) ==
234+
schemaLogPath)
235+
assert(!rebuiltTable.getCatalogTable.isPresent,
236+
"path branch should not have catalogTable")
237+
// Rebuilt schema is driven by the persisted entry, not the snapshot.
238+
assertSchemaMatchesSeededLogEntry(rebuiltTable)
239+
// And the rule's output is re-derived from that rebuilt schema.
240+
assert(result.output.map(_.name) == seededFieldNames)
241+
242+
// Idempotent: re-applying the rule does not rebuild a second time.
243+
val reappliedResult = applyRule(result).asInstanceOf[StreamingRelationV2]
244+
assert(reappliedResult.table eq rebuiltTable, "re-applying rule should not rebuild")
245+
}
246+
}
247+
}
248+
249+
test("schema-tracking rebuild: catalog-based SparkTable picks up the persisted schema and " +
250+
"keeps its CatalogTable") {
251+
withTempDir { tableDir =>
252+
withTempDir { schemaLogDir =>
253+
val tablePath = tableDir.getCanonicalPath
254+
createDeltaTable(tablePath)
255+
val schemaLogPath = schemaLogDir.getCanonicalPath
256+
seedSchemaLogWithExtraColumn(tablePath, schemaLogPath)
257+
258+
val table = buildCatalogBasedSparkTable(tableDir.toURI, new JHashMap[String, String]())
259+
assert(!table.getOptions.containsKey(DeltaOptions.SCHEMA_TRACKING_LOCATION))
260+
assert(table.getCatalogTable.isPresent)
261+
262+
val plan = buildStreamingRelationV2(
263+
table, Map(DeltaOptions.SCHEMA_TRACKING_LOCATION -> schemaLogPath))
264+
val result = applyRule(plan).asInstanceOf[StreamingRelationV2]
265+
val rebuiltTable = result.table.asInstanceOf[SparkTable]
266+
267+
assert(rebuiltTable.getOptions.containsKey(DeltaOptions.SCHEMA_TRACKING_LOCATION))
268+
assert(rebuiltTable.getCatalogTable.isPresent,
269+
"catalog branch should keep CatalogTable")
270+
assertSchemaMatchesSeededLogEntry(rebuiltTable)
271+
}
272+
}
273+
}
274+
275+
test("schema-tracking rebuild: triggered by SCHEMA_TRACKING_LOCATION_ALIAS option key") {
276+
withTempDir { tableDir =>
277+
withTempDir { schemaLogDir =>
278+
val tablePath = tableDir.getCanonicalPath
279+
createDeltaTable(tablePath)
280+
val schemaLogPath = schemaLogDir.getCanonicalPath
281+
seedSchemaLogWithExtraColumn(tablePath, schemaLogPath)
282+
283+
val table = buildCatalogBasedSparkTable(tableDir.toURI, new JHashMap[String, String]())
284+
285+
val plan = buildStreamingRelationV2(
286+
table, Map(DeltaOptions.SCHEMA_TRACKING_LOCATION_ALIAS -> schemaLogPath))
287+
val result = applyRule(plan).asInstanceOf[StreamingRelationV2]
288+
val rebuiltTable = result.table.asInstanceOf[SparkTable]
289+
290+
assert(rebuiltTable.getOptions.containsKey(DeltaOptions.SCHEMA_TRACKING_LOCATION_ALIAS))
291+
assert(rebuiltTable.getOptions.get(DeltaOptions.SCHEMA_TRACKING_LOCATION_ALIAS) ==
292+
schemaLogPath)
293+
assertSchemaMatchesSeededLogEntry(rebuiltTable)
294+
}
295+
}
296+
}
297+
298+
test("schema-tracking rebuild: skipped when extraOptions has no schema-tracking option") {
299+
withTempDir { tableDir =>
300+
val tablePath = tableDir.getCanonicalPath
301+
createDeltaTable(tablePath)
302+
val table = buildCatalogBasedSparkTable(tableDir.toURI, new JHashMap[String, String]())
303+
304+
val plan = buildStreamingRelationV2(table, Map.empty)
305+
val result = applyRule(plan)
306+
assert(result eq plan, "no rebuild expected when schema-tracking option not present")
307+
}
308+
}
309+
310+
test("schema-tracking rebuild: skipped when SparkTable already carries the " +
311+
"schema-tracking option") {
312+
withTempDir { tableDir =>
313+
withTempDir { schemaLogDir =>
314+
val tablePath = tableDir.getCanonicalPath
315+
createDeltaTable(tablePath)
316+
val schemaLogPath = schemaLogDir.getCanonicalPath
317+
val tableOptions = new JHashMap[String, String]()
318+
tableOptions.put(DeltaOptions.SCHEMA_TRACKING_LOCATION, schemaLogPath)
319+
val table = buildCatalogBasedSparkTable(tableDir.toURI, tableOptions)
320+
assert(table.getOptions.containsKey(DeltaOptions.SCHEMA_TRACKING_LOCATION))
321+
322+
val plan = buildStreamingRelationV2(
323+
table, Map(DeltaOptions.SCHEMA_TRACKING_LOCATION -> schemaLogPath))
324+
val result = applyRule(plan)
325+
assert(result eq plan, "no rebuild expected when table already carries the option")
326+
}
327+
}
328+
}
329+
330+
test("schema-tracking via V1 StreamingRelation: option propagates through V1 -> V2 conversion") {
331+
// Counterpart to the V2 rebuild tests above: those start from StreamingRelationV2 and exercise
332+
// the rebuild branch. This test starts from a V1 StreamingRelation carrying the schema-tracking
333+
// option in dataSource.options, and verifies the V1 -> V2 conversion branch hands the option to
334+
// the new SparkTable so its schema is driven by the persisted log entry.
335+
withTempDir { tableDir =>
336+
withTempDir { schemaLogDir =>
337+
val tablePath = tableDir.getCanonicalPath
338+
createDeltaTable(tablePath)
339+
val schemaLogPath = schemaLogDir.getCanonicalPath
340+
seedSchemaLogWithExtraColumn(tablePath, schemaLogPath)
341+
342+
val catalogTable = createCatalogTable(tableDir.toURI, ucManaged = false)
343+
val dataSource = DataSource(
344+
sparkSession = spark,
345+
userSpecifiedSchema = None,
346+
className = "delta",
347+
options = Map(
348+
"path" -> tablePath,
349+
DeltaOptions.SCHEMA_TRACKING_LOCATION -> schemaLogPath),
350+
catalogTable = Some(catalogTable))
351+
val plan = StreamingRelation(dataSource)
352+
353+
// STRICT mode forces V1 -> V2 conversion in ApplyV2Streaming.
354+
withSQLConf(DeltaSQLConf.V2_ENABLE_MODE.key -> "STRICT") {
355+
val result = applyRule(plan).asInstanceOf[StreamingRelationV2]
356+
val convertedTable = result.table.asInstanceOf[SparkTable]
357+
assert(convertedTable.getOptions.containsKey(DeltaOptions.SCHEMA_TRACKING_LOCATION))
358+
assert(convertedTable.getOptions.get(DeltaOptions.SCHEMA_TRACKING_LOCATION) ==
359+
schemaLogPath)
360+
// Schema is driven by the seeded log entry, not the underlying snapshot.
361+
assertSchemaMatchesSeededLogEntry(convertedTable)
362+
assert(result.output.map(_.name) == seededFieldNames)
363+
}
364+
}
365+
}
366+
}
143367
}

spark-unified/src/test/scala/org/apache/spark/sql/delta/test/DeltaV2SourceSchemaEvolutionSuite.scala

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,8 @@ trait DeltaV2SourceSchemaEvolutionSuiteBase extends V2ForceTest {
7171
}
7272

7373
// TODO(#5319): Move tests to shouldPassTests as V2 schema tracking log support is implemented.
74-
override protected def shouldPassTests: Set[String] = Set.empty[String]
75-
76-
// All tests from StreamingSchemaEvolutionSuiteBase.
77-
// Override in CDC suites to add CDC-specific tests.
78-
override protected def shouldFailTests: Set[String] = Set(
79-
// ========== Schema location validation ==========
74+
override protected def shouldPassTests: Set[String] = Set(
75+
// ========== Schema log unit test ==========
8076
"schema location not under checkpoint",
8177
"schema location same as checkpoint",
8278
"schema location using a different file system",
@@ -91,11 +87,18 @@ trait DeltaV2SourceSchemaEvolutionSuiteBase extends V2ForceTest {
9187
"schema / checkpoint location unit tests - " +
9288
"schema location and checkpoint location are the same but with explicit file scheme",
9389
"schema / checkpoint location unit tests - special characters in schema location",
90+
"concurrent schema log modification should be detected",
91+
"schema log replace current",
92+
"backward-compat: latest version can read back older JSON",
93+
"forward-compat: older version can read back newer JSON",
9494

9595
// ========== Schema log core ==========
96-
"multiple delta source sharing same schema log is blocked",
96+
"multiple delta source sharing same schema log is blocked"
97+
)
98+
99+
override protected def shouldFailTests: Set[String] = Set(
100+
// ========== Schema log core ==========
97101
"schema log is applied",
98-
"concurrent schema log modification should be detected",
99102
"schema log initialization with additive schema changes",
100103
"detect incompatible schema change while streaming",
101104
"detect incompatible schema change during first getBatch",
@@ -123,12 +126,7 @@ trait DeltaV2SourceSchemaEvolutionSuiteBase extends V2ForceTest {
123126
"unblock with sql conf",
124127
"schema tracking interacting with unsafe escape flag",
125128
"streaming with a column mapping upgrade",
126-
"partition evolution",
127-
"schema log replace current",
128-
129-
// ========== Backward/forward compatibility ==========
130-
"backward-compat: latest version can read back older JSON",
131-
"forward-compat: older version can read back newer JSON"
129+
"partition evolution"
132130
)
133131
}
134132

@@ -143,25 +141,24 @@ class DeltaV2SourceSchemaEvolutionIdColumnMappingSuite
143141
with DeltaV2SourceSchemaEvolutionSuiteBase
144142

145143
// CDC suites
144+
// TODO(#5319): Support CDC non-additive schema evolution
145+
trait DeltaV2SourceSchemaEvolutionCDCSuiteBase extends DeltaV2SourceSchemaEvolutionSuiteBase {
146+
self: StreamingSchemaEvolutionSuiteBase =>
146147

147-
class DeltaV2SourceSchemaEvolutionCDCNameColumnMappingSuite
148-
extends DeltaSourceSchemaEvolutionCDCNameColumnMappingSuite
149-
with DeltaV2SourceSchemaEvolutionSuiteBase {
148+
override protected def shouldPassTests: Set[String] = Set.empty[String]
150149

151-
override protected def shouldFailTests: Set[String] = super.shouldFailTests ++ Set(
152-
// Additional tests from CDCStreamingSchemaEvolutionSuiteBase
153-
"CDC streaming with schema evolution",
154-
"protocol and configuration evolution"
155-
)
150+
override protected def shouldFailTests: Set[String] =
151+
super.shouldPassTests ++ super.shouldFailTests ++ Set(
152+
// Additional tests from CDCStreamingSchemaEvolutionSuiteBase
153+
"CDC streaming with schema evolution",
154+
"protocol and configuration evolution"
155+
)
156156
}
157157

158+
class DeltaV2SourceSchemaEvolutionCDCNameColumnMappingSuite
159+
extends DeltaSourceSchemaEvolutionCDCNameColumnMappingSuite
160+
with DeltaV2SourceSchemaEvolutionCDCSuiteBase
161+
158162
class DeltaV2SourceSchemaEvolutionCDCIdColumnMappingSuite
159163
extends DeltaSourceSchemaEvolutionCDCIdColumnMappingSuite
160-
with DeltaV2SourceSchemaEvolutionSuiteBase {
161-
162-
override protected def shouldFailTests: Set[String] = super.shouldFailTests ++ Set(
163-
// Additional tests from CDCStreamingSchemaEvolutionSuiteBase
164-
"CDC streaming with schema evolution",
165-
"protocol and configuration evolution"
166-
)
167-
}
164+
with DeltaV2SourceSchemaEvolutionCDCSuiteBase

0 commit comments

Comments
 (0)