Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
725f18b
Add GPU support for Iceberg v3 row lineage writes
res-life Aug 25, 2026
396d97e
Verify GPU CTAS execution for Iceberg row lineage
res-life Sep 2, 2026
70b6f96
Merge branch 'main' into fix/issue-15441-row-lineage-write
res-life Sep 4, 2026
a730620
Fix row lineage test coverage
res-life Sep 5, 2026
52578c1
Merge branch 'main' into fix/issue-15441-row-lineage-write
res-life Sep 7, 2026
8b6b451
Simplify Iceberg row lineage CTAS test
res-life Sep 7, 2026
25794ad
Simplify Iceberg row lineage delete test
res-life Sep 7, 2026
4a8b7d8
Simplify Iceberg row lineage merge test
res-life Sep 7, 2026
890a9f1
Simplify Iceberg row lineage write tests
res-life Sep 7, 2026
0f0151d
Simplify Iceberg row lineage update test
res-life Sep 7, 2026
a88d3a6
Merge branch 'main' into fix/issue-15441-row-lineage-write
res-life Sep 7, 2026
683c7ee
Remove Iceberg rewrite data files support
res-life Sep 8, 2026
a84b7fc
Use datagen for Iceberg row lineage tests
res-life Sep 8, 2026
e41202c
Make Iceberg row lineage writes deterministic
res-life Sep 8, 2026
7e4a47f
Fix Iceberg row lineage replace data writes
res-life Sep 8, 2026
aaf88ac
Fix Scala import order
res-life Sep 8, 2026
e48289e
Fix Iceberg row lineage writer visibility
res-life Sep 8, 2026
0c73dec
Merge branch 'main' into fix/issue-15441-row-lineage-write
res-life Sep 8, 2026
1d89ff1
Align GPU data writers with Spark API
res-life Sep 9, 2026
b0eac4c
Merge branch 'main' into fix/issue-15441-row-lineage-write
res-life Sep 10, 2026
3b5e4e8
Remove duplicate Iceberg cleanup bridge
res-life Sep 10, 2026
ce95927
Revert unrelated Iceberg formatting changes
res-life Sep 10, 2026
e5b6571
Cache Iceberg row lineage metadata ordinals
res-life Sep 10, 2026
28cb8ce
Remove redundant GPU merge output padding
res-life Sep 10, 2026
680c184
Verify Iceberg next row ID after GPU writes
res-life Sep 10, 2026
482fb08
Merge branch 'main' into fix/issue-15441-row-lineage-write
res-life Sep 11, 2026
17f975d
Preserve row lineage and partition order in GPU Iceberg writes
res-life Sep 11, 2026
71a95da
Exercise Iceberg v3 across existing test matrices
res-life Sep 11, 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 @@ -252,7 +252,7 @@ class GpuPositionDeltaWriterFactory(
}


trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] {
trait GpuIcebergDeltaWriter extends DeltaWriter[ColumnarBatch] with GpuDeltaBatchWriter {

def context: GpuWriteContext

Expand Down Expand Up @@ -470,7 +470,7 @@ class GpuBasePositionDeltaWriter(
* Base trait for delta writers that handle both deletes and data writes.
* This is the GPU equivalent of Java's DeleteAndDataDeltaWriter.
*/
trait GpuDeleteAndDataDeltaWriter extends GpuDeltaWriter {
trait GpuDeleteAndDataDeltaWriter extends GpuIcebergDeltaWriter {
protected val table: Table
protected val delegate: GpuBasePositionDeltaWriter
protected val io: FileIO
Expand All @@ -492,6 +492,29 @@ trait GpuDeleteAndDataDeltaWriter extends GpuDeltaWriter {

private var closed: Boolean = false

protected def insertData(row: ColumnarBatch): Unit

override def insert(row: ColumnarBatch): Unit = reinsert(null, row)

override def reinsert(metadata: ColumnarBatch, row: ColumnarBatch): Unit = {
val physicalRow = withResource(Seq(metadata, row)) { _ =>
GpuDataWriterWithRowLineage.appendLineage(
row, metadata, context.dataSparkType, context.metadataSparkType)
}
insertData(physicalRow)
}

override def insertAndReinsert(
metadata: ColumnarBatch,
row: ColumnarBatch,
reinsertMask: CudfColumnVector): Unit = {
val physicalRow = withResource(Seq(metadata, row, reinsertMask)) { _ =>
GpuDataWriterWithRowLineage.appendLineage(
row, metadata, context.dataSparkType, context.metadataSparkType, reinsertMask)
}
insertData(physicalRow)
}

override def delete(metadata: ColumnarBatch, rowId: ColumnarBatch): Unit = {
require(metadata != null, "Metadata batch must be non null")

Expand Down Expand Up @@ -569,7 +592,7 @@ class GpuDeleteOnlyDeltaWriter(
table: Table,
writerFactory: GpuSparkFileWriterFactory,
deleteFileFactory: OutputFileFactory,
override val context: GpuWriteContext) extends GpuDeltaWriter {
override val context: GpuWriteContext) extends GpuIcebergDeltaWriter {

private val io: FileIO = table.io()
private val specs: mutable.Map[Integer, PartitionSpec] = table.specs().asScala
Expand Down Expand Up @@ -642,6 +665,19 @@ class GpuDeleteOnlyDeltaWriter(
throw new UnsupportedOperationException("Delete-only writer does not support inserts")
}

override def insertAndReinsert(
metadata: ColumnarBatch,
row: ColumnarBatch,
reinsertMask: CudfColumnVector): Unit = {
withResource(Seq(metadata, row, reinsertMask)) { _ =>
throw new UnsupportedOperationException("Delete-only writer does not support inserts")
}
}

override def reinsert(metadata: ColumnarBatch, row: ColumnarBatch): Unit = {
throw new UnsupportedOperationException("Delete-only writer does not support reinserts")
}

override def commit(): WriterCommitMessage = {
close()
val result = delegate.result()
Expand Down Expand Up @@ -704,7 +740,7 @@ class GpuUnpartitionedDeltaWriter(
delegate.writeDelete(batch, spec, partition)
}

override def insert(row: ColumnarBatch): Unit = {
override protected def insertData(row: ColumnarBatch): Unit = {
val spillBatch = closeOnExcept(row) { _ =>
SpillableColumnarBatch(row, ACTIVE_ON_DECK_PRIORITY)
}
Expand Down Expand Up @@ -756,7 +792,7 @@ class GpuPartitionedDeltaWriter(
delegate.writeDelete(batch, spec, partition)
}

override def insert(row: ColumnarBatch): Unit = {
override protected def insertData(row: ColumnarBatch): Unit = {
// Partition the data and write each partition
dataPartitioner.partition(row)
.safeConsume { part =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import java.util.Locale
import scala.collection.JavaConverters._
import scala.util.{Failure, Success}

import ai.rapids.cudf.{ColumnVector => CudfColumnVector}
import com.nvidia.spark.rapids._
import com.nvidia.spark.rapids.Arm.closeOnExcept
import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource}
import com.nvidia.spark.rapids.RapidsPluginImplicits.AutoCloseableSeq
import com.nvidia.spark.rapids.SpillPriorities.ACTIVE_ON_DECK_PRIORITY
import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO
Expand All @@ -47,8 +48,8 @@ import org.apache.spark.sql.execution.SparkPlan
import org.apache.spark.sql.execution.datasources.v2.{AtomicCreateTableAsSelectExec, AtomicReplaceTableAsSelectExec}
import org.apache.spark.sql.rapids.GpuWriteJobStatsTracker
import org.apache.spark.sql.rapids.shims.SparkSessionUtils
import org.apache.spark.sql.types.StructType
import org.apache.spark.sql.vectorized.ColumnarBatch
import org.apache.spark.sql.types.{LongType, StructType}
import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
import org.apache.spark.util.SerializableConfiguration


Expand All @@ -64,7 +65,7 @@ class GpuSparkWrite(cpu: Write) extends GpuWrite with RequiresDistributionAndOrd
// Iceberg's SparkWrite returns different implementations based on write mode:
// - BatchAppend for append operations
// - DynamicOverwrite for dynamic partition overwrite
// - BatchRewrite for copy-on-write operations (DELETE)
// - CopyOnWriteOperation for row-level copy-on-write operations
// Since these are private classes, we check the class name to determine which GPU version
// to use
val cpuBatch = cpu.toBatch
Expand Down Expand Up @@ -374,11 +375,18 @@ class GpuWriterFactory(val tableBroadcast: Broadcast[Table],
val outputWriterFactory: ColumnarOutputWriterFactory,
val statsTracker: GpuWriteJobStatsTracker,
val hadoopConf: SerializableConfiguration
) extends DataWriterFactory {
) extends GpuDataWriterFactory {

private lazy val fileIO: IcebergFileIO = new IcebergFileIO(tableBroadcast.value.io())

override def createWriter(partitionId: Int, taskId: Long): DataWriter[InternalRow] = {
createWriter(partitionId, taskId, null)
}

override def createWriter(
partitionId: Int,
taskId: Long,
metadataSchema: StructType): DataWriter[InternalRow] = {
val table = tableBroadcast.value
val spec = table.specs().get(outputSpecId)
val io = table.io()
Expand All @@ -402,23 +410,131 @@ class GpuWriterFactory(val tableBroadcast: Broadcast[Table],
fileIO)

if (spec.isUnpartitioned) {
new GpuUnpartitionedDataWriter(writerFactory, outputFileFactory, io, spec, targetFileSize)
new GpuUnpartitionedDataWriter(
writerFactory, outputFileFactory, io, spec, targetFileSize, metadataSchema)
.asInstanceOf[DataWriter[InternalRow]]
} else {
new GpuPartitionedDataWriter(writerFactory, outputFileFactory, io, spec, writeSchema,
dsSchema, targetFileSize, useFanout)
dsSchema, targetFileSize, useFanout, metadataSchema)
.asInstanceOf[DataWriter[InternalRow]]
}
}
}

trait GpuDataWriterWithRowLineage extends GpuDataWriter {
Comment thread
res-life marked this conversation as resolved.
protected def dataSparkType: StructType
protected def metadataSchema: StructType

override def write(record: ColumnarBatch): Unit

override def write(
metadata: ColumnarBatch,
record: ColumnarBatch): Unit = {
write(GpuDataWriterWithRowLineage.appendLineage(
record, metadata, dataSparkType, metadataSchema))
}
}

object GpuDataWriterWithRowLineage {
val lineageColumnNames: Seq[String] = Seq("_row_id", "_last_updated_sequence_number")

/**
* Returns an owned physical row batch matching dataSparkType without consuming record, metadata,
* or reinsertMask. Records that already contain all physical columns keep their existing values.
* Otherwise, appends the missing _row_id and _last_updated_sequence_number columns at the end.
*
* Without metadata, both appended columns are null. With metadata, lineage columns are looked up
* by name in metadataSchema. If reinsertMask is absent, their values are copied for every row.
* With a mask, only REINSERT rows (true) copy metadata; INSERT rows (false) receive nulls even
* when their metadata contains values. Metadata nulls are preserved for Iceberg's lineage
* inheritance mechanism; this method does not assign row IDs or sequence numbers.
*
* For example, a batch containing a REINSERT followed by an INSERT:
* {{{
* record:
* id amount
* 1 200
* 2 300
*
* metadata:
* _row_id _last_updated_sequence_number
* 101 null
* 999 8
*
* reinsertMask: [true, false]
*
* result (columns in dataSparkType order):
* id amount _row_id _last_updated_sequence_number
* 1 200 101 null
* 2 300 null null
* }}}
* The REINSERT preserves row ID 101; the INSERT ignores metadata values 999 and 8 so its lineage
* can be inherited. Input row order is unchanged.
*/
def appendLineage(
record: ColumnarBatch,
metadata: ColumnarBatch,
dataSparkType: StructType,
metadataSchema: StructType,
reinsertMask: CudfColumnVector = null): ColumnarBatch = {
if (reinsertMask != null) {
require(reinsertMask.getRowCount == record.numRows(),
"Reinsert mask row count does not match record row count")
}
val missingColumnCount = dataSparkType.length - record.numCols()
if (missingColumnCount == 0) {
GpuColumnVector.combineColumns(record)
} else {
require(missingColumnCount == lineageColumnNames.length,
s"Expected ${lineageColumnNames.length} row lineage " +
s"columns but record is missing $missingColumnCount columns")
require(dataSparkType.takeRight(missingColumnCount).map(_.name).toSeq == lineageColumnNames,
"Expected row lineage columns at the end of the write schema")
if (metadata != null) {
require(metadata.numRows() == record.numRows(),
s"Metadata row count ${metadata.numRows()} does not match record row count " +
s"${record.numRows()}")
}

val lineageColumns = closeOnExcept(new Array[ColumnVector](missingColumnCount)) { columns =>
lineageColumnNames.zipWithIndex.foreach { case (name, index) =>
columns(index) = if (metadata == null) {
// Newly inserted rows inherit both lineage values from the Iceberg commit.
GpuColumnVector.fromNull(record.numRows(), LongType)
} else {
val column = metadata.column(metadataSchema.fieldIndex(name))
.asInstanceOf[GpuColumnVector]
if (reinsertMask == null) {
column.incRefCount()
} else {
// INSERT rows inherit lineage even when their metadata projection has values.
withResource(GpuScalar.from(null, LongType)) { nullValue =>
GpuColumnVector.from(
reinsertMask.ifElse(column.getBase, nullValue), LongType)
}
}
}
}
columns
}

withResource(new ColumnarBatch(lineageColumns, record.numRows())) { lineage =>
GpuColumnVector.combineColumns(record, lineage)
}
}
}
}

class GpuUnpartitionedDataWriter(
val fileWriterFactory: GpuSparkFileWriterFactory,
val fileFactory: OutputFileFactory,
val io: FileIO,
val spec: PartitionSpec,
val targetFileSize: Long)
extends DataWriter[ColumnarBatch] {
val targetFileSize: Long,
override protected val metadataSchema: StructType)
extends GpuDataWriterWithRowLineage {
override protected def dataSparkType: StructType = fileWriterFactory.dataSparkType

private val delegate = new GpuRollingDataWriter(
fileWriterFactory,
fileFactory,
Expand Down Expand Up @@ -460,10 +576,11 @@ class GpuPartitionedDataWriter(
val io: FileIO,
val spec: PartitionSpec,
val dataSchema: Schema,
val dataSparkType: StructType,
override val dataSparkType: StructType,
val targetFileSize: Long,
val fanoutEnabled: Boolean,
) extends DataWriter[ColumnarBatch] {
override protected val metadataSchema: StructType,
) extends GpuDataWriterWithRowLineage {

private val delegate: PartitioningWriter[SpillableColumnarBatch, DataWriteResult] =
if (fanoutEnabled) {
Expand Down
Loading
Loading