Skip to content

[AURON #2321] Support Iceberg column rename and drop-then-add in the native scan#2322

Open
lyne7-sc wants to merge 7 commits into
apache:masterfrom
lyne7-sc:fix/iceberg_rename
Open

[AURON #2321] Support Iceberg column rename and drop-then-add in the native scan#2322
lyne7-sc wants to merge 7 commits into
apache:masterfrom
lyne7-sc:fix/iceberg_rename

Conversation

@lyne7-sc

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #2321

Rationale for this change

The native Iceberg scan matches data-file columns by name, but Iceberg tracks them by field-id. After a column rename, old files read as all-NULL; after a drop-then-add of the same name, the new column reads the old column's data.

What changes are included in this PR?

Resolve columns by Iceberg field-id instead of by name:

  • proto: add field_id to Field.
  • JVM (AuronIcebergSourceUtil, IcebergScanSupport, NativeConverters): extract top-level name → field-id from the scan's expectedSchema() and serialize it into the plan.
  • native (auron-planner, scan/mod.rs): stamp the id into Arrow field metadata (PARQUET:field_id); fields_match matches by id when present, else falls back to case-insensitive name matching (non-Iceberg scans unchanged).

Nested-struct evolution and ORC rename/drop fall back to Spark, additive evolution stays native.

Are there any user-facing changes?

Yes. Iceberg queries on renamed or drop-then-added columns now return correct results under the native scan. Unsupported cases fall back to Spark. No API change.

How was this patch tested?

Added cases to AuronIcebergIntegrationSuite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comment thread native-engine/datafusion-ext-plans/src/scan/mod.rs

@SteNicholas SteNicholas left a comment

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.

@lyne7-sc, thanks for contribution. I left some comments for this pull request. PTAL.

.metadata()
.get(PARQUET_FIELD_ID_META_KEY)
.is_some_and(|file_field_id| file_field_id == table_field_id),
None => table_field.name().eq_ignore_ascii_case(file_field.name()),

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.

When table_field has a PARQUET:field_id but file_field does not, is_some_and returns false and there is no name-based fallback — the column simply doesn't match.

For spec-compliant Iceberg Parquet files this is fine (the Iceberg spec mandates field IDs, and arrow-rs populates them into Arrow metadata). But if an older Parquet writer omitted the field_id in the Thrift SchemaElement, or if a non-Iceberg Parquet file happens to be served through this path, every column would fail to match and the scan would produce all-NULL rows.

Consider falling back to name matching when file_field lacks a field ID:

fn fields_match(table_field: &Field, file_field: &Field) -> bool {
    match table_field.metadata().get(PARQUET_FIELD_ID_META_KEY) {
        Some(table_field_id) => match file_field.metadata().get(PARQUET_FIELD_ID_META_KEY) {
            Some(file_field_id) => file_field_id == table_field_id,
            None => table_field.name().eq_ignore_ascii_case(file_field.name()),
        },
        None => table_field.name().eq_ignore_ascii_case(file_field.name()),
    }
}

This preserves field-id matching when both sides have IDs, but degrades gracefully to name matching otherwise.

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.

Updated fields_match to use a nested match. When file_field lacks a field id, it now falls back to case-insensitive name matching.

@@ -75,6 +76,27 @@ object IcebergScanSupport extends Logging {
partitionSchema.fields.forall(field => NativeConverters.isTypeSupported(field.dataType)),
"Has unsupported schema type.")

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.

The inspected block bundles detectRenameOrDrop and expectedFieldIds in a single try/catch. If detectRenameOrDrop throws (e.g., a catalog timeout in table.schemas()), expectedFieldIds — which is independent and likely to succeed since expectedSchema() is a local field access — is also discarded. The scan falls back to Spark entirely.

Consider separating them:

val fieldIdsByName = try {
  AuronIcebergSourceUtil.expectedFieldIds(scan.asInstanceOf[AnyRef])
} catch { case NonFatal(t) => logWarning(...); return None }

val renameOrDrop = try {
  AuronIcebergSourceUtil.detectRenameOrDrop(scan.asInstanceOf[AnyRef])
} catch { case NonFatal(t) =>
  logWarning(...)
  AuronIcebergSourceUtil.RenameOrDrop(topLevel = true, nested = true) // conservative
}

This way a transient schema-history failure can still fall back on the ORC/nested guards while preserving field-id matching for Parquet.

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.

Split this into two independent inspection steps.

expectedFieldIds failure returns None because field-id mapping is required for safe native planning.

detectRenameOrDrop failure returns None because rename/drop safety cannot be determined reliably.

This avoids reporting a misleading nested rename/drop fallback reason when the actual issue is schema-history inspection failure.

@@ -75,6 +76,27 @@ object IcebergScanSupport extends Logging {
partitionSchema.fields.forall(field => NativeConverters.isTypeSupported(field.dataType)),
"Has unsupported schema type.")

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.

Minor: plan() is called twice per query (once in isSupported, once in convert — pattern from IcebergConvertProvider). This PR adds detectRenameOrDrop inside plan(), which iterates all historical schema versions via table.schemas().values(). For long-lived tables this loading + comparison now happens twice per scan node. Consider caching the plan result (e.g., via a TreeNodeTag on the BatchScanExec) to avoid this.

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.

Added TreeNodeTag caching on the BatchScanExec. plan(exec) now reuses the cached result on the second call


def detectRenameOrDrop(scan: AnyRef): RenameOrDrop = {
val table = asBatchQueryScan(scan).table()
val currentFields = collectFieldIdToName(table.schema())

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.

Two observations on detectRenameOrDrop:

  1. table.schemas().values() includes the current schema. When compared against currentFields (built from table.schema()), every field matches itself — the entire iteration is a no-op. Consider filtering it out: table.schemas().asScala.filterNot(_._1 == table.schema().schemaId()).

  2. collectFieldIdToName hand-rolls a recursive field-ID collector. Iceberg provides TypeUtil.indexById(schema.asStruct()) which returns Map<Integer, NestedField> covering all nested fields. The only extra piece is the topLevel flag, which can be derived trivially from schema.columns(). Using the Iceberg utility would reduce maintenance surface and benefit from upstream fixes for new type variants.

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.

Addressed both points:

  1. Skipped the current schema.
  2. Replaced the local recursive collector with TypeUtil.indexById(schema.asStruct()).

fileSchema.fields.forall(field => fieldIdsByName.contains(field.name)),
"Failed to find field ids for all Iceberg data columns.")

val partitions = inputPartitions(exec)

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.

Nit: the assertion message "Failed to find field ids for all Iceberg data columns." doesn't include which columns are missing, making debugging harder. Consider:

val missing = fileSchema.fields.filterNot(f => fieldIdsByName.contains(f.name)).map(_.name)
assert(missing.isEmpty, s"Missing Iceberg field ids for columns: ${missing.mkString(", ")}")

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.

Updated the assertion to be more explicit.

lyne7-sc added 2 commits June 16, 2026 20:29
…berg_rename

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

@lyne7-sc lyne7-sc left a comment

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.

@SteNicholas Thanks for the detailed review. I’ve addressed the comments and updated the PR. Please take another look when you get a chance.

.metadata()
.get(PARQUET_FIELD_ID_META_KEY)
.is_some_and(|file_field_id| file_field_id == table_field_id),
None => table_field.name().eq_ignore_ascii_case(file_field.name()),

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.

Updated fields_match to use a nested match. When file_field lacks a field id, it now falls back to case-insensitive name matching.


def detectRenameOrDrop(scan: AnyRef): RenameOrDrop = {
val table = asBatchQueryScan(scan).table()
val currentFields = collectFieldIdToName(table.schema())

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.

Addressed both points:

  1. Skipped the current schema.
  2. Replaced the local recursive collector with TypeUtil.indexById(schema.asStruct()).

@@ -75,6 +76,27 @@ object IcebergScanSupport extends Logging {
partitionSchema.fields.forall(field => NativeConverters.isTypeSupported(field.dataType)),
"Has unsupported schema type.")

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.

Added TreeNodeTag caching on the BatchScanExec. plan(exec) now reuses the cached result on the second call

fileSchema.fields.forall(field => fieldIdsByName.contains(field.name)),
"Failed to find field ids for all Iceberg data columns.")

val partitions = inputPartitions(exec)

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.

Updated the assertion to be more explicit.

@@ -75,6 +76,27 @@ object IcebergScanSupport extends Logging {
partitionSchema.fields.forall(field => NativeConverters.isTypeSupported(field.dataType)),
"Has unsupported schema type.")

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.

Split this into two independent inspection steps.

expectedFieldIds failure returns None because field-id mapping is required for safe native planning.

detectRenameOrDrop failure returns None because rename/drop safety cannot be determined reliably.

This avoids reporting a misleading nested rename/drop fallback reason when the actual issue is schema-history inspection failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native Iceberg scan returns wrong data after column rename / drop-then-add

5 participants