@@ -19,13 +19,13 @@ package org.apache.spark.sql.auron.iceberg
1919import scala .collection .JavaConverters ._
2020import 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 }
2323import org .apache .iceberg .expressions .{And => IcebergAnd , BoundPredicate , Expression => IcebergExpression , Not => IcebergNot , Or => IcebergOr , UnboundPredicate }
2424import org .apache .iceberg .spark .source .AuronIcebergSourceUtil
2525import org .apache .spark .internal .Logging
2626import org .apache .spark .sql .auron .NativeConverters
2727import 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 }
2929import org .apache .spark .sql .execution .datasources .v2 .BatchScanExec
3030import org .apache .spark .sql .internal .SQLConf
3131import 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+
3845final 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
4653object 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