Skip to content
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
431671b
Support Iceberg v3 field defaults
Aug 11, 2026
e3f48e4
Use projected schema for Iceberg defaults
Aug 11, 2026
ec80b19
Reject Iceberg timestamp NTZ defaults on GPU
Aug 13, 2026
66935b3
Test Iceberg defaults independently in metadata
Aug 13, 2026
2f1c849
Merge branch 'main' into fix/iceberg-v3-default-semantics
res-life Aug 25, 2026
7a21bd1
Check equality delete fields for Iceberg defaults
res-life Aug 25, 2026
aa30271
Fix Iceberg equality delete schema compilation
res-life Aug 25, 2026
55141b2
Reject unsupported Iceberg default types
res-life Aug 25, 2026
4967587
Cover Iceberg default types and equality deletes
res-life Aug 25, 2026
d006eff
Detect unsupported defaults in Iceberg collections
res-life Sep 1, 2026
132afe6
Fix Iceberg write-default integration coverage
res-life Sep 1, 2026
c936493
Test Iceberg write-default validation
res-life Sep 1, 2026
d027291
Merge branch 'main' into fix/iceberg-v3-default-semantics
res-life Sep 2, 2026
48a7811
Fix Iceberg default conversion compatibility
res-life Sep 2, 2026
e1605fa
Test Iceberg write-default validation on CPU
res-life Sep 2, 2026
68301ac
Test Iceberg write-default application
res-life Sep 2, 2026
19d00b9
Test GPU write-default application
res-life Sep 2, 2026
f71b462
Handle nested Iceberg equality fields
res-life Sep 2, 2026
db3d735
Use exact Iceberg result assertion
res-life Sep 2, 2026
97afb2e
Split Iceberg tests by operation
res-life Sep 2, 2026
bcd4f88
Route Iceberg defaults through a shim bridge
res-life Sep 4, 2026
68038ba
Merge remote-tracking branch 'origin/main' into fix/iceberg-v3-defaul…
res-life Sep 4, 2026
74c3d5d
Verify Iceberg default GPU plans
res-life Sep 4, 2026
bddc4c0
Fall back nested Iceberg equality deletes
res-life Sep 4, 2026
221853c
Limit Iceberg defaults support to reads
res-life Sep 7, 2026
5ed1d2c
Simplify Iceberg default scan validation
res-life Sep 8, 2026
b4a1c3f
Merge branch 'main' into fix/iceberg-v3-default-semantics
res-life Sep 8, 2026
e0a01a0
Generate Iceberg default test data
res-life Sep 9, 2026
10086bf
Merge remote-tracking branch 'origin/main' into fix/iceberg-v3-defaul…
res-life Sep 9, 2026
12d6a78
Merge main into fix/iceberg-v3-default-semantics
res-life Oct 10, 2026
2bb138d
Preserve struct validity when projecting Iceberg defaults
res-life Oct 19, 2026
a767fbe
Disambiguate Iceberg Parquet root field lookup
res-life Oct 19, 2026
7fdada4
Preserve existing fields in Iceberg projection test expectations
res-life Oct 19, 2026
8a12457
Use an unpruned CPU oracle for nested Iceberg defaults
res-life Oct 19, 2026
baf710b
Preserve requested Iceberg containers removed by projection
res-life Oct 19, 2026
76709f1
Merge main into fix/iceberg-v3-default-semantics
res-life Oct 19, 2026
02514fe
Fix Iceberg defaults with filters and hidden timestamp helpers
res-life Oct 19, 2026
bb7cd95
Preserve all requested Iceberg container paths
res-life Oct 21, 2026
774b3ca
Merge main into fix/iceberg-v3-default-semantics
res-life Oct 23, 2026
1cfe9bf
Merge branch 'main' into fix/iceberg-v3-default-semantics
res-life Oct 27, 2026
72e1b44
Decode Iceberg validity helpers using physical types
res-life Oct 27, 2026
f86237b
Merge branch 'main' into fix/iceberg-v3-default-semantics
res-life Oct 27, 2026
259d6b9
Preserve Iceberg helper IDs and legacy list elements
res-life Oct 27, 2026
c088783
Decode opaque validity helper groups physically
res-life Oct 27, 2026
c18d846
Add Iceberg shims for default-aware Parquet reads
res-life Oct 27, 2026
41cdad9
Refactor Parquet helper schema traversal with visitors
res-life Oct 27, 2026
c4170ab
Organize Iceberg read and delete regression tests
res-life Oct 27, 2026
1513612
Merge origin/main for VARIANT schema conversion
res-life Oct 27, 2026
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 @@ -78,6 +78,17 @@ public static DeleteFile writeDeleteFile(
throws IOException {
int[] equalityFieldIds =
deleteRowSchema.columns().stream().mapToInt(Types.NestedField::fieldId).toArray();
return writeDeleteFile(table, out, partition, deletes, deleteRowSchema, equalityFieldIds);
}

public static DeleteFile writeDeleteFile(
Table table,
OutputFile out,
StructLike partition,
List<Record> deletes,
Schema deleteRowSchema,
int[] equalityFieldIds)
throws IOException {
FileWriterFactory<Record> factory =
newWriterFactory(table, deleteRowSchema, equalityFieldIds);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions;
import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader;
import org.apache.iceberg.spark.source.GpuSparkScan;
import org.apache.iceberg.types.Types;
import org.apache.spark.sql.catalyst.InternalRow;
import org.apache.spark.sql.connector.read.Scan;
import org.apache.spark.sql.connector.write.DeltaBatchWrite;
Expand All @@ -60,6 +61,16 @@ public interface IcebergShimUtils {
/** Returns the Iceberg table format version from the table's current metadata. */
int formatVersion(Table table);

/** Returns whether this field has an Iceberg v3 initial default. */
default boolean hasInitialDefault(Types.NestedField field) {
return false;
}

/** Converts an Iceberg v3 initial default to Spark's internal value representation. */
default Object initialDefaultToSpark(Types.NestedField field) {
throw new UnsupportedOperationException("Iceberg initial defaults are not supported");
}

/**
* Returns the fully qualified location URI of an Iceberg {@link ContentFile},
* e.g. {@code "s3://bucket/path/to/file.parquet"} or {@code "file:/path/to/file.parquet"}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions;
import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader;
import org.apache.iceberg.spark.source.GpuSparkScan;
import org.apache.iceberg.types.Types;
import org.apache.spark.sql.catalyst.InternalRow;
import org.apache.spark.sql.connector.read.Scan;
import org.apache.spark.sql.connector.write.DeltaBatchWrite;
Expand Down Expand Up @@ -78,6 +79,14 @@ public static int formatVersion(Table table) {
return IMPL.formatVersion(table);
}

public static boolean hasInitialDefault(Types.NestedField field) {
return IMPL.hasInitialDefault(field);
}

public static Object initialDefaultToSpark(Types.NestedField field) {
return IMPL.initialDefaultToSpark(field);
}

public static int rowIdFieldId() {
return ROW_ID_FIELD_ID;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,37 @@

package com.nvidia.spark.rapids.iceberg

import scala.collection.JavaConverters._

import com.nvidia.spark.rapids.{RapidsConf, RapidsMeta}
import org.apache.iceberg.{Table, TableProperties}
import org.apache.iceberg.{Schema, Table, TableProperties}
import org.apache.iceberg.types.{Types, TypeUtil}

import org.apache.spark.sql.execution.SparkPlan

/** Common planning gate for Iceberg table format versions. */
object IcebergFormatVersionSupport {
private val MaxSupportedFormatVersion = 2
private val SupportedDefaultTypeIds = Set(
"BOOLEAN", "INTEGER", "LONG", "FLOAT", "DOUBLE", "DATE", "TIMESTAMP",
"STRING", "BINARY", "DECIMAL")

def tagForFormatVersion(table: Table, meta: RapidsMeta[_, _, _]): Unit = {
tagForFormatVersion(ShimUtils.formatVersion(table), meta)
tagForFormatVersion(table, table.schema(), meta)
}

def tagForFormatVersion(
table: Table,
schemaToCheck: Schema,
meta: RapidsMeta[_, _, _]): Unit = {
val formatVersion = ShimUtils.formatVersion(table)
tagForFormatVersion(formatVersion, meta)
if (formatVersion > MaxSupportedFormatVersion && meta.conf.isIcebergV3Enabled) {
unsupportedDefault(schemaToCheck).foreach { case (path, typeName) =>
meta.willNotWorkOnGpu(
s"Iceberg default for field '$path' with type $typeName is not supported on GPU")
}
}
}

def tagForFormatVersion(
Expand All @@ -50,6 +70,64 @@ object IcebergFormatVersionSupport {
}
}

private[iceberg] def unsupportedDefault(schema: Schema): Option[(String, String)] = {
def find(fields: Seq[Types.NestedField], parent: String): Option[(String, String)] = {
fields.iterator.map { field =>
val path = if (parent.isEmpty) field.name() else s"$parent.${field.name()}"
val hasDefault = ShimUtils.hasInitialDefault(field)
val fieldType = field.`type`()

val unsupportedTimestampNtz = fieldType == Types.TimestampType.withoutZone()
if (hasDefault && fieldType.isPrimitiveType &&
(!SupportedDefaultTypeIds.contains(fieldType.typeId().name()) ||
unsupportedTimestampNtz)) {
Some(path -> fieldType.toString)
} else if (hasDefault && !fieldType.isPrimitiveType && !fieldType.isStructType) {
Some(path -> fieldType.toString)
} else if (fieldType.isNestedType) {
find(fieldType.asNestedType().fields().asScala.toSeq, path)
} else {
None
}
}.collectFirst { case Some(value) => value }
}

find(schema.columns().asScala.toSeq, "")
}

def withRequiredFields(
tableSchema: Schema,
expectedSchema: Schema,
requiredFieldIds: Seq[Int]): Schema = {
val projectedIds = TypeUtil.getProjectedIds(expectedSchema).asScala.map(_.toInt).toSet
val requiredFields = requiredFieldIds.distinct.map { fieldId =>
val field = Option(tableSchema.findField(fieldId)).getOrElse {
throw new IllegalArgumentException(s"Cannot find required field for ID $fieldId")
}

// Iceberg permits an equality-delete field to be a primitive nested in a struct. However,
// GpuDeleteFilter builds both its equality-delete schema and its probe expressions from the
// top-level columns in the table and required schemas. Adding a nested leaf as a top-level
// column here would make the planning check pass, but GpuDeleteFilter would later fail to
// resolve the field ID or bind it to the wrong input column. Validate every equality field,
// including fields already projected by the query, and fall back until GpuDeleteFilter can
// preserve and extract the complete nested path.
if (tableSchema.asStruct().field(fieldId) == null) {
throw new UnsupportedOperationException(
s"Nested equality-delete field '${field.name()}' with ID $fieldId is not supported " +
"on GPU")
}
field
}
val missingFields = requiredFields.filterNot(field => projectedIds.contains(field.fieldId()))

if (missingFields.isEmpty) {
expectedSchema
} else {
new Schema((expectedSchema.columns().asScala ++ missingFields).asJava)
}
}

private def tagMergeRowsAncestor(
meta: Option[RapidsMeta[_, _, _]],
reason: String): Unit = meta match {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,23 @@ private[iceberg] case class FetchConstant(
}
}

/** Fill a field missing from an old data file with its Iceberg initial default. */
private[iceberg] case class FillDefault(
fieldId: Int,
value: Any,
sparkType: DataType
) extends ColumnAction {
override def execute(ctx: ColumnActionContext): CudfColumnVector = {
withResource(GpuScalar.from(value, sparkType)) { scalar =>
CudfColumnVector.fromScalar(scalar, ctx.numRows)
}
}

override def display(indent: Int): String = {
" " * indent + s"FillDefault(fieldId=$fieldId, ${sparkType.simpleString})"
}
}

/** Fill with null values for missing optional column. */
private[iceberg] case class FillNull(sparkType: DataType) extends ColumnAction {
override def execute(ctx: ColumnActionContext): CudfColumnVector = {
Expand Down Expand Up @@ -373,7 +390,6 @@ private[iceberg] case class ProcessStruct(
}
}


/**
* Helper object for building column actions when field is missing from file schema.
* Shared between ActionBuildingVisitor and GpuParquetReaderPostProcessor.
Expand All @@ -384,10 +400,10 @@ private[iceberg] object MissingFieldActionBuilder {
* Checks constants, metadata columns, and optionality.
*/
def buildAction(
fieldId: Int,
field: Types.NestedField,
sparkType: DataType,
isFieldOptional: Boolean,
idToConstant: JMap[Integer, _]): ColumnAction = {
val fieldId = field.fieldId()
// Row-lineage metadata needs per-row inheritance rather than ordinary constants.
if (fieldId == ShimUtils.rowIdFieldId()) {
return InheritRowId
Expand All @@ -412,11 +428,16 @@ private[iceberg] object MissingFieldActionBuilder {
throw new UnsupportedOperationException("IS_DELETED meta column is not supported yet")
}

// 3. Check if optional - fill null
if (isFieldOptional) {
// 3. Materialize Iceberg's initial default for data written before this field was added.
if (ShimUtils.hasInitialDefault(field)) {
return FillDefault(fieldId, ShimUtils.initialDefaultToSpark(field), sparkType)
}

// 4. Check if optional - fill null
if (field.isOptional) {
FillNull(sparkType)
} else {
// 4. Required field missing - throw error
// 5. Required field without an initial default is invalid.
throw new IllegalArgumentException(s"Missing required field: $fieldId")
}
}
Expand Down Expand Up @@ -478,9 +499,8 @@ private class ActionBuildingVisitor(
return FillNull(sparkType)
}
return MissingFieldActionBuilder.buildAction(
currentField.fieldId(),
currentField,
sparkType,
currentField.isOptional,
idToConstant)
Comment thread
res-life marked this conversation as resolved.
}

Expand Down Expand Up @@ -516,8 +536,7 @@ private class ActionBuildingVisitor(
override def beforeField(field: Types.NestedField, partner: Type): Unit = {
fieldStack.push((field, partner,
isInsideConstantStruct ||
(field.`type`().isStructType &&
idToConstant.containsKey(field.fieldId()))))
(field.`type`().isStructType && idToConstant.containsKey(field.fieldId()))))
}

override def afterField(field: Types.NestedField, partner: Type): Unit = {
Expand All @@ -539,9 +558,8 @@ private class ActionBuildingVisitor(
return FillNull(sparkType)
}
return MissingFieldActionBuilder.buildAction(
currentField.fieldId(),
currentField,
sparkType,
currentField.isOptional,
idToConstant)
}

Expand All @@ -563,9 +581,8 @@ private class ActionBuildingVisitor(
return FillNull(sparkType)
}
return MissingFieldActionBuilder.buildAction(
currentField.fieldId(),
currentField,
sparkType,
currentField.isOptional,
idToConstant)
}

Expand Down Expand Up @@ -604,9 +621,8 @@ private class ActionBuildingVisitor(
FillNull(expectedType)
} else {
MissingFieldActionBuilder.buildAction(
currentField.fieldId(),
currentField,
expectedType,
currentField.isOptional,
idToConstant)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ class AddEqDeletes extends UDF3[String, String, String, Unit] with Logging {
outputFile,
partitionValue.orNull,
deleteRecords.map(_.asInstanceOf[Record]).asJava,
parquetFileInfo.deleteSchema)
parquetFileInfo.deleteSchema,
parquetFileInfo.equalityFieldIds.toArray)
}.foldLeft(table.newRowDelta())(_.addDeletes(_))
.commit()

Expand All @@ -107,9 +108,21 @@ class AddEqDeletes extends UDF3[String, String, String, Unit] with Logging {
object AddEqDeletes extends Logging {
private val PartitionColId = MetadataColumns.PARTITION_COLUMN_ID - 20000

private def primitiveFieldIds(field: NestedField): Seq[Int] = {
if (field.`type`().isPrimitiveType) {
Seq(field.fieldId())
} else if (field.`type`().isStructType) {
field.`type`().asStructType().fields().asScala.flatMap(primitiveFieldIds).toSeq
} else {
throw new IllegalArgumentException(
s"Equality-delete field ${field.name()} must be primitive or nested in a struct")
}
}

case class ParquetFileInfo(
fileSchema: ShadedMessageType,
deleteSchema: Schema,
equalityFieldIds: Seq[Int],
partitionSchema: Option[StructType]) {
val readSchema: Schema = partitionSchema match {
case Some(p) =>
Expand All @@ -127,13 +140,22 @@ object AddEqDeletes extends Logging {
GenericParquetReaders.buildReader(readSchema, fileSchema)

def nameMapping: NameMapping = {
def toMappedField(field: NestedField): MappedField = {
if (field.`type`().isStructType) {
val nestedFields = field.`type`().asStructType().fields().asScala.map(toMappedField)
MappedField.of(field.fieldId(), field.name(), MappedFields.of(nestedFields.toSeq: _*))
} else {
MappedField.of(field.fieldId(), field.name())
}
}

val deleteFieldsMapping = deleteSchema
.columns()
.asScala
.map(f => MappedField.of(f.fieldId(), f.name()))
.map(toMappedField)

val partitionFieldsMapping = partitionSchema
.map(_.fields().asScala.map(f => MappedField.of(f.fieldId(), f.name())))
.map(_.fields().asScala.map(toMappedField))
.map(fields => MappedField.of(PartitionColId, MetadataColumns.PARTITION_COLUMN_NAME,
MappedFields.of(fields.toSeq: _*)))

Expand All @@ -150,21 +172,24 @@ object AddEqDeletes extends Logging {
withResource(icebergPartitionedFile.newReader()) { reader =>
logDebug(s"Reading eq delete parquet file $parquetFile, " +
s"schema:\n${reader.getFileMetaData.getSchema}")
val colNames = reader
val columnPaths = reader
.getFileMetaData
.getSchema
.getColumns
.asScala
.map(_.getPath.head)
.filterNot(MetadataColumns.isMetadataColumn)
.map(_.getPath)
.filterNot(path => MetadataColumns.isMetadataColumn(path.head))
.map(_.mkString("."))
.toSeq

val deleteSchema = tableSchema.select(colNames: _*)
val deleteSchema = tableSchema.select(columnPaths: _*)
val equalityFieldIds = deleteSchema.columns().asScala.flatMap(primitiveFieldIds).toSeq
val partitionSchema = Option(table.spec()).map(_.partitionType())

ParquetFileInfo(reader.getFileMetaData.getSchema,
deleteSchema,
equalityFieldIds,
partitionSchema)
}
}
}
}
Loading
Loading