Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import scala.collection.JavaConverters._
import scala.util.control.NonFatal

import org.apache.commons.lang3.reflect.MethodUtils
import org.apache.iceberg.{AddedRowsScanTask, ChangelogOperation, ChangelogScanTask, FileFormat, FileScanTask, MetadataColumns, ScanTask}
import org.apache.iceberg.{AddedRowsScanTask, ChangelogOperation, ChangelogScanTask, DataFile, DeletedDataFileScanTask, FileFormat, FileScanTask, MetadataColumns, ScanTask}
import org.apache.iceberg.expressions.{And => IcebergAnd, BoundPredicate, Expression => IcebergExpression, Not => IcebergNot, Or => IcebergOr, UnboundPredicate}
import org.apache.iceberg.spark.source.AuronIcebergSourceUtil
import org.apache.spark.internal.Logging
Expand Down Expand Up @@ -267,22 +267,14 @@ object IcebergScanSupport extends Logging {
return None
}

val addedRowsTasks = changelogTasks.collect { case task: AddedRowsScanTask => task }
// First native changelog support is insert-only. Delete and update images need Iceberg
// delete-file handling, so keep them on Spark's reader for now.
if (addedRowsTasks.size != changelogTasks.size) {
// Native changelog scan can read data-file rows directly for insert tasks and
// full-data-file delete tasks. Row-level deletes still need Iceberg delete-file handling.
val nativeChangelogTasks = changelogTasks.flatMap(toNativeChangelogDataFileTask)
if (nativeChangelogTasks.size != changelogTasks.size) {
return None
}

if (!addedRowsTasks.forall(_.operation() == ChangelogOperation.INSERT)) {
return None
}

if (!addedRowsTasks.forall(task => deletesEmpty(task.deletes()))) {
return None
}

val formats = addedRowsTasks.map(_.file().format()).distinct
val formats = nativeChangelogTasks.map(_.file.format()).distinct
if (formats.size > 1) {
return None
}
Expand All @@ -298,7 +290,7 @@ object IcebergScanSupport extends Logging {
}

val pruningPredicates = collectPruningPredicates(scan.asInstanceOf[AnyRef], readSchema)
val nativeTasks = addedRowsTasks.map(task => toNativeScanTask(task, partitionSchema))
val nativeTasks = nativeChangelogTasks.map(task => toNativeScanTask(task, partitionSchema))
Some(
IcebergScanPlan(
nativeTasks,
Expand Down Expand Up @@ -528,6 +520,12 @@ object IcebergScanSupport extends Logging {

private case class IcebergPartitionView(tasks: Seq[ScanTask])

private case class NativeChangelogDataFileTask(
file: DataFile,
start: Long,
length: Long,
changelogTask: ChangelogScanTask)

private def icebergPartition(partition: InputPartition): Option[IcebergPartitionView] = {
val className = partition.getClass.getName
// Only accept Iceberg SparkInputPartition to access task groups.
Expand Down Expand Up @@ -559,6 +557,21 @@ object IcebergScanSupport extends Logging {
}
}

private def toNativeChangelogDataFileTask(
task: ChangelogScanTask): Option[NativeChangelogDataFileTask] = {
task match {
case added: AddedRowsScanTask
if added.operation() == ChangelogOperation.INSERT &&
deletesEmpty(added.deletes()) =>
Some(NativeChangelogDataFileTask(added.file(), added.start(), added.length(), added))
case deleted: DeletedDataFileScanTask if deletesEmpty(deleted.existingDeletes()) =>
Some(
NativeChangelogDataFileTask(deleted.file(), deleted.start(), deleted.length(), deleted))
case _ =>
None
}
}
Comment on lines +560 to +573

private def toNativeScanTask(
task: FileScanTask,
partitionSchema: StructType): IcebergNativeScanTask = {
Expand All @@ -572,15 +585,18 @@ object IcebergScanSupport extends Logging {
}

private def toNativeScanTask(
task: AddedRowsScanTask,
task: NativeChangelogDataFileTask,
partitionSchema: StructType): IcebergNativeScanTask = {
val file = task.file()
IcebergNativeScanTask(
file.location(),
task.start(),
task.length(),
file.fileSizeInBytes(),
metadataPartitionValues(file.location(), file.specId(), Some(task), partitionSchema))
task.file.location(),
task.start,
task.length,
task.file.fileSizeInBytes(),
metadataPartitionValues(
task.file.location(),
task.file.specId(),
Some(task.changelogTask),
partitionSchema))
}

private def metadataPartitionValues(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,48 @@ class AuronIcebergIntegrationSuite
}
}

test("iceberg native scan supports full-data-file delete changelog scan") {
withTable("local.db.t_changelog_full_file_delete") {
withTempView("t_changelog_full_file_delete_changes") {
sql("""
|create table local.db.t_changelog_full_file_delete (id int, v string, p int)
|using iceberg
|partitioned by (p)
|tblproperties ('format-version' = '2')
|""".stripMargin)
sql("""
|insert into local.db.t_changelog_full_file_delete
|values (1, 'a', 1), (2, 'b', 2)
|""".stripMargin)
val startSnapshotId = currentSnapshotId("local.db.t_changelog_full_file_delete")
sql("delete from local.db.t_changelog_full_file_delete where p = 1")
val endSnapshotId = currentSnapshotId("local.db.t_changelog_full_file_delete")
createChangelogView(
"local.db.t_changelog_full_file_delete",
"t_changelog_full_file_delete_changes",
startSnapshotId,
endSnapshotId)

val query =
"""
|select id, v, p, _change_type
|from t_changelog_full_file_delete_changes
|order by id
|""".stripMargin
val expected = Seq(Row(1, "a", 1, "DELETE"))
withSQLConf("spark.auron.enable" -> "false") {
checkAnswer(sql(query), expected)
}
withSQLConf("spark.auron.enable" -> "true", "spark.auron.enable.iceberg.scan" -> "true") {
val df = sql(query)
checkAnswer(df, expected)
val nativeScan = executedNativeIcebergTableScanExec(df)
assert(nativeScan.staticPlan.scanTasks.size == 1)
}
}
}
}

test("iceberg native changelog scan remains correct in dynamic pruning join") {
withTable("local.db.t_changelog_dpp", "local.db.t_changelog_dpp_dim") {
withTempView("t_changelog_dpp_changes") {
Expand Down Expand Up @@ -700,39 +742,45 @@ class AuronIcebergIntegrationSuite
}
}

test("iceberg changelog scan falls back when delete changes exist") {
withTable("local.db.t_changelog_delete") {
withTempView("t_changelog_delete_changes") {
test("iceberg native scan supports mixed insert and full-data-file delete changelog scan") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replaces the previous falls back when delete changes exist test, which covered row-level deletes. Could we keep a dedicated fallback test (e.g. non-partitioned table with a position delete, or a DeletedDataFileScanTask with non-empty existingDeletes())? It would help guard against regressions on the row-level delete / update paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. I rechecked the behavior of the replaced test and Iceberg 1.10.1.

The replaced test did not actually exercise a row-level delete. In the CI run, the insert produced two data files and the DELETE removed one complete data file, so it covered a DeletedDataFileScanTask with empty existingDeletes(), which is the case this PR now makes native.

A genuine position-delete changelog case cannot currently reach Auron's fallback guard. BaseIncrementalChangelogScan rejects any snapshot in the changelog range that contains delete manifests before Auron receives the planned tasks. In addition, Iceberg 1.10.1's CreateDataFileChangeTasks constructs both AddedRowsScanTask and DeletedDataFileScanTask with NO_DELETES, so a DeletedDataFileScanTask with non-empty existingDeletes() cannot be produced through the public changelog planner.

The existing "iceberg scan falls back when delete files exist" test still covers position-delete fallback for regular Iceberg scans. It explicitly creates a position delete, verifies that the planned FileScanTask contains deletes, and checks that NativeIcebergTableScan is not used.

Given these limitations, I have not added a changelog integration test that would either fail inside Iceberg before reaching Auron or not exercise the intended guard. If useful, I can add a focused synthetic task-level test for the existingDeletes() guard instead.

withTable("local.db.t_changelog_mixed_delete") {
withTempView("t_changelog_mixed_delete_changes") {
sql("""
|create table local.db.t_changelog_delete (id int, v string)
|create table local.db.t_changelog_mixed_delete (id int, v string, p int)
Comment on lines +745 to +749
|using iceberg
|partitioned by (p)
|tblproperties ('format-version' = '2')
|""".stripMargin)
sql("insert into local.db.t_changelog_delete values (1, 'a'), (2, 'b')")
val startSnapshotId = currentSnapshotId("local.db.t_changelog_delete")
sql("delete from local.db.t_changelog_delete where id = 1")
val endSnapshotId = currentSnapshotId("local.db.t_changelog_delete")
sql("""
|insert into local.db.t_changelog_mixed_delete
|values (1, 'a', 1), (2, 'b', 2)
|""".stripMargin)
val startSnapshotId = currentSnapshotId("local.db.t_changelog_mixed_delete")
sql("delete from local.db.t_changelog_mixed_delete where p = 1")
sql("insert into local.db.t_changelog_mixed_delete values (3, 'c', 3)")
val endSnapshotId = currentSnapshotId("local.db.t_changelog_mixed_delete")
createChangelogView(
"local.db.t_changelog_delete",
"t_changelog_delete_changes",
"local.db.t_changelog_mixed_delete",
"t_changelog_mixed_delete_changes",
startSnapshotId,
endSnapshotId)

val query =
"""
|select id, v, _change_type, _change_ordinal, _commit_snapshot_id
|from t_changelog_delete_changes
|select id, v, p, _change_type, _change_ordinal, _commit_snapshot_id
|from t_changelog_mixed_delete_changes
|order by id, _change_type
|""".stripMargin
var expected: Seq[Row] = Nil
withSQLConf("spark.auron.enable" -> "false") {
expected = sql(query).collect().toSeq
}
assert(expected.exists(row => row.getString(3) == "DELETE"))
assert(expected.exists(row => row.getString(3) == "INSERT"))
withSQLConf("spark.auron.enable" -> "true", "spark.auron.enable.iceberg.scan" -> "true") {
val df = sql(query)
checkAnswer(df, expected)
val plan = df.queryExecution.executedPlan.toString()
assert(!plan.contains("NativeIcebergTableScan"))
executedNativeIcebergTableScanExec(df)
}
}
}
Expand Down
Loading