Skip to content

Commit 08d2e63

Browse files
committed
[AURON #2253] Support insert-only Iceberg changelog native scan
1 parent 2f3a748 commit 08d2e63

3 files changed

Lines changed: 348 additions & 103 deletions

File tree

thirdparty/auron-iceberg/src/main/scala/org/apache/spark/sql/auron/iceberg/IcebergScanSupport.scala

Lines changed: 233 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ package org.apache.spark.sql.auron.iceberg
1919
import scala.collection.JavaConverters._
2020
import scala.util.control.NonFatal
2121

22-
import org.apache.iceberg.{FileFormat, FileScanTask, MetadataColumns}
22+
import org.apache.iceberg.{AddedRowsScanTask, ChangelogOperation, ChangelogScanTask, FileFormat, FileScanTask, MetadataColumns, ScanTask}
2323
import org.apache.iceberg.expressions.{And => IcebergAnd, BoundPredicate, Expression => IcebergExpression, Not => IcebergNot, Or => IcebergOr, UnboundPredicate}
2424
import org.apache.iceberg.spark.source.AuronIcebergSourceUtil
2525
import org.apache.spark.internal.Logging
2626
import org.apache.spark.sql.auron.NativeConverters
2727
import org.apache.spark.sql.catalyst.expressions.{And => SparkAnd, AttributeReference, EqualTo, Expression => SparkExpression, GreaterThan, GreaterThanOrEqual, In, IsNaN, IsNotNull, IsNull, LessThan, LessThanOrEqual, Literal, Not => SparkNot, Or => SparkOr}
28-
import org.apache.spark.sql.connector.read.InputPartition
28+
import org.apache.spark.sql.connector.read.{InputPartition, Scan}
2929
import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
3030
import org.apache.spark.sql.internal.SQLConf
3131
import org.apache.spark.sql.types.{BinaryType, DataType, DecimalType, StringType, StructField, StructType}
@@ -35,8 +35,15 @@ import org.apache.auron.{protobuf => pb}
3535
// fileSchema is read from the data files. partitionSchema carries supported metadata columns
3636
// (for example _file and _spec_id) that are materialized as per-file constant values in
3737
// the native scan.
38+
final case class IcebergNativeScanTask(
39+
location: String,
40+
start: Long,
41+
length: Long,
42+
fileSizeInBytes: Long,
43+
partitionValues: Seq[Any])
44+
3845
final case class IcebergScanPlan(
39-
fileTasks: Seq[FileScanTask],
46+
scanTasks: Seq[IcebergNativeScanTask],
4047
fileFormat: FileFormat,
4148
readSchema: StructType,
4249
fileSchema: StructType,
@@ -45,35 +52,39 @@ final case class IcebergScanPlan(
4552

4653
object IcebergScanSupport extends Logging {
4754

55+
private val SparkChangelogScanClassName =
56+
"org.apache.iceberg.spark.source.SparkChangelogScan"
57+
58+
private val ChangelogMetadataColumnNames = Set(
59+
MetadataColumns.CHANGE_TYPE.name(),
60+
MetadataColumns.CHANGE_ORDINAL.name(),
61+
MetadataColumns.COMMIT_SNAPSHOT_ID.name())
62+
4863
def plan(exec: BatchScanExec): Option[IcebergScanPlan] = {
4964
val scan = exec.scan
5065
val scanClassName = scan.getClass.getName
5166
// Only handle Iceberg scans; other sources must stay on Spark's path.
52-
assert(
53-
AuronIcebergSourceUtil.getClassOfSparkBatchQueryScan.isInstance(scan),
54-
"Not iceberg scans.")
67+
if (scanClassName == SparkChangelogScanClassName) {
68+
return planChangelogScan(exec, scan)
69+
}
70+
71+
if (!AuronIcebergSourceUtil.getClassOfSparkBatchQueryScan.isInstance(scan)) {
72+
return None
73+
}
74+
75+
planFileScan(exec, scan, scanClassName)
76+
}
5577

78+
private def planFileScan(
79+
exec: BatchScanExec,
80+
scan: Scan,
81+
scanClassName: String): Option[IcebergScanPlan] = {
5682
val readSchema = scan.readSchema
57-
val unsupportedMetadataColumns = collectUnsupportedMetadataColumns(readSchema)
58-
// Native scan can project file-level metadata columns such as _file and _spec_id
59-
// via partition values.
60-
// Metadata columns that require per-row materialization (for example _pos) still fallback.
61-
assert(
62-
!(unsupportedMetadataColumns.nonEmpty),
63-
"Has per-row materialization (for example _pos).")
64-
65-
val fileSchema = StructType(readSchema.fields.filterNot(isSupportedMetadataColumn))
66-
// Supported metadata columns are materialized via per-file constant values rather than
67-
// read from the Iceberg data file payload.
68-
val partitionSchema = StructType(readSchema.fields.filter(isSupportedMetadataColumn))
69-
70-
assert(
71-
fileSchema.fields.forall(field => NativeConverters.isTypeSupported(field.dataType)),
72-
"Has unsupported Iceberg data-file schema type.")
73-
74-
assert(
75-
partitionSchema.fields.forall(field => NativeConverters.isTypeSupported(field.dataType)),
76-
"Has unsupported schema type.")
83+
val schemas = supportedSchemas(readSchema, isChangelogScan = false)
84+
if (schemas.isEmpty) {
85+
return None
86+
}
87+
val (fileSchema, partitionSchema) = schemas.get
7788

7889
val partitions = inputPartitions(exec)
7990
// Empty scan (e.g. empty table) should still build a plan to return no rows.
@@ -90,49 +101,165 @@ object IcebergScanSupport extends Logging {
90101
}
91102

92103
val icebergPartitions = partitions.flatMap(icebergPartition)
93-
// All partitions must be Iceberg SparkInputPartition; otherwise fallback.
94-
assert(
95-
icebergPartitions.size == partitions.size,
96-
"All partitions must be Iceberg SparkInputPartition.")
104+
// All partitions must be Iceberg SparkInputPartition with file scan tasks; otherwise fallback.
105+
if (icebergPartitions.size != partitions.size) {
106+
return None
107+
}
97108

98-
val fileTasks = icebergPartitions.flatMap(_.fileTasks)
109+
val rawTasks = icebergPartitions.flatMap(_.tasks)
110+
val fileTasks = rawTasks.collect { case task: FileScanTask => task }
111+
if (fileTasks.size != rawTasks.size) {
112+
return None
113+
}
99114

100115
// Native scan does not apply delete files; only allow pure data files (COW).
101-
assert(
102-
fileTasks.forall(task => task.deletes() == null || task.deletes().isEmpty),
103-
"Not iceberg cow table.")
116+
if (!fileTasks.forall(task => deletesEmpty(task.deletes()))) {
117+
return None
118+
}
104119

105120
// Native scan handles a single file format; mixed formats must fallback.
106121
val formats = fileTasks.map(_.file().format()).distinct
107-
assert(!(formats.size > 1), "Not all data file format is a single file format.")
122+
if (formats.size > 1) {
123+
return None
124+
}
125+
126+
val format = formats.headOption.getOrElse(FileFormat.PARQUET)
127+
if (format != FileFormat.PARQUET && format != FileFormat.ORC) {
128+
return None
129+
}
130+
131+
val pruningPredicates = collectPruningPredicates(scan.asInstanceOf[AnyRef], readSchema)
132+
val nativeTasks = fileTasks.map(task => toNativeScanTask(task, partitionSchema))
133+
Some(
134+
IcebergScanPlan(
135+
nativeTasks,
136+
format,
137+
readSchema,
138+
fileSchema,
139+
partitionSchema,
140+
pruningPredicates))
141+
}
142+
143+
private def planChangelogScan(exec: BatchScanExec, scan: Scan): Option[IcebergScanPlan] = {
144+
val readSchema = scan.readSchema
145+
val schemas = supportedSchemas(readSchema, isChangelogScan = true)
146+
if (schemas.isEmpty) {
147+
return None
148+
}
149+
val (fileSchema, partitionSchema) = schemas.get
150+
151+
val partitions = inputPartitions(exec)
152+
if (partitions.isEmpty) {
153+
return Some(
154+
IcebergScanPlan(
155+
Seq.empty,
156+
FileFormat.PARQUET,
157+
readSchema,
158+
fileSchema,
159+
partitionSchema,
160+
Seq.empty))
161+
}
162+
163+
val icebergPartitions = partitions.flatMap(icebergPartition)
164+
if (icebergPartitions.size != partitions.size) {
165+
return None
166+
}
167+
168+
val rawTasks = icebergPartitions.flatMap(_.tasks)
169+
val changelogTasks = rawTasks.collect { case task: ChangelogScanTask => task }
170+
if (changelogTasks.size != rawTasks.size) {
171+
return None
172+
}
173+
174+
val addedRowsTasks = changelogTasks.collect { case task: AddedRowsScanTask => task }
175+
// First native changelog support is insert-only. Delete and update images need Iceberg
176+
// delete-file handling, so keep them on Spark's reader for now.
177+
if (addedRowsTasks.size != changelogTasks.size) {
178+
return None
179+
}
180+
181+
if (!addedRowsTasks.forall(_.operation() == ChangelogOperation.INSERT)) {
182+
return None
183+
}
184+
185+
if (!addedRowsTasks.forall(task => deletesEmpty(task.deletes()))) {
186+
return None
187+
}
188+
189+
val formats = addedRowsTasks.map(_.file().format()).distinct
190+
if (formats.size > 1) {
191+
return None
192+
}
108193

109194
val format = formats.headOption.getOrElse(FileFormat.PARQUET)
110-
assert(
111-
!(format != FileFormat.PARQUET && format != FileFormat.ORC),
112-
"Only support parquet or orc.")
195+
if (format != FileFormat.PARQUET && format != FileFormat.ORC) {
196+
return None
197+
}
113198

114199
val pruningPredicates = collectPruningPredicates(scan.asInstanceOf[AnyRef], readSchema)
200+
val nativeTasks = addedRowsTasks.map(task => toNativeScanTask(task, partitionSchema))
115201
Some(
116202
IcebergScanPlan(
117-
fileTasks,
203+
nativeTasks,
118204
format,
119205
readSchema,
120206
fileSchema,
121207
partitionSchema,
122208
pruningPredicates))
123209
}
124210

125-
private def collectUnsupportedMetadataColumns(schema: StructType): Seq[String] =
211+
private def supportedSchemas(
212+
readSchema: StructType,
213+
isChangelogScan: Boolean): Option[(StructType, StructType)] = {
214+
val unsupportedMetadataColumns =
215+
collectUnsupportedMetadataColumns(readSchema, isChangelogScan)
216+
// Supported metadata columns are materialized via per-file/per-task constant values rather
217+
// than read from the Iceberg data file payload. Metadata columns that require per-row
218+
// materialization (for example _pos) still fallback.
219+
if (unsupportedMetadataColumns.nonEmpty) {
220+
return None
221+
}
222+
223+
val fileSchema =
224+
StructType(readSchema.fields.filterNot(isSupportedMetadataColumn(_, isChangelogScan)))
225+
val partitionSchema =
226+
StructType(readSchema.fields.filter(isSupportedMetadataColumn(_, isChangelogScan)))
227+
228+
if (!fileSchema.fields.forall(field => NativeConverters.isTypeSupported(field.dataType))) {
229+
return None
230+
}
231+
232+
if (!partitionSchema.fields.forall(field =>
233+
NativeConverters.isTypeSupported(field.dataType))) {
234+
return None
235+
}
236+
237+
Some(fileSchema -> partitionSchema)
238+
}
239+
240+
private def collectUnsupportedMetadataColumns(
241+
schema: StructType,
242+
isChangelogScan: Boolean): Seq[String] =
126243
schema.fields.collect {
127244
case field
128-
if MetadataColumns.isMetadataColumn(field.name) &&
129-
!isSupportedMetadataColumn(field) =>
245+
if isIcebergMetadataColumn(field.name, isChangelogScan) &&
246+
!isSupportedMetadataColumn(field, isChangelogScan) =>
130247
field.name
131248
}
132249

133-
private def isSupportedMetadataColumn(field: org.apache.spark.sql.types.StructField): Boolean =
250+
private def isIcebergMetadataColumn(name: String, isChangelogScan: Boolean): Boolean =
251+
MetadataColumns.isMetadataColumn(name) ||
252+
(isChangelogScan && ChangelogMetadataColumnNames.contains(name))
253+
254+
private def isSupportedMetadataColumn(
255+
field: org.apache.spark.sql.types.StructField,
256+
isChangelogScan: Boolean): Boolean =
134257
field.name == MetadataColumns.FILE_PATH.name() ||
135-
field.name == MetadataColumns.SPEC_ID.name()
258+
field.name == MetadataColumns.SPEC_ID.name() ||
259+
(isChangelogScan && ChangelogMetadataColumnNames.contains(field.name))
260+
261+
private def deletesEmpty(deletes: java.util.List[_]): Boolean =
262+
deletes == null || deletes.isEmpty
136263

137264
private def inputPartitions(exec: BatchScanExec): Seq[InputPartition] = {
138265
// Prefer DataSource V2 batch API; if not available, fallback to exec methods via reflection.
@@ -190,40 +317,93 @@ object IcebergScanSupport extends Logging {
190317
}
191318
}
192319

193-
private case class IcebergPartitionView(fileTasks: Seq[FileScanTask])
320+
private case class IcebergPartitionView(tasks: Seq[ScanTask])
194321

195322
private def icebergPartition(partition: InputPartition): Option[IcebergPartitionView] = {
196323
val className = partition.getClass.getName
197324
// Only accept Iceberg SparkInputPartition to access task groups.
198-
assert(
199-
AuronIcebergSourceUtil.getClassOfSparkInputPartition().isInstance(partition),
200-
"Not iceberg scans.")
325+
if (!AuronIcebergSourceUtil.getClassOfSparkInputPartition().isInstance(partition)) {
326+
return None
327+
}
201328

202329
try {
203330
// SparkInputPartition is package-private; use reflection to read its task group.
204331
val taskGroupField = partition.getClass.getDeclaredField("taskGroup")
205332
taskGroupField.setAccessible(true)
206333
val taskGroup = taskGroupField.get(partition)
207334

208-
// Extract tasks and keep only file scan tasks.
335+
// Extract the Iceberg scan tasks. The caller decides which concrete task type is supported.
209336
val tasksMethod = taskGroup.getClass.getDeclaredMethod("tasks")
210337
tasksMethod.setAccessible(true)
211338
val tasks = tasksMethod.invoke(taskGroup).asInstanceOf[java.util.Collection[_]].asScala
212-
val fileTasks = tasks.collect { case task: FileScanTask => task }.toSeq
339+
val icebergTasks = tasks.collect { case task: ScanTask => task }.toSeq
213340

214-
// If any task is not a FileScanTask, fallback.
215-
if (fileTasks.size != tasks.size) {
341+
if (icebergTasks.size != tasks.size) {
216342
return None
217343
}
218344

219-
Some(IcebergPartitionView(fileTasks))
345+
Some(IcebergPartitionView(icebergTasks))
220346
} catch {
221347
case NonFatal(t) =>
222348
logDebug(s"Failed to read Iceberg SparkInputPartition via reflection for $className.", t)
223349
None
224350
}
225351
}
226352

353+
private def toNativeScanTask(
354+
task: FileScanTask,
355+
partitionSchema: StructType): IcebergNativeScanTask = {
356+
val file = task.file()
357+
IcebergNativeScanTask(
358+
file.location(),
359+
task.start(),
360+
task.length(),
361+
file.fileSizeInBytes(),
362+
metadataPartitionValues(file.location(), file.specId(), None, partitionSchema))
363+
}
364+
365+
private def toNativeScanTask(
366+
task: AddedRowsScanTask,
367+
partitionSchema: StructType): IcebergNativeScanTask = {
368+
val file = task.file()
369+
IcebergNativeScanTask(
370+
file.location(),
371+
task.start(),
372+
task.length(),
373+
file.fileSizeInBytes(),
374+
metadataPartitionValues(file.location(), file.specId(), Some(task), partitionSchema))
375+
}
376+
377+
private def metadataPartitionValues(
378+
filePath: String,
379+
specId: Int,
380+
changelogTask: Option[ChangelogScanTask],
381+
partitionSchema: StructType): Seq[Any] = {
382+
def requiredChangelogTask(columnName: String): ChangelogScanTask =
383+
changelogTask.getOrElse {
384+
throw new IllegalStateException(
385+
s"Iceberg changelog metadata column requires a changelog scan task: $columnName")
386+
}
387+
388+
partitionSchema.fields.map { field =>
389+
field.name match {
390+
case name if name == MetadataColumns.FILE_PATH.name() =>
391+
filePath
392+
case name if name == MetadataColumns.SPEC_ID.name() =>
393+
specId
394+
case name if name == MetadataColumns.CHANGE_TYPE.name() =>
395+
requiredChangelogTask(name).operation().name()
396+
case name if name == MetadataColumns.CHANGE_ORDINAL.name() =>
397+
requiredChangelogTask(name).changeOrdinal()
398+
case name if name == MetadataColumns.COMMIT_SNAPSHOT_ID.name() =>
399+
requiredChangelogTask(name).commitSnapshotId()
400+
case name =>
401+
throw new IllegalStateException(
402+
s"unsupported Iceberg metadata column in native scan: $name")
403+
}
404+
}
405+
}
406+
227407
private def collectPruningPredicates(
228408
scan: AnyRef,
229409
readSchema: StructType): Seq[pb.PhysicalExprNode] = {

0 commit comments

Comments
 (0)