This is an automated email from the ASF dual-hosted git repository.

ahmedabu98 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 57801c9b2d2 [Iceberg CDC sink] Write deltas transform (#40031)
57801c9b2d2 is described below

commit 57801c9b2d2668a810bb19bebcbc9c90caa92345
Author: Ahmed Abualsaud <[email protected]>
AuthorDate: Thu Sep 17 09:49:49 2026 +0100

    [Iceberg CDC sink] Write deltas transform (#40031)
    
    * write deltas
    
    * destination shard
    
    * cleanup
    
    * address comments
    
    * use serializable table
---
 .../sdk/io/iceberg/cdc/sink/ShardDeltaFiles.java   |  87 +++
 .../beam/sdk/io/iceberg/cdc/sink/WriteDeltas.java  | 204 +++++
 .../io/iceberg/cdc/sink/ShardDeltaFilesTest.java   | 185 +++++
 .../sdk/io/iceberg/cdc/sink/WriteDeltasTest.java   | 824 +++++++++++++++++++++
 4 files changed, 1300 insertions(+)

diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/ShardDeltaFiles.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/ShardDeltaFiles.java
new file mode 100644
index 00000000000..f86b74f32fa
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/ShardDeltaFiles.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.iceberg.cdc.sink;
+
+import com.google.auto.value.AutoValue;
+import java.util.List;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.io.iceberg.SerializableDataFile;
+import org.apache.beam.sdk.io.iceberg.SerializableDeleteFile;
+import org.apache.beam.sdk.schemas.AutoValueSchema;
+import org.apache.beam.sdk.schemas.NoSuchSchemaException;
+import org.apache.beam.sdk.schemas.SchemaRegistry;
+import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
+import org.apache.beam.sdk.schemas.annotations.SchemaFieldNumber;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DeleteFile;
+
+/**
+ * Holds the serialized metadata for {@link DataFile}s and {@link DeleteFile}s 
of one {@code
+ * (destination, shard, window)} group, plus the source sequence range the 
bundle covers.
+ */
+@AutoValue
+@DefaultSchema(AutoValueSchema.class)
+public abstract class ShardDeltaFiles {
+
+  static Coder<ShardDeltaFiles> coder() {
+    try {
+      return 
SchemaRegistry.createDefault().getSchemaCoder(ShardDeltaFiles.class);
+    } catch (NoSuchSchemaException e) {
+      throw new RuntimeException("Could not build a coder for 
ShardDeltaFiles.", e);
+    }
+  }
+
+  @SchemaFieldNumber("0")
+  public abstract String getTableIdentifierString();
+
+  /** Reconstructed into live {@link DataFile}s at commit time. */
+  @SchemaFieldNumber("1")
+  public abstract List<SerializableDataFile> getDataFiles();
+
+  /** Reconstructed into live {@link DeleteFile}s at commit time. */
+  @SchemaFieldNumber("2")
+  public abstract List<SerializableDeleteFile> getDeleteFiles();
+
+  /** The minimum source sequence number covered by this bundle. */
+  @SchemaFieldNumber("3")
+  public abstract long getMinSequenceNumber();
+
+  /** The maximum source sequence number covered by this bundle. */
+  @SchemaFieldNumber("4")
+  public abstract long getMaxSequenceNumber();
+
+  public static Builder builder() {
+    return new AutoValue_ShardDeltaFiles.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder setTableIdentifierString(String tableIdString);
+
+    public abstract Builder setDataFiles(List<SerializableDataFile> dataFiles);
+
+    public abstract Builder setDeleteFiles(List<SerializableDeleteFile> 
deleteFiles);
+
+    public abstract Builder setMinSequenceNumber(long minSequenceNumber);
+
+    public abstract Builder setMaxSequenceNumber(long maxSequenceNumber);
+
+    public abstract ShardDeltaFiles build();
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/WriteDeltas.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/WriteDeltas.java
new file mode 100644
index 00000000000..8262b9ae54a
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/WriteDeltas.java
@@ -0,0 +1,204 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.iceberg.cdc.sink;
+
+import static 
org.apache.beam.sdk.io.iceberg.IcebergUtils.beamRowToIcebergRecord;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import org.apache.beam.sdk.io.iceberg.DynamicDestinations;
+import org.apache.beam.sdk.io.iceberg.IcebergCatalogConfig;
+import org.apache.beam.sdk.io.iceberg.SerializableDataFile;
+import org.apache.beam.sdk.io.iceberg.SerializableDeleteFile;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.PTransform;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.PCollection;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.SerializableTable;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.io.OutputFileFactory;
+import org.apache.iceberg.io.WriteResult;
+import org.apache.iceberg.util.PropertyUtil;
+
+/**
+ * Stage 3 of the CDC sink: consumes each complete sorted {@code (destination, 
shard, window)} group
+ * from {@link CommitWindows}, collapses each primary key to its final state 
through a {@link
+ * RecordDeltaTaskWriter}, and emits one {@link ShardDeltaFiles} with the 
serialized file metadata.
+ */
+final class WriteDeltas
+    extends PTransform<
+        PCollection<KV<DestinationShard, Iterable<KV<byte[], CdcRecord>>>>,
+        PCollection<ShardDeltaFiles>> {
+
+  private final IcebergCatalogConfig catalogConfig;
+  private final CdcWriteConfig config;
+  private final DynamicDestinations destinations;
+  private final String runId;
+
+  WriteDeltas(
+      IcebergCatalogConfig catalogConfig,
+      CdcWriteConfig config,
+      DynamicDestinations destinations,
+      String runId) {
+    this.catalogConfig = catalogConfig;
+    this.config = config;
+    this.destinations = destinations;
+    this.runId = runId;
+  }
+
+  @Override
+  public PCollection<ShardDeltaFiles> expand(
+      PCollection<KV<DestinationShard, Iterable<KV<byte[], CdcRecord>>>> 
input) {
+    return input
+        .apply(
+            "WriteDeltas",
+            ParDo.of(
+                new WriteDeltasFn(
+                    new TableSetup(catalogConfig, config, destinations, runId),
+                    config,
+                    runId,
+                    destinations.getDataSchema())))
+        .setCoder(ShardDeltaFiles.coder());
+  }
+
+  /**
+   * Writes one {@link RecordDeltaTaskWriter} per input group and emits the 
serialized {@link
+   * ShardDeltaFiles}. On any failure {@link RecordDeltaTaskWriter#abort()} 
deletes the group's
+   * written files.
+   */
+  @VisibleForTesting
+  static final class WriteDeltasFn
+      extends DoFn<KV<DestinationShard, Iterable<KV<byte[], CdcRecord>>>, 
ShardDeltaFiles> {
+
+    private final TableSetup tableSetup;
+    private final CdcWriteConfig config;
+    private final String runId;
+    private final Schema dataSchema;
+
+    WriteDeltasFn(TableSetup tableSetup, CdcWriteConfig config, String runId, 
Schema dataSchema) {
+      this.tableSetup = tableSetup;
+      this.config = config;
+      this.runId = runId;
+      this.dataSchema = dataSchema;
+    }
+
+    @ProcessElement
+    public void process(
+        @Element KV<DestinationShard, Iterable<KV<byte[], CdcRecord>>> group,
+        BoundedWindow window,
+        OutputReceiver<ShardDeltaFiles> out)
+        throws IOException {
+      String destString = group.getKey().getDestination();
+      int shardId = group.getKey().getShard();
+      TableSetup.Dest dest = tableSetup.get(destString, dataSchema);
+      // Get a frozen view of the table
+      Table table = SerializableTable.copyOf(dest.table());
+      PartitionSpec spec = dest.spec();
+
+      FileFormat dataFormat = RecordDeltaTaskWriter.dataFileFormat(table);
+      FileFormat deleteFormat = RecordDeltaTaskWriter.deleteFileFormat(table, 
dataFormat);
+      long targetFileSize =
+          PropertyUtil.propertyAsLong(
+              table.properties(),
+              TableProperties.WRITE_TARGET_FILE_SIZE_BYTES,
+              TableProperties.WRITE_TARGET_FILE_SIZE_BYTES_DEFAULT);
+
+      OutputFileFactory fileFactory =
+          OutputFileFactory.builderFor(table, shardId, /* taskId= */ 0)
+              .format(dataFormat)
+              .defaultSpec(spec)
+              .operationId(
+                  runId + "-" + window.maxTimestamp().getMillis() + "-" + 
UUID.randomUUID())
+              .build();
+      RecordDeltaTaskWriter writer =
+          RecordDeltaTaskWriter.create(
+              table,
+              spec,
+              dest.equalityFieldIds(),
+              config.getUpsert(),
+              targetFileSize,
+              fileFactory,
+              dataFormat,
+              deleteFormat);
+
+      long minSeq = Long.MAX_VALUE;
+      long maxSeq = Long.MIN_VALUE;
+      try {
+        // Sorted upstream by the pk-prefixed sort key: the collapse writer 
relies on each key's
+        // records arriving contiguous, in (seq, kind) order, and reads the 
key's pk prefix to
+        // find block boundaries.
+        for (KV<byte[], CdcRecord> keyed : group.getValue()) {
+          CdcRecord record = keyed.getValue();
+          writer.write(
+              keyed.getKey(),
+              beamRowToIcebergRecord(table.schema(), record.getData()),
+              record.getKind());
+          minSeq = Math.min(minSeq, record.getSequenceNumber());
+          maxSeq = Math.max(maxSeq, record.getSequenceNumber());
+        }
+
+        WriteResult result = writer.complete();
+        if (result.dataFiles().length == 0 && result.deleteFiles().length == 
0) {
+          return; // Empty group: emit nothing.
+        }
+        out.output(serialize(destString, table, result, minSeq, maxSeq));
+      } catch (Throwable t) {
+        // Delete this group's written files; preserve the root cause if abort 
also fails.
+        try {
+          writer.abort();
+        } catch (Exception abortEx) {
+          t.addSuppressed(abortEx);
+        }
+        throw t;
+      }
+    }
+  }
+
+  /** Serializes {@code result}'s files and returns the {@link 
ShardDeltaFiles} carrying them. */
+  static ShardDeltaFiles serialize(
+      String destString, Table table, WriteResult result, long minSeq, long 
maxSeq) {
+    Map<Integer, PartitionSpec> specs = table.specs();
+    List<SerializableDataFile> dataFiles = new 
ArrayList<>(result.dataFiles().length);
+    for (DataFile dataFile : result.dataFiles()) {
+      dataFiles.add(SerializableDataFile.from(dataFile, specs));
+    }
+    List<SerializableDeleteFile> deleteFiles = new 
ArrayList<>(result.deleteFiles().length);
+    for (DeleteFile deleteFile : result.deleteFiles()) {
+      deleteFiles.add(SerializableDeleteFile.from(deleteFile, specs));
+    }
+    return ShardDeltaFiles.builder()
+        .setTableIdentifierString(destString)
+        .setDataFiles(dataFiles)
+        .setDeleteFiles(deleteFiles)
+        .setMinSequenceNumber(minSeq)
+        .setMaxSequenceNumber(maxSeq)
+        .build();
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/sink/ShardDeltaFilesTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/sink/ShardDeltaFilesTest.java
new file mode 100644
index 00000000000..c9c0a7e70c2
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/sink/ShardDeltaFilesTest.java
@@ -0,0 +1,185 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.iceberg.cdc.sink;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
+
+import java.util.Map;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.io.iceberg.SerializableDataFile;
+import org.apache.beam.sdk.io.iceberg.SerializableDeleteFile;
+import org.apache.beam.sdk.testing.CoderProperties;
+import org.apache.beam.sdk.util.CoderUtils;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.FileContent;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.FileMetadata;
+import org.apache.iceberg.PartitionKey;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.types.Types;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for {@link ShardDeltaFiles}, the element the write stage hands the 
committer: schema-coder
+ * round-trips (including the empty-list cases), and — the invariant that made 
direct transport
+ * viable at all — that a partition value survives the coder and reconstructs 
into an identical
+ * Iceberg partition tuple, including values a rendered partition <i>path</i> 
could not round-trip.
+ */
+@RunWith(JUnit4.class)
+public class ShardDeltaFilesTest {
+
+  private static final Schema ICEBERG_SCHEMA =
+      new Schema(
+          Types.NestedField.required(1, "id", Types.IntegerType.get()),
+          Types.NestedField.required(2, "name", Types.StringType.get()));
+
+  /** Identity-partitioned on the STRING column, so partition values can 
contain a {@code '/'}. */
+  private static final PartitionSpec SPEC =
+      PartitionSpec.builderFor(ICEBERG_SCHEMA).identity("name").build();
+
+  private static final Map<Integer, PartitionSpec> SPECS = 
ImmutableMap.of(SPEC.specId(), SPEC);
+
+  /** The partition tuple {@code SPEC} produces for a row whose {@code name} 
is {@code value}. */
+  private static PartitionKey partitionFor(String value) {
+    GenericRecord record = GenericRecord.create(ICEBERG_SCHEMA);
+    record.setField("id", 1);
+    record.setField("name", value);
+    PartitionKey key = new PartitionKey(SPEC, ICEBERG_SCHEMA);
+    key.partition(record);
+    return key;
+  }
+
+  private static DataFile dataFile(String path, String partitionValue) {
+    return DataFiles.builder(SPEC)
+        .withFormat(FileFormat.PARQUET)
+        .withPath(path)
+        .withPartition(partitionFor(partitionValue))
+        .withFileSizeInBytes(100L)
+        .withRecordCount(2L)
+        .build();
+  }
+
+  /** A V3 deletion vector: PUFFIN position deletes carrying the three DV-only 
fields. */
+  private static DeleteFile deletionVector(String path, String referenced, 
String partitionValue) {
+    return FileMetadata.deleteFileBuilder(SPEC)
+        .ofPositionDeletes()
+        .withPath(path)
+        .withFormat(FileFormat.PUFFIN)
+        .withPartition(partitionFor(partitionValue))
+        .withFileSizeInBytes(80L)
+        .withRecordCount(1L)
+        .withContentOffset(4L)
+        .withContentSizeInBytes(40L)
+        .withReferencedDataFile(referenced)
+        .build();
+  }
+
+  private static ShardDeltaFiles shard(
+      Iterable<SerializableDataFile> dataFiles, 
Iterable<SerializableDeleteFile> deleteFiles) {
+    return ShardDeltaFiles.builder()
+        .setTableIdentifierString("db.t")
+        .setDataFiles(ImmutableList.copyOf(dataFiles))
+        .setDeleteFiles(ImmutableList.copyOf(deleteFiles))
+        .setMinSequenceNumber(1L)
+        .setMaxSequenceNumber(2L)
+        .build();
+  }
+
+  @Test
+  public void coderRoundTripsAllFieldsAndEmptyLists() throws Exception {
+    // facet: every field populated.
+    ShardDeltaFiles files =
+        shard(
+            ImmutableList.of(
+                SerializableDataFile.from(dataFile("/tmp/d-1.parquet", "a"), 
SPEC),
+                SerializableDataFile.from(dataFile("/tmp/d-2.parquet", "b"), 
SPEC)),
+            ImmutableList.of(
+                SerializableDeleteFile.from(
+                    deletionVector("/tmp/dv-1.puffin", "/tmp/d-1.parquet", 
"a"), SPEC)));
+
+    Coder<ShardDeltaFiles> coder = ShardDeltaFiles.coder();
+    CoderProperties.coderDecodeEncodeEqual(coder, files);
+
+    ShardDeltaFiles decoded =
+        CoderUtils.decodeFromByteArray(coder, 
CoderUtils.encodeToByteArray(coder, files));
+    assertThat(decoded.getTableIdentifierString(), equalTo("db.t"));
+    assertThat(decoded.getMinSequenceNumber(), equalTo(1L));
+    assertThat(decoded.getMaxSequenceNumber(), equalTo(2L));
+    assertThat(decoded.getDataFiles(), hasSize(2));
+    assertThat(decoded.getDeleteFiles(), hasSize(1));
+    assertThat(decoded.getDataFiles().get(0).getPath(), 
equalTo("/tmp/d-1.parquet"));
+    assertThat(decoded.getDeleteFiles().get(0).getLocation(), 
equalTo("/tmp/dv-1.puffin"));
+
+    // facet: both lists empty — a shard may carry only data files or only 
delete files.
+    ShardDeltaFiles emptyLists = shard(ImmutableList.of(), ImmutableList.of());
+    CoderProperties.coderDecodeEncodeEqual(coder, emptyLists);
+    ShardDeltaFiles decodedEmpty =
+        CoderUtils.decodeFromByteArray(coder, 
CoderUtils.encodeToByteArray(coder, emptyLists));
+    assertThat(decodedEmpty.getDataFiles(), empty());
+    assertThat(decodedEmpty.getDeleteFiles(), empty());
+  }
+
+  /**
+   * The load-bearing property of this transport: a partition value survives 
the coder and rebuilds
+   * into the same Iceberg partition tuple. The value carries a {@code '/'} on 
purpose — the
+   * previous attempt at a direct wire format rendered partitions as a 
<i>path</i> string, which
+   * split on exactly this character and had to reject it at write time. The 
typed JSON partition
+   * these types carry has no such hazard.
+   */
+  @Test
+  public void partitionValuesSurviveTheCoderAndReconstructExactly() throws 
Exception {
+    DataFile original = dataFile("/tmp/d-slash.parquet", "a/b");
+    DeleteFile originalDv = deletionVector("/tmp/dv-slash.puffin", 
"/tmp/d-slash.parquet", "a/b");
+    ShardDeltaFiles files =
+        shard(
+            ImmutableList.of(SerializableDataFile.from(original, SPEC)),
+            ImmutableList.of(SerializableDeleteFile.from(originalDv, SPEC)));
+
+    Coder<ShardDeltaFiles> coder = ShardDeltaFiles.coder();
+    ShardDeltaFiles decoded =
+        CoderUtils.decodeFromByteArray(coder, 
CoderUtils.encodeToByteArray(coder, files));
+
+    DataFile rebuilt = decoded.getDataFiles().get(0).createDataFile(SPECS);
+    assertThat(rebuilt.specId(), equalTo(SPEC.specId()));
+    assertThat(rebuilt.partition().get(0, String.class), equalTo("a/b"));
+    assertThat(rebuilt.location(), equalTo(original.location()));
+    assertThat(rebuilt.recordCount(), equalTo(original.recordCount()));
+    // No sequence number is baked in, so the eventual commit assigns one by 
snapshot inheritance.
+    assertThat(rebuilt.dataSequenceNumber(), nullValue());
+
+    DeleteFile rebuiltDv = 
decoded.getDeleteFiles().get(0).createDeleteFile(SPECS, null);
+    assertThat(rebuiltDv.partition().get(0, String.class), equalTo("a/b"));
+    assertThat(rebuiltDv.content(), equalTo(FileContent.POSITION_DELETES));
+    assertThat(rebuiltDv.format(), equalTo(FileFormat.PUFFIN));
+    assertThat(rebuiltDv.referencedDataFile(), 
equalTo("/tmp/d-slash.parquet"));
+    assertThat(rebuiltDv.contentOffset(), equalTo(4L));
+    assertThat(rebuiltDv.contentSizeInBytes(), equalTo(40L));
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/sink/WriteDeltasTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/sink/WriteDeltasTest.java
new file mode 100644
index 00000000000..c8cb90e1f28
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/sink/WriteDeltasTest.java
@@ -0,0 +1,824 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.iceberg.cdc.sink;
+
+import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.nullValue;
+import static org.hamcrest.Matchers.startsWith;
+import static org.junit.Assert.assertThrows;
+
+import java.io.File;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import org.apache.beam.sdk.coders.ByteArrayCoder;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.coders.CoderException;
+import org.apache.beam.sdk.coders.IterableCoder;
+import org.apache.beam.sdk.coders.KvCoder;
+import org.apache.beam.sdk.coders.RowCoder;
+import org.apache.beam.sdk.io.iceberg.DynamicDestinations;
+import org.apache.beam.sdk.io.iceberg.IcebergCatalogConfig;
+import org.apache.beam.sdk.io.iceberg.IcebergUtils;
+import org.apache.beam.sdk.io.iceberg.SerializableDataFile;
+import org.apache.beam.sdk.io.iceberg.SerializableDeleteFile;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.transforms.windowing.GlobalWindow;
+import org.apache.beam.sdk.util.CoderUtils;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.ValueKind;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.primitives.UnsignedBytes;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.FileContent;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.FileMetadata;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.RowDelta;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.IcebergGenerics;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.io.WriteResult;
+import org.apache.iceberg.types.Types;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for {@link WriteDeltas}, stage 3 of the CDC sink. The partition tests 
pin the headline
+ * property: partition values ride from live {@link DataFile}s into {@link 
SerializableDataFile}'s
+ * typed JSON and back unchanged, so transforms a path-rendered wire format 
cannot round-trip are
+ * simply legal; each reconstructs the emitted metadata, hand-commits it, and 
reads the table back
+ * as ground truth.
+ */
+@RunWith(JUnit4.class)
+public class WriteDeltasTest {
+
+  @Rule public transient TestPipeline p = TestPipeline.create();
+  @Rule public transient TemporaryFolder tmp = new TemporaryFolder();
+
+  /** Canonical test table schema. */
+  private static final Schema ICEBERG_SCHEMA =
+      new Schema(
+          Types.NestedField.required(1, "id", Types.IntegerType.get()),
+          Types.NestedField.optional(2, "name", Types.StringType.get()),
+          Types.NestedField.optional(3, "data", Types.StringType.get()));
+
+  private static final String SINK_ID = "test-sink";
+  private static final long BIG_TARGET_FILE_SIZE = 512L * 1024 * 1024;
+
+  /** Transform outputs collected per test; static because the runner 
serializes DoFn fields. */
+  private static final ConcurrentMap<String, List<ShardDeltaFiles>> COLLECTED =
+      new ConcurrentHashMap<>();
+
+  private File warehouseDir;
+  private Catalog catalog;
+
+  @Before
+  public void setUp() throws IOException {
+    warehouseDir = tmp.newFolder("warehouse");
+    catalog = CdcSinkTestUtils.hadoopCatalog(warehouseDir);
+  }
+
+  private IcebergCatalogConfig catalogConfig() {
+    return CdcSinkTestUtils.catalogConfig(warehouseDir);
+  }
+
+  private static CdcWriteConfig cfg() {
+    return CdcWriteConfig.builder().setSinkId(SINK_ID).build();
+  }
+
+  private WriteDeltas transform(TableIdentifier id, 
org.apache.beam.sdk.schemas.Schema dataSchema) {
+    return new WriteDeltas(
+        catalogConfig(), cfg(), DynamicDestinations.singleTable(id, 
dataSchema), "px");
+  }
+
+  private static Coder<KV<DestinationShard, Iterable<KV<byte[], CdcRecord>>>> 
groupCoder(
+      org.apache.beam.sdk.schemas.Schema dataSchema) {
+    return KvCoder.of(
+        DestinationShard.coder(),
+        IterableCoder.of(KvCoder.of(ByteArrayCoder.of(), 
CdcRecordCoder.of(dataSchema))));
+  }
+
+  /** The {@code id INT32} schema whose encoding keys the sort prefixes below. 
*/
+  private static final org.apache.beam.sdk.schemas.Schema PK_SCHEMA =
+      org.apache.beam.sdk.schemas.Schema.builder().addInt32Field("id").build();
+
+  /** {@code pkBytes} for one {@code id}, matching the sink's encoding for an 
id-keyed table. */
+  private static byte[] pkBytes(int id) {
+    try {
+      return CoderUtils.encodeToByteArray(
+          RowCoder.of(PK_SCHEMA), 
Row.withSchema(PK_SCHEMA).addValues(id).build());
+    } catch (CoderException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * A sorted-group element: the {@link CdcSortKey} bytes paired with the 
{@link CdcRecord}. The
+   * collapse writer blocks on the key's pk prefix, so it carries the row's 
encoded {@code id};
+   * every fixture here keys rows by a distinct {@code id}.
+   */
+  private static KV<byte[], CdcRecord> kv(Row data, long seq, ValueKind kind) {
+    byte[] pk = pkBytes(checkStateNotNull(data.getInt32("id")));
+    return KV.of(CdcSortKey.encode(pk, seq, kind), CdcRecord.of(data, kind, 
seq));
+  }
+
+  private static Row row(org.apache.beam.sdk.schemas.Schema schema, Object... 
values) {
+    return Row.withSchema(schema).addValues(values).build();
+  }
+
+  /** Runs the given groups through {@link WriteDeltas} and returns the 
emitted elements. */
+  private List<ShardDeltaFiles> runAndCollect(
+      TableIdentifier id,
+      org.apache.beam.sdk.schemas.Schema dataSchema,
+      List<KV<DestinationShard, Iterable<KV<byte[], CdcRecord>>>> groups) {
+    String collectKey = id + "-" + System.nanoTime();
+    COLLECTED.put(collectKey, Collections.synchronizedList(new ArrayList<>()));
+    p.apply(Create.of(groups).withCoder(groupCoder(dataSchema)))
+        .apply(transform(id, dataSchema))
+        .apply(ParDo.of(new CollectFn(collectKey)));
+    p.run().waitUntilFinish();
+    return checkStateNotNull(COLLECTED.get(collectKey));
+  }
+
+  /** Collects the transform's output into {@link #COLLECTED} under {@code 
collectKey}. */
+  private static final class CollectFn extends DoFn<ShardDeltaFiles, Void> {
+    private final String collectKey;
+
+    CollectFn(String collectKey) {
+      this.collectKey = collectKey;
+    }
+
+    @ProcessElement
+    public void process(@Element ShardDeltaFiles files) {
+      checkStateNotNull(COLLECTED.get(collectKey)).add(files);
+    }
+  }
+
+  /** Reconstructs the live {@link DataFile}s carried by {@code files}, 
against {@code table}. */
+  private static List<DataFile> dataFilesOf(Table table, ShardDeltaFiles 
files) {
+    List<DataFile> reconstructed = new ArrayList<>();
+    for (SerializableDataFile f : files.getDataFiles()) {
+      reconstructed.add(f.createDataFile(table.specs()));
+    }
+    return reconstructed;
+  }
+
+  /** Reconstructs the live {@link DeleteFile}s carried by {@code files}, 
against {@code table}. */
+  private static List<DeleteFile> deleteFilesOf(Table table, ShardDeltaFiles 
files) {
+    List<DeleteFile> reconstructed = new ArrayList<>();
+    for (SerializableDeleteFile f : files.getDeleteFiles()) {
+      reconstructed.add(f.createDeleteFile(table.specs(), table.sortOrders()));
+    }
+    return reconstructed;
+  }
+
+  /** All regular files under {@code root}, skipping hidden files (Hadoop's 
.crc checksums). */
+  private static List<File> filesUnder(File root) {
+    List<File> found = new ArrayList<>();
+    File[] children = root.listFiles();
+    if (children == null) {
+      return found;
+    }
+    for (File child : children) {
+      if (child.isDirectory()) {
+        found.addAll(filesUnder(child));
+      } else if (!child.getName().startsWith(".")) {
+        found.add(child);
+      }
+    }
+    return found;
+  }
+
+  private File tableDir(TableIdentifier id) {
+    return new File(warehouseDir, "db/" + id.name());
+  }
+
+  /** All data/delete files under the table location, excluding the {@code 
metadata} directory. */
+  private List<File> dataFilesUnder(TableIdentifier id) {
+    List<File> found = new ArrayList<>();
+    File[] children = tableDir(id).listFiles();
+    if (children == null) {
+      return found;
+    }
+    for (File child : children) {
+      if (child.isDirectory() && !child.getName().equals("metadata")) {
+        found.addAll(filesUnder(child));
+      }
+    }
+    return found;
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // 1. + 8. One INSERT group: the data file's metadata is emitted; min/max 
sequence recorded.
+  // 
---------------------------------------------------------------------------------------------
+
+  /** Creates the canonical unpartitioned V2 {@link #ICEBERG_SCHEMA} table (PK 
{@code id}). */
+  private Table v2Table(TableIdentifier id) {
+    return CdcSinkTestUtils.createTable(
+        catalog, id, ICEBERG_SCHEMA, ImmutableSet.of(1), 2, 
PartitionSpec.unpartitioned());
+  }
+
+  @Test
+  public void writesAndEmitsDeltaForSortedInsertGroup() {
+    TableIdentifier id = TableIdentifier.of("db", "t1_" + System.nanoTime());
+    Table t = v2Table(id);
+    org.apache.beam.sdk.schemas.Schema dataSchema =
+        IcebergUtils.icebergSchemaToBeamSchema(t.schema());
+
+    Iterable<KV<byte[], CdcRecord>> sorted =
+        ImmutableList.of(
+            kv(row(dataSchema, 1, "a", "x"), 3L, ValueKind.INSERT),
+            kv(row(dataSchema, 2, "b", "y"), 5L, ValueKind.INSERT),
+            kv(row(dataSchema, 3, "c", "z"), 9L, ValueKind.INSERT));
+
+    List<ShardDeltaFiles> out =
+        runAndCollect(
+            id, dataSchema, 
ImmutableList.of(KV.of(DestinationShard.of(id.toString(), 0), sorted)));
+
+    assertThat(out, hasSize(1));
+    ShardDeltaFiles files = out.get(0);
+    assertThat(files.getTableIdentifierString(), equalTo(id.toString()));
+    assertThat(files.getMinSequenceNumber(), equalTo(3L));
+    assertThat(files.getMaxSequenceNumber(), equalTo(9L));
+    assertThat(files.getDataFiles(), hasSize(1));
+    // INSERT-only, non-upsert: no delete files.
+    assertThat(files.getDeleteFiles(), empty());
+
+    List<DataFile> reconstructed = dataFilesOf(t, files);
+    assertThat(reconstructed, hasSize(1));
+    DataFile dataFile = reconstructed.get(0);
+    assertThat(dataFile.recordCount(), equalTo(3L));
+    assertThat(dataFile.location(), startsWith(t.location()));
+    assertThat(new File(dataFile.location().replaceFirst("^file:", 
"")).exists(), is(true));
+    // No sequence number carried: the eventual commit assigns one by snapshot 
inheritance.
+    assertThat(dataFile.dataSequenceNumber(), nullValue());
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // 2. An update pair: the equality-delete file's metadata survives 
serialization.
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  public void updatePairCarriesEqualityDeleteThroughSerialization() {
+    TableIdentifier id = TableIdentifier.of("db", "t2_" + System.nanoTime());
+    Table t = v2Table(id);
+    org.apache.beam.sdk.schemas.Schema dataSchema =
+        IcebergUtils.icebergSchemaToBeamSchema(t.schema());
+
+    Iterable<KV<byte[], CdcRecord>> sorted =
+        ImmutableList.of(
+            kv(row(dataSchema, 1, "a", "x"), 5L, ValueKind.UPDATE_BEFORE),
+            kv(row(dataSchema, 1, "a2", "x2"), 5L, ValueKind.UPDATE_AFTER),
+            kv(row(dataSchema, 2, "b", "y"), 7L, ValueKind.INSERT));
+
+    List<ShardDeltaFiles> out =
+        runAndCollect(
+            id, dataSchema, 
ImmutableList.of(KV.of(DestinationShard.of(id.toString(), 0), sorted)));
+
+    assertThat(out, hasSize(1));
+    ShardDeltaFiles files = out.get(0);
+    assertThat(files.getMinSequenceNumber(), equalTo(5L));
+    assertThat(files.getMaxSequenceNumber(), equalTo(7L));
+
+    List<DataFile> dataFiles = dataFilesOf(t, files);
+    assertThat(dataFiles, hasSize(1));
+    assertThat(dataFiles.get(0).recordCount(), equalTo(2L));
+
+    // The update pair collapsed to one PK-only equality delete row plus the 
after-image.
+    List<DeleteFile> deleteFiles = deleteFilesOf(t, files);
+    assertThat(deleteFiles, hasSize(1));
+    DeleteFile delete = deleteFiles.get(0);
+    assertThat(delete.content(), equalTo(FileContent.EQUALITY_DELETES));
+    assertThat(delete.equalityFieldIds(), equalTo(ImmutableList.of(1)));
+    assertThat(delete.recordCount(), equalTo(1L));
+    // No sequence number carried: the eventual commit assigns one by snapshot 
inheritance.
+    assertThat(delete.dataSequenceNumber(), nullValue());
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // 3. day(timestamptz) partition, end-to-end (the predecessor crashed at 
commit until DATE got a
+  //    renderer special-case; typed JSON removes the rendered path entirely).
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  public void dayPartitionedTimestamptzTableCommitsAndReadsBack() throws 
IOException {
+    Schema icebergSchema =
+        new Schema(
+            Types.NestedField.required(1, "id", Types.IntegerType.get()),
+            Types.NestedField.required(2, "event_ts", 
Types.TimestampType.withZone()),
+            Types.NestedField.optional(3, "name", Types.StringType.get()));
+    PartitionSpec spec = 
PartitionSpec.builderFor(icebergSchema).day("event_ts").build();
+    TableIdentifier id = TableIdentifier.of("db", "t3_" + System.nanoTime());
+    // event_ts is also an equality column: the key-derived-partition shape.
+    Table t =
+        CdcSinkTestUtils.createTable(catalog, id, icebergSchema, 
ImmutableSet.of(1, 2), 2, spec);
+    org.apache.beam.sdk.schemas.Schema dataSchema =
+        IcebergUtils.icebergSchemaToBeamSchema(t.schema());
+
+    Instant ts1 = Instant.parse("2024-03-15T10:30:00.123456Z");
+    Instant ts2 = Instant.parse("2024-06-01T00:00:00Z");
+    Iterable<KV<byte[], CdcRecord>> sorted =
+        ImmutableList.of(
+            kv(row(dataSchema, 1, ts1, "a"), 1L, ValueKind.INSERT),
+            kv(row(dataSchema, 2, ts2, "b"), 2L, ValueKind.INSERT));
+
+    List<ShardDeltaFiles> out =
+        runAndCollect(
+            id, dataSchema, 
ImmutableList.of(KV.of(DestinationShard.of(id.toString(), 0), sorted)));
+
+    assertThat(out, hasSize(1));
+    List<DataFile> files = dataFilesOf(t, out.get(0));
+    assertThat(files, hasSize(2));
+    Set<Object> partitionValues = new HashSet<>();
+    for (DataFile f : files) {
+      partitionValues.add(f.partition().get(0, Object.class));
+    }
+    assertThat(
+        partitionValues,
+        containsInAnyOrder(
+            (int) LocalDate.of(2024, 3, 15).toEpochDay(),
+            (int) LocalDate.of(2024, 6, 1).toEpochDay()));
+
+    // Hand-commit the reconstructed files and read the table back as ground 
truth.
+    commitDataFiles(t, files);
+
+    Map<Integer, Record> byId = readById(t);
+    assertThat(byId.keySet(), hasSize(2));
+    Record row1 = checkStateNotNull(byId.get(1));
+    Record row2 = checkStateNotNull(byId.get(2));
+    assertThat(row1.getField("event_ts"), 
equalTo(OffsetDateTime.ofInstant(ts1, ZoneOffset.UTC)));
+    assertThat(row1.getField("name"), equalTo("a"));
+    assertThat(row2.getField("event_ts"), 
equalTo(OffsetDateTime.ofInstant(ts2, ZoneOffset.UTC)));
+    assertThat(row2.getField("name"), equalTo("b"));
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // 4. identity(DATE) partition, end-to-end: DATE is the result type the old 
design had to
+  //    special-case, so it is the sharpest check that partition values travel 
natively.
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  public void identityDatePartitionedTableCommitsAndReadsBack() throws 
IOException {
+    Schema icebergSchema =
+        new Schema(
+            Types.NestedField.required(1, "id", Types.IntegerType.get()),
+            Types.NestedField.required(2, "event_date", Types.DateType.get()),
+            Types.NestedField.optional(3, "name", Types.StringType.get()));
+    PartitionSpec spec = 
PartitionSpec.builderFor(icebergSchema).identity("event_date").build();
+    TableIdentifier id = TableIdentifier.of("db", "t4_" + System.nanoTime());
+    Table t =
+        CdcSinkTestUtils.createTable(catalog, id, icebergSchema, 
ImmutableSet.of(1, 2), 2, spec);
+    org.apache.beam.sdk.schemas.Schema dataSchema =
+        IcebergUtils.icebergSchemaToBeamSchema(t.schema());
+
+    LocalDate d1 = LocalDate.of(2024, 3, 15);
+    LocalDate d2 = LocalDate.of(2024, 6, 1);
+    Iterable<KV<byte[], CdcRecord>> sorted =
+        ImmutableList.of(
+            kv(row(dataSchema, 1, d1, "a"), 1L, ValueKind.INSERT),
+            kv(row(dataSchema, 2, d2, "b"), 2L, ValueKind.INSERT));
+
+    List<ShardDeltaFiles> out =
+        runAndCollect(
+            id, dataSchema, 
ImmutableList.of(KV.of(DestinationShard.of(id.toString(), 0), sorted)));
+
+    assertThat(out, hasSize(1));
+    List<DataFile> files = dataFilesOf(t, out.get(0));
+    assertThat(files, hasSize(2));
+    Set<Object> partitionValues = new HashSet<>();
+    for (DataFile f : files) {
+      partitionValues.add(f.partition().get(0, Object.class));
+    }
+    assertThat(partitionValues, containsInAnyOrder((int) d1.toEpochDay(), 
(int) d2.toEpochDay()));
+
+    commitDataFiles(t, files);
+
+    Map<Integer, Record> byId = readById(t);
+    assertThat(byId.keySet(), hasSize(2));
+    assertThat(checkStateNotNull(byId.get(1)).getField("event_date"), 
equalTo(d1));
+    assertThat(checkStateNotNull(byId.get(2)).getField("event_date"), 
equalTo(d2));
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // 5. A '/' inside a STRING identity partition value, end-to-end (previously 
rejected loudly).
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  public void slashInStringPartitionValueRoundTripsEndToEnd() throws 
IOException {
+    Schema icebergSchema =
+        new Schema(
+            Types.NestedField.required(1, "id", Types.IntegerType.get()),
+            Types.NestedField.required(2, "name", Types.StringType.get()),
+            Types.NestedField.optional(3, "data", Types.StringType.get()));
+    PartitionSpec spec = 
PartitionSpec.builderFor(icebergSchema).identity("name").build();
+    TableIdentifier id = TableIdentifier.of("db", "t5_" + System.nanoTime());
+    Table t =
+        CdcSinkTestUtils.createTable(catalog, id, icebergSchema, 
ImmutableSet.of(1, 2), 2, spec);
+    org.apache.beam.sdk.schemas.Schema dataSchema =
+        IcebergUtils.icebergSchemaToBeamSchema(t.schema());
+
+    Iterable<KV<byte[], CdcRecord>> sorted =
+        ImmutableList.of(kv(row(dataSchema, 1, "a/b", "x"), 1L, 
ValueKind.INSERT));
+
+    List<ShardDeltaFiles> out =
+        runAndCollect(
+            id, dataSchema, 
ImmutableList.of(KV.of(DestinationShard.of(id.toString(), 0), sorted)));
+
+    assertThat(out, hasSize(1));
+    List<DataFile> files = dataFilesOf(t, out.get(0));
+    assertThat(files, hasSize(1));
+    // The raw partition value survives, slash and all. A partition-PATH 
round-trip would split
+    // "name=a/b" into two fields here; the typed JSON partition has no such 
hazard.
+    assertThat(files.get(0).partition().get(0, String.class), equalTo("a/b"));
+
+    commitDataFiles(t, files);
+
+    Map<Integer, Record> byId = readById(t);
+    assertThat(byId.keySet(), hasSize(1));
+    Record slashRow = checkStateNotNull(byId.get(1));
+    assertThat(slashRow.getField("name"), equalTo("a/b"));
+    assertThat(slashRow.getField("data"), equalTo("x"));
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // 6. Spec evolution: each file is serialized under the FILES' spec, not the 
current table spec.
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  public void serializesFilesUnderTheirOwnSpecAfterSpecEvolution() throws 
IOException {
+    TableIdentifier id = TableIdentifier.of("db", "t6_" + System.nanoTime());
+    PartitionSpec spec0 = 
PartitionSpec.builderFor(ICEBERG_SCHEMA).bucket("id", 4).build();
+    Table t =
+        CdcSinkTestUtils.createTable(catalog, id, ICEBERG_SCHEMA, 
ImmutableSet.of(1), 2, spec0);
+    assertThat(t.spec().specId(), equalTo(0));
+
+    // Write a real data file under spec 0 through the production writer path.
+    RecordDeltaTaskWriter writer =
+        CdcSinkTestUtils.deltaWriter(t, ImmutableSet.of(1), false, 
BIG_TARGET_FILE_SIZE);
+    GenericRecord rec = GenericRecord.create(t.schema());
+    rec.setField("id", 1);
+    rec.setField("name", "a");
+    rec.setField("data", "x");
+    writer.write(CdcSortKey.encode(pkBytes(1), 1L, ValueKind.INSERT), rec, 
ValueKind.INSERT);
+    WriteResult result = writer.complete();
+    assertThat(result.dataFiles().length, equalTo(1));
+    assertThat(result.dataFiles()[0].specId(), equalTo(0));
+    Object originalPartition = result.dataFiles()[0].partition().get(0, 
Object.class);
+
+    // Mid-run partition-spec evolution: table.spec() is now spec 1, the file 
is still spec 0.
+    t.updateSpec().addField(Expressions.bucket("name", 2)).commit();
+    t.refresh();
+    assertThat(t.spec().specId(), equalTo(1));
+
+    ShardDeltaFiles files = WriteDeltas.serialize(id.toString(), t, result, 
1L, 1L);
+
+    // Serialized against its OWN spec, never the evolved t.spec(), or the 
single-field partition
+    // tuple would be read as the evolved two-field one.
+    List<DataFile> reconstructed = dataFilesOf(t, files);
+    assertThat(reconstructed, hasSize(1));
+    assertThat(reconstructed.get(0).specId(), equalTo(0));
+    assertThat(reconstructed.get(0).partition().size(), equalTo(1));
+    assertThat(reconstructed.get(0).partition().get(0, Object.class), 
equalTo(originalPartition));
+    assertThat(reconstructed.get(0).location(), 
equalTo(result.dataFiles()[0].location()));
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // 7. Empty group emits nothing (and writes nothing).
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  public void emptyGroupEmitsNothing() {
+    TableIdentifier id = TableIdentifier.of("db", "t7_" + System.nanoTime());
+    v2Table(id);
+    org.apache.beam.sdk.schemas.Schema dataSchema =
+        IcebergUtils.icebergSchemaToBeamSchema(ICEBERG_SCHEMA);
+
+    Iterable<KV<byte[], CdcRecord>> emptyGroup = ImmutableList.of();
+    PCollection<ShardDeltaFiles> out =
+        p.apply(
+                Create.of(KV.of(DestinationShard.of(id.toString(), 0), 
emptyGroup))
+                    .withCoder(groupCoder(dataSchema)))
+            .apply(transform(id, dataSchema));
+
+    PAssert.that(out).empty();
+    p.run().waitUntilFinish();
+
+    assertThat(dataFilesUnder(id), empty());
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // 8./9. Abort path. The positive control proves the on-disk scan looks in 
the right place
+  // (valid records leave data files); the abort test then proves a mid-group 
record failure
+  // leaves none.
+  // 
---------------------------------------------------------------------------------------------
+
+  /**
+   * A table whose {@code data} column is {@code decimal(4,2)}, with a tiny 
target file size so a
+   * completed file hits disk at the 1000-row roll check; an over-precision 
poison value then passes
+   * the sink's schema validation and fails only at Parquet serialization.
+   */
+  private Table decimalDataTable(TableIdentifier id) {
+    Schema decimalDataSchema =
+        new Schema(
+            Types.NestedField.required(1, "id", Types.IntegerType.get()),
+            Types.NestedField.optional(2, "name", Types.StringType.get()),
+            Types.NestedField.required(3, "data", Types.DecimalType.of(4, 2)));
+    Table t =
+        CdcSinkTestUtils.createTable(
+            catalog, id, decimalDataSchema, ImmutableSet.of(1), 2, 
PartitionSpec.unpartitioned());
+    t.updateProperties().set(TableProperties.WRITE_TARGET_FILE_SIZE_BYTES, 
"1").commit();
+    return t;
+  }
+
+  private static org.apache.beam.sdk.schemas.Schema decimalDataBeamSchema() {
+    return org.apache.beam.sdk.schemas.Schema.builder()
+        .addInt32Field("id")
+        .addNullableStringField("name")
+        .addDecimalField("data")
+        .build();
+  }
+
+  private static List<KV<byte[], CdcRecord>> thousandValidRecords(
+      org.apache.beam.sdk.schemas.Schema schema) {
+    List<KV<byte[], CdcRecord>> records = new ArrayList<>();
+    for (int i = 1; i <= 1000; i++) {
+      records.add(kv(row(schema, i, "n" + i, new BigDecimal("1.23")), i, 
ValueKind.INSERT));
+    }
+    return sortedBySortKey(records);
+  }
+
+  /** Byte-orders {@code records} by sort key: the order the shuffle sorter 
actually delivers. */
+  private static List<KV<byte[], CdcRecord>> sortedBySortKey(List<KV<byte[], 
CdcRecord>> records) {
+    records.sort(
+        (a, b) -> 
UnsignedBytes.lexicographicalComparator().compare(a.getKey(), b.getKey()));
+    return records;
+  }
+
+  @Test
+  public void successfulGroupLeavesDataFilesOnDisk() {
+    TableIdentifier id = TableIdentifier.of("db", "t8ctrl_" + 
System.nanoTime());
+    decimalDataTable(id);
+    org.apache.beam.sdk.schemas.Schema dataSchema = decimalDataBeamSchema();
+
+    Iterable<KV<byte[], CdcRecord>> sorted = thousandValidRecords(dataSchema);
+    List<ShardDeltaFiles> out =
+        runAndCollect(
+            id, dataSchema, 
ImmutableList.of(KV.of(DestinationShard.of(id.toString(), 0), sorted)));
+
+    assertThat(out, hasSize(1));
+    assertThat(out.get(0).getDataFiles(), not(empty()));
+    // The scan below is exactly the one the abort test asserts EMPTY, so this 
proves it looks in
+    // the right place.
+    assertThat(dataFilesUnder(id), not(empty()));
+  }
+
+  @Test
+  public void abortLeavesNoDataFilesOnRecordFailure() {
+    TableIdentifier id = TableIdentifier.of("db", "t9_" + System.nanoTime());
+    decimalDataTable(id);
+    org.apache.beam.sdk.schemas.Schema dataSchema = decimalDataBeamSchema();
+
+    // 1000 valid records (a completed data file is rolled and flushed to disk 
before the poison
+    // sorts in), plus a poison record: a value too wide for the decimal(4,2) 
column, failing
+    // when serialized.
+    List<KV<byte[], CdcRecord>> records = thousandValidRecords(dataSchema);
+    records.add(
+        kv(row(dataSchema, 1001, "bad", new BigDecimal("123.45")), 1001L, 
ValueKind.INSERT));
+    sortedBySortKey(records);
+
+    p.apply(
+            Create.of(
+                    KV.of(
+                        DestinationShard.of(id.toString(), 0),
+                        (Iterable<KV<byte[], CdcRecord>>) records))
+                .withCoder(groupCoder(dataSchema)))
+        .apply(transform(id, dataSchema));
+
+    assertThrows(Exception.class, () -> p.run().waitUntilFinish());
+
+    // The abort deleted the flushed file: a failed group leaves nothing, so a 
retry starts clean.
+    assertThat(dataFilesUnder(id), empty());
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // 10. A bundle spanning two partition specs serializes each file under its 
own spec (a staged
+  //     manifest could not represent this at all).
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  public void serializesHeterogeneousSpecBundlePerFile() {
+    TableIdentifier id = TableIdentifier.of("db", "t10_" + System.nanoTime());
+    Table t = v2Table(id);
+
+    DataFile spec0Data =
+        DataFiles.builder(t.spec())
+            .withFormat(FileFormat.PARQUET)
+            .withPath("/tmp/d-0.parquet")
+            .withFileSizeInBytes(100L)
+            .withRecordCount(2L)
+            .build();
+    DeleteFile spec0Delete =
+        FileMetadata.deleteFileBuilder(t.spec())
+            .ofEqualityDeletes(1)
+            .withPath("/tmp/eq-0.parquet")
+            .withFormat(FileFormat.PARQUET)
+            .withFileSizeInBytes(50L)
+            .withRecordCount(1L)
+            .build();
+
+    // Evolve the spec so a second, spec-1 delete file can make the bundle 
heterogeneous.
+    t.updateSpec().addField(Expressions.bucket("id", 4)).commit();
+    t.refresh();
+    // UpdatePartitionSpec generates the new field's name (e.g. 
"id_bucket_4"); read it back rather
+    // than guessing.
+    String bucketFieldName = t.spec().fields().get(0).name();
+    DeleteFile spec1Delete =
+        FileMetadata.deleteFileBuilder(t.spec())
+            .ofEqualityDeletes(1)
+            .withPath("/tmp/eq-1.parquet")
+            .withFormat(FileFormat.PARQUET)
+            .withFileSizeInBytes(50L)
+            .withRecordCount(1L)
+            .withPartitionPath(bucketFieldName + "=1")
+            .build();
+
+    WriteResult mixedSpecs =
+        WriteResult.builder()
+            .addDataFiles(spec0Data)
+            .addDeleteFiles(spec0Delete, spec1Delete)
+            .build();
+    ShardDeltaFiles files = WriteDeltas.serialize(id.toString(), t, 
mixedSpecs, 1L, 2L);
+
+    // Each file keeps its own spec id, and each reconstructs with the right 
partition arity.
+    assertThat(dataFilesOf(t, files).get(0).specId(), equalTo(0));
+    List<DeleteFile> deletes = deleteFilesOf(t, files);
+    assertThat(deletes, hasSize(2));
+    assertThat(deletes.get(0).specId(), equalTo(0));
+    assertThat(deletes.get(0).partition().size(), equalTo(0));
+    assertThat(deletes.get(1).specId(), equalTo(1));
+    assertThat(deletes.get(1).partition().get(0, Integer.class), equalTo(1));
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // 11. Multiple shards of the same window produce independent 
ShardDeltaFiles.
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  public void multipleShardsSameWindowProduceIndependentFileSets() {
+    TableIdentifier id = TableIdentifier.of("db", "t11_" + System.nanoTime());
+    Table t = v2Table(id);
+    org.apache.beam.sdk.schemas.Schema dataSchema =
+        IcebergUtils.icebergSchemaToBeamSchema(t.schema());
+
+    Iterable<KV<byte[], CdcRecord>> shard0 =
+        ImmutableList.of(
+            kv(row(dataSchema, 1, "a", "x"), 1L, ValueKind.INSERT),
+            kv(row(dataSchema, 2, "b", "y"), 2L, ValueKind.INSERT));
+    Iterable<KV<byte[], CdcRecord>> shard1 =
+        ImmutableList.of(
+            kv(row(dataSchema, 3, "c", "z"), 3L, ValueKind.INSERT),
+            kv(row(dataSchema, 4, "d", "w"), 4L, ValueKind.INSERT));
+
+    List<ShardDeltaFiles> out =
+        runAndCollect(
+            id,
+            dataSchema,
+            ImmutableList.of(
+                KV.of(DestinationShard.of(id.toString(), 0), shard0),
+                KV.of(DestinationShard.of(id.toString(), 1), shard1)));
+
+    assertThat(out, hasSize(2));
+    Set<String> dataFilePaths = new HashSet<>();
+    Map<Long, ShardDeltaFiles> byMinSeq = new HashMap<>();
+    for (ShardDeltaFiles files : out) {
+      assertThat(files.getDataFiles(), hasSize(1));
+      List<DataFile> reconstructed = dataFilesOf(t, files);
+      assertThat(reconstructed.get(0).recordCount(), equalTo(2L));
+      dataFilePaths.add(reconstructed.get(0).location());
+      byMinSeq.put(files.getMinSequenceNumber(), files);
+    }
+    // No cross-talk: distinct data files, each covering only its own shard's 
sequence range.
+    assertThat(dataFilePaths, hasSize(2));
+    assertThat(checkStateNotNull(byMinSeq.get(1L)).getMaxSequenceNumber(), 
equalTo(2L));
+    assertThat(checkStateNotNull(byMinSeq.get(3L)).getMaxSequenceNumber(), 
equalTo(4L));
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // 12. Mid-run partition-spec evolution: the write path keeps writing under 
the pinned spec.
+  // 
---------------------------------------------------------------------------------------------
+
+  /**
+   * After a mid-run spec evolution the same {@code WriteDeltasFn} keeps 
building writers from the
+   * pinned spec, never the live {@code table.spec()}.
+   */
+  @Test
+  public void writePathKeepsWritingUnderPinnedSpecAfterEvolution() throws 
IOException {
+    TableIdentifier id = TableIdentifier.of("db", "spec_evolution_" + 
System.nanoTime());
+    v2Table(id);
+    org.apache.beam.sdk.schemas.Schema dataSchema =
+        IcebergUtils.icebergSchemaToBeamSchema(ICEBERG_SCHEMA);
+
+    TableSetup setup =
+        new TableSetup(
+            catalogConfig(), cfg(), DynamicDestinations.singleTable(id, 
dataSchema), "px");
+    WriteDeltas.WriteDeltasFn fn = new WriteDeltas.WriteDeltasFn(setup, cfg(), 
"px", dataSchema);
+
+    // Resolve (and memoize) the destination against the table's original spec.
+    TableSetup.Dest dest = setup.get(id.toString(), dataSchema);
+    int resolvedSpecId = dest.spec().specId();
+
+    // The operator evolves the spec; the shared Table instance is refreshed 
onto it.
+    dest.table().updateSpec().addField(Expressions.bucket("id", 4)).commit();
+    dest.table().refresh();
+    assertThat(dest.table().spec().specId(), not(equalTo(resolvedSpecId)));
+
+    KV<DestinationShard, Iterable<KV<byte[], CdcRecord>>> group =
+        KV.of(
+            DestinationShard.of(id.toString(), 0),
+            ImmutableList.of(kv(row(dataSchema, 1, "a", "x"), 1L, 
ValueKind.INSERT)));
+
+    List<ShardDeltaFiles> out = new ArrayList<>();
+    fn.process(group, GlobalWindow.INSTANCE, 
CdcSinkTestUtils.collectInto(out));
+
+    assertThat(out, hasSize(1));
+    List<DataFile> files = dataFilesOf(dest.table(), out.get(0));
+    assertThat(files, hasSize(1));
+    assertThat(files.get(0).specId(), equalTo(resolvedSpecId));
+    // The pinned spec is the unpartitioned one: the tuple stays empty despite 
the live spec.
+    assertThat(files.get(0).partition().size(), equalTo(0));
+  }
+
+  /** Commits {@code files} to {@code table} as one row delta and refreshes 
the table. */
+  private static void commitDataFiles(Table table, List<DataFile> files) {
+    RowDelta rowDelta = table.newRowDelta();
+    files.forEach(rowDelta::addRows);
+    rowDelta.commit();
+    table.refresh();
+  }
+
+  /** Reads the table's current rows keyed by {@code id}. */
+  private static Map<Integer, Record> readById(Table t) throws IOException {
+    Map<Integer, Record> byId = new HashMap<>();
+    try (CloseableIterable<Record> reader = IcebergGenerics.read(t).build()) {
+      for (Record r : reader) {
+        byId.put((Integer) checkStateNotNull(r.getField("id")), r);
+      }
+    }
+    return byId;
+  }
+}

Reply via email to