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 c4b4bda2a5d [Iceberg CDC] Add Changelog readers and update resolver 
(#38837)
c4b4bda2a5d is described below

commit c4b4bda2a5d9dac2a15f003d76c70d64c02d918b
Author: Ahmed Abualsaud <[email protected]>
AuthorDate: Mon Aug 3 15:59:56 2026 -0700

    [Iceberg CDC] Add Changelog readers and update resolver (#38837)
    
    * add changelog readers
    
    * trigger ITs
    
    * address comments
    
    * use getLength for size estimate; use Locale.English; cdc resolver PK hash 
logic array-aware
    
    * add scaling factor to byte size threshold
    
    * sync
    
    * address comments
    
    * address comments
    
    ---------
    
    Co-authored-by: Ahmed Abualsaud <[email protected]>
---
 .../beam/sdk/io/iceberg/IcebergScanConfig.java     | 107 ++++-
 .../apache/beam/sdk/io/iceberg/IcebergUtils.java   | 244 +++++-----
 .../beam/sdk/io/iceberg/cdc/CdcOutputUtils.java    |   6 +-
 .../beam/sdk/io/iceberg/cdc/CdcReadUtils.java      |   2 +
 .../beam/sdk/io/iceberg/cdc/CdcResolver.java       | 180 ++++++++
 .../beam/sdk/io/iceberg/cdc/CdcRowDescriptor.java  |  89 ++++
 .../beam/sdk/io/iceberg/cdc/ChangelogScanner.java  |  13 +-
 .../beam/sdk/io/iceberg/cdc/LocalResolveDoFn.java  | 245 ++++++++++
 .../beam/sdk/io/iceberg/cdc/OverlapRange.java      | 102 +++++
 .../sdk/io/iceberg/cdc/ReadFromChangelogs.java     | 494 +++++++++++++++++++++
 .../beam/sdk/io/iceberg/IcebergUtilsTest.java      |  10 +-
 .../beam/sdk/io/iceberg/cdc/CdcResolverTest.java   | 156 +++++++
 .../sdk/io/iceberg/cdc/ChangelogScannerTest.java   |  20 +
 .../sdk/io/iceberg/cdc/LocalResolveDoFnTest.java   | 340 ++++++++++++++
 .../beam/sdk/io/iceberg/cdc/OverlapRangeTest.java  | 161 +++++++
 .../sdk/io/iceberg/cdc/ReadFromChangelogsTest.java | 366 +++++++++++++++
 16 files changed, 2409 insertions(+), 126 deletions(-)

diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java
index d184a84edf9..45ec21f0ca5 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java
@@ -19,20 +19,25 @@ package org.apache.beam.sdk.io.iceberg;
 
 import static 
org.apache.beam.sdk.io.iceberg.IcebergUtils.icebergSchemaToBeamSchema;
 import static org.apache.beam.sdk.util.Preconditions.checkArgumentNotNull;
+import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
 import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;
-import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
 import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkState;
 import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Sets.newHashSet;
+import static org.apache.iceberg.types.Type.TypeID.LONG;
+import static org.apache.iceberg.types.Type.TypeID.TIMESTAMP;
 
 import com.google.auto.value.AutoValue;
 import java.io.Serializable;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 import org.apache.beam.sdk.io.iceberg.IcebergIO.ReadRows.StartingStrategy;
 import org.apache.beam.sdk.io.iceberg.cdc.IcebergCdcMetadataColumns;
 import org.apache.beam.sdk.schemas.Schema;
@@ -40,6 +45,7 @@ import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.Vi
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.MoreObjects;
 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.MetadataColumns;
 import org.apache.iceberg.StructLike;
 import org.apache.iceberg.Table;
 import org.apache.iceberg.TableUtil;
@@ -48,6 +54,7 @@ import org.apache.iceberg.expressions.Evaluator;
 import org.apache.iceberg.expressions.Expression;
 import org.apache.iceberg.types.Comparators;
 import org.apache.iceberg.types.TypeUtil;
+import org.apache.iceberg.types.Types.NestedField;
 import org.apache.iceberg.util.SnapshotUtil;
 import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
 import org.checkerframework.checker.nullness.qual.Nullable;
@@ -102,9 +109,9 @@ public abstract class IcebergScanConfig implements 
Serializable {
       @Nullable List<String> keep,
       @Nullable List<String> drop,
       @Nullable Set<String> fieldsInFilter) {
-    ImmutableList.Builder<String> selectedFieldsBuilder = 
ImmutableList.builder();
+    Set<String> selectedFields = new LinkedHashSet<>();
     if (keep != null && !keep.isEmpty()) {
-      selectedFieldsBuilder.addAll(keep);
+      selectedFields.addAll(keep);
     } else if (drop != null && !drop.isEmpty()) {
       List<String> paths = new 
ArrayList<>(TypeUtil.indexNameById(schema.asStruct()).values());
       Collections.sort(paths);
@@ -113,7 +120,7 @@ public abstract class IcebergScanConfig implements 
Serializable {
         boolean isParent = i + 1 < paths.size() && paths.get(i + 
1).startsWith(path + ".");
         boolean isDrop = drop.stream().anyMatch(d -> path.equals(d) || 
path.startsWith(d + "."));
         if (!isParent && !isDrop) {
-          selectedFieldsBuilder.add(path);
+          selectedFields.add(path);
         }
       }
     } else {
@@ -124,9 +131,8 @@ public abstract class IcebergScanConfig implements 
Serializable {
     if (fieldsInFilter != null && !fieldsInFilter.isEmpty()) {
       fieldsInFilter.stream()
           .map(f -> schema.caseInsensitiveFindField(f).name())
-          .forEach(selectedFieldsBuilder::add);
+          .forEach(selectedFields::add);
     }
-    ImmutableList<String> selectedFields = selectedFieldsBuilder.build();
     return selectedFields.isEmpty() ? schema : schema.select(selectedFields);
   }
 
@@ -259,6 +265,12 @@ public abstract class IcebergScanConfig implements 
Serializable {
   @Pure
   public abstract @Nullable List<String> getDropFields();
 
+  @Pure
+  public abstract @Nullable String getWatermarkColumn();
+
+  @Pure
+  public abstract @Nullable String getWatermarkColumnTimeUnit();
+
   @Pure
   public abstract @Nullable Duration getMaxSnapshotDiscoveryDelay();
 
@@ -288,6 +300,7 @@ public abstract class IcebergScanConfig implements 
Serializable {
         .setStartingStrategy(null)
         .setTag(null)
         .setBranch(null)
+        .setWatermarkColumn(null)
         .setMetadataColumns(ImmutableList.of());
   }
 
@@ -354,6 +367,10 @@ public abstract class IcebergScanConfig implements 
Serializable {
 
     public abstract Builder setDropFields(@Nullable List<String> fields);
 
+    public abstract Builder setWatermarkColumn(@Nullable String 
watermarkColumn);
+
+    public abstract Builder setWatermarkColumnTimeUnit(@Nullable String 
timeUnit);
+
     public abstract Builder setMaxSnapshotDiscoveryDelay(@Nullable Duration 
delay);
 
     public abstract Builder setMetadataColumns(List<String> metadataColumns);
@@ -364,6 +381,7 @@ public abstract class IcebergScanConfig implements 
Serializable {
   @VisibleForTesting
   abstract Builder toBuilder();
 
+  @SuppressWarnings("ReturnValueIgnored")
   void validate(Table table) {
     @Nullable List<String> keep = getKeepFields();
     @Nullable List<String> drop = getDropFields();
@@ -375,16 +393,19 @@ public abstract class IcebergScanConfig implements 
Serializable {
       String param;
       if (keep != null) {
         param = "keep";
-        fieldsSpecified = newHashSet(checkNotNull(keep));
+        fieldsSpecified = newHashSet(checkArgumentNotNull(keep));
       } else { // drop != null
         param = "drop";
-        fieldsSpecified = newHashSet(checkNotNull(drop));
+        fieldsSpecified = newHashSet(checkArgumentNotNull(drop));
       }
       fieldsSpecified.removeIf(name -> table.schema().findField(name) != null);
 
       checkArgument(
-          fieldsSpecified.isEmpty(),
-          error(String.format("'%s' specifies unknown field(s): %s", param, 
fieldsSpecified)));
+          fieldsSpecified.isEmpty()
+              || 
fieldsSpecified.stream().allMatch(MetadataColumns::isMetadataColumn),
+          error("'%s' specifies unknown field(s): %s"),
+          param,
+          fieldsSpecified);
     }
 
     // TODO(#34168, ahmedabu98): fill these gaps for the existing batch source
@@ -448,7 +469,6 @@ public abstract class IcebergScanConfig implements 
Serializable {
     checkArgument(
         getToTimestamp() == null || getToSnapshot() == null,
         error("only one of 'to_timestamp' or 'to_snapshot' can be set"));
-
     @Nullable Long fromSnapshotId = ReadUtils.getFromSnapshotInclusive(table, 
this);
     @Nullable Long toSnapshotId = ReadUtils.getToSnapshot(table, this);
     if (fromSnapshotId != null) {
@@ -471,11 +491,76 @@ public abstract class IcebergScanConfig implements 
Serializable {
           toSnapshotId);
     }
 
+    if (fromSnapshotId != null) {
+      checkArgumentNotNull(
+          table.snapshot(fromSnapshotId),
+          error("configured starting snapshot does not exist: '%s'"),
+          fromSnapshotId);
+    }
+    if (toSnapshotId != null) {
+      checkArgumentNotNull(
+          table.snapshot(toSnapshotId),
+          error("configured end snapshot does not exist: '%s'"),
+          toSnapshotId);
+    }
+    if (fromSnapshotId != null && toSnapshotId != null) {
+      checkArgument(
+          SnapshotUtil.isAncestorOf(table, toSnapshotId, fromSnapshotId),
+          error("fromSnapshot '%s' is not an ancestor of toSnapshot '%s'"),
+          fromSnapshotId,
+          toSnapshotId);
+    }
+
     if (getPollInterval() != null) {
       checkArgument(
           Boolean.TRUE.equals(getStreaming()),
           error("'poll_interval_seconds' can only be set when streaming is 
true"));
     }
+
+    @Nullable String watermarkColumn = getWatermarkColumn();
+    if (watermarkColumn != null) {
+      checkArgument(getUseCdc(), error("'watermark_column' is only supported 
in CDC mode"));
+      NestedField field = table.schema().findField(watermarkColumn);
+      checkArgument(
+          field != null, error("'watermark_column' refers to unknown column: 
%s"), watermarkColumn);
+      checkArgument(
+          field.isRequired(),
+          error("'watermark_column' needs to be a non-nullable column: %s"),
+          watermarkColumn);
+      checkArgument(
+          field.type().typeId() == TIMESTAMP || field.type().typeId() == LONG,
+          error("'watermark_column' must be a timestamp-typed column, but '%s' 
has type %s"),
+          watermarkColumn,
+          field.type().typeId());
+      checkArgumentNotNull(
+          getProjectedSchema().findField(watermarkColumn),
+          "'watermark_column' column should not be dropped.");
+    }
+
+    @Nullable String watermarkColumnTimeUnit = getWatermarkColumnTimeUnit();
+    if (watermarkColumnTimeUnit != null) {
+      checkArgument(
+          table
+                  .schema()
+                  .findField(
+                      checkStateNotNull(
+                          watermarkColumn,
+                          "watermark_column_time_unit is configured without a 
specified watermark_column"))
+                  .type()
+                  .typeId()
+              == LONG,
+          error("watermark_column_time_unit is only applicable for LONG 
columns."));
+      try {
+        TimeUnit.valueOf(watermarkColumnTimeUnit.toUpperCase(Locale.ENGLISH));
+      } catch (IllegalArgumentException e) {
+        throw new IllegalArgumentException(
+            error(
+                String.format(
+                    "watermark_column_time_unit '%s' is invalid. Please choose 
one of: %s",
+                    watermarkColumnTimeUnit, 
Arrays.toString(TimeUnit.values()))),
+            e);
+      }
+    }
   }
 
   private void validateMetadataColumns(Table table) {
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergUtils.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergUtils.java
index 35accf45976..fa8d17f3c47 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergUtils.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergUtils.java
@@ -48,6 +48,7 @@ import org.apache.beam.sdk.values.Row;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
 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.StructLike;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.catalog.TableIdentifierParser;
 import org.apache.iceberg.data.GenericRecord;
@@ -119,8 +120,8 @@ public class IcebergUtils {
         return Schema.FieldType.STRING;
       case UUID:
       case BINARY:
-        return Schema.FieldType.BYTES;
       case FIXED:
+        return Schema.FieldType.BYTES;
       case DECIMAL:
         return Schema.FieldType.DECIMAL;
       case STRUCT:
@@ -395,7 +396,8 @@ public class IcebergUtils {
             .ifPresent(v -> rec.setField(name, UUID.nameUUIDFromBytes(v)));
         break;
       case FIXED:
-        throw new UnsupportedOperationException("Fixed-precision fields are 
not yet supported.");
+        Optional.ofNullable(value.getBytes(name)).ifPresent(v -> 
rec.setField(name, v));
+        break;
       case BINARY:
         Optional.ofNullable(value.getBytes(name))
             .ifPresent(v -> rec.setField(name, ByteBuffer.wrap(v)));
@@ -506,120 +508,150 @@ public class IcebergUtils {
     }
   }
 
+  /** Converts a {@link StructLike} to a Beam {@link Row}. */
+  public static Row structToRow(Schema schema, StructLike struct) {
+    checkState(
+        schema.getFieldCount() == struct.size(),
+        "Struct of size %s does not match expected schema size %s",
+        struct.size(),
+        schema.getFieldCount());
+    Row.Builder rowBuilder = Row.withSchema(schema);
+    for (int i = 0; i < schema.getFieldCount(); i++) {
+      Schema.Field field = schema.getField(i);
+      @Nullable Object icebergValue = struct.get(i, Object.class);
+      addIcebergValue(rowBuilder, field, icebergValue);
+    }
+    return rowBuilder.build();
+  }
+
   /** Converts an Iceberg {@link Record} to a Beam {@link Row}. */
   public static Row icebergRecordToBeamRow(Schema schema, Record record) {
     Row.Builder rowBuilder = Row.withSchema(schema);
     for (Schema.Field field : schema.getFields()) {
-      boolean isNullable = field.getType().getNullable();
       @Nullable Object icebergValue = record.getField(field.getName());
-      if (icebergValue == null) {
-        if (isNullable) {
-          rowBuilder.addValue(null);
-          continue;
-        }
-        throw new RuntimeException(
-            String.format("Received null value for required field '%s'.", 
field.getName()));
+      addIcebergValue(rowBuilder, field, icebergValue);
+    }
+    return rowBuilder.build();
+  }
+
+  private static void addIcebergValue(
+      Row.Builder rowBuilder, Schema.Field field, @Nullable Object 
icebergValue) {
+    boolean isNullable = field.getType().getNullable();
+    if (icebergValue == null) {
+      if (isNullable) {
+        rowBuilder.addValue(null);
+        return;
       }
-      switch (field.getType().getTypeName()) {
-        case BYTE:
-        case INT16:
-        case INT32:
-        case INT64:
-        case DECIMAL: // Iceberg and Beam both use BigDecimal
-        case FLOAT: // Iceberg and Beam both use float
-        case DOUBLE: // Iceberg and Beam both use double
-        case STRING: // Iceberg and Beam both use String
-        case BOOLEAN: // Iceberg and Beam both use boolean
-          rowBuilder.addValue(icebergValue);
-          break;
-        case ARRAY:
-          checkState(
-              icebergValue instanceof List,
-              "Expected List type for field '%s' but received %s",
-              field.getName(),
-              icebergValue.getClass());
-          List<@NonNull ?> beamList = (List<@NonNull ?>) icebergValue;
-          Schema.FieldType collectionType =
-              checkStateNotNull(field.getType().getCollectionElementType());
-          // recurse on struct types
-          if (collectionType.getTypeName().isCompositeType()) {
-            Schema innerSchema = 
checkStateNotNull(collectionType.getRowSchema());
-            beamList =
-                beamList.stream()
-                    .map(v -> icebergRecordToBeamRow(innerSchema, (Record) v))
-                    .collect(Collectors.toList());
-          }
-          rowBuilder.addValue(beamList);
-          break;
-        case ITERABLE:
-          checkState(
-              icebergValue instanceof Iterable,
-              "Expected Iterable type for field '%s' but received %s",
-              field.getName(),
-              icebergValue.getClass());
-          Iterable<@NonNull ?> beamIterable = (Iterable<@NonNull ?>) 
icebergValue;
-          Schema.FieldType iterableCollectionType =
-              checkStateNotNull(field.getType().getCollectionElementType());
-          // recurse on struct types
-          if (iterableCollectionType.getTypeName().isCompositeType()) {
-            Schema innerSchema = 
checkStateNotNull(iterableCollectionType.getRowSchema());
-            ImmutableList.Builder<Row> builder = ImmutableList.builder();
-            for (Record v : (Iterable<@NonNull Record>) icebergValue) {
-              builder.add(icebergRecordToBeamRow(innerSchema, v));
-            }
-            beamIterable = builder.build();
+      throw new RuntimeException(
+          String.format("Received null value for required field '%s'.", 
field.getName()));
+    }
+    switch (field.getType().getTypeName()) {
+      case BYTE:
+      case INT16:
+      case INT32:
+      case INT64:
+      case DECIMAL: // Iceberg and Beam both use BigDecimal
+      case FLOAT: // Iceberg and Beam both use float
+      case DOUBLE: // Iceberg and Beam both use double
+      case STRING: // Iceberg and Beam both use String
+      case BOOLEAN: // Iceberg and Beam both use boolean
+        rowBuilder.addValue(icebergValue);
+        break;
+      case ARRAY:
+        checkState(
+            icebergValue instanceof List,
+            "Expected List type for field '%s' but received %s",
+            field.getName(),
+            icebergValue.getClass());
+        List<@NonNull ?> beamList = (List<@NonNull ?>) icebergValue;
+        Schema.FieldType collectionType =
+            checkStateNotNull(field.getType().getCollectionElementType());
+        // recurse on struct types
+        if (collectionType.getTypeName().isCompositeType()) {
+          Schema innerSchema = 
checkStateNotNull(collectionType.getRowSchema());
+          beamList =
+              beamList.stream()
+                  .map(v -> icebergRecordToBeamRow(innerSchema, (Record) v))
+                  .collect(Collectors.toList());
+        }
+        rowBuilder.addValue(beamList);
+        break;
+      case ITERABLE:
+        checkState(
+            icebergValue instanceof Iterable,
+            "Expected Iterable type for field '%s' but received %s",
+            field.getName(),
+            icebergValue.getClass());
+        Iterable<@NonNull ?> beamIterable = (Iterable<@NonNull ?>) 
icebergValue;
+        Schema.FieldType iterableCollectionType =
+            checkStateNotNull(field.getType().getCollectionElementType());
+        // recurse on struct types
+        if (iterableCollectionType.getTypeName().isCompositeType()) {
+          Schema innerSchema = 
checkStateNotNull(iterableCollectionType.getRowSchema());
+          ImmutableList.Builder<Row> builder = ImmutableList.builder();
+          for (Record v : (Iterable<@NonNull Record>) icebergValue) {
+            builder.add(icebergRecordToBeamRow(innerSchema, v));
           }
-          rowBuilder.addValue(beamIterable);
-          break;
-        case MAP:
-          checkState(
-              icebergValue instanceof Map,
-              "Expected Map type for field '%s' but received %s",
-              field.getName(),
-              icebergValue.getClass());
-          Map<?, ?> beamMap = (Map<?, ?>) icebergValue;
-          Schema.FieldType valueType = 
checkStateNotNull(field.getType().getMapValueType());
-          // recurse on struct types
-          if (valueType.getTypeName().isCompositeType()) {
-            Schema innerSchema = checkStateNotNull(valueType.getRowSchema());
-            ImmutableMap.Builder<Object, Row> newMap = ImmutableMap.builder();
-            for (Map.Entry<?, ?> entry : ((Map<?, ?>) 
icebergValue).entrySet()) {
-              Record rec = ((Record) entry.getValue());
-              newMap.put(
-                  checkStateNotNull(entry.getKey()),
-                  icebergRecordToBeamRow(innerSchema, checkStateNotNull(rec)));
-            }
-            beamMap = newMap.build();
+          beamIterable = builder.build();
+        }
+        rowBuilder.addValue(beamIterable);
+        break;
+      case MAP:
+        checkState(
+            icebergValue instanceof Map,
+            "Expected Map type for field '%s' but received %s",
+            field.getName(),
+            icebergValue.getClass());
+        Map<?, ?> beamMap = (Map<?, ?>) icebergValue;
+        Schema.FieldType valueType = 
checkStateNotNull(field.getType().getMapValueType());
+        // recurse on struct types
+        if (valueType.getTypeName().isCompositeType()) {
+          Schema innerSchema = checkStateNotNull(valueType.getRowSchema());
+          ImmutableMap.Builder<Object, Row> newMap = ImmutableMap.builder();
+          for (Map.Entry<?, ?> entry : ((Map<?, ?>) icebergValue).entrySet()) {
+            Record rec = ((Record) entry.getValue());
+            newMap.put(
+                checkStateNotNull(entry.getKey()),
+                icebergRecordToBeamRow(innerSchema, checkStateNotNull(rec)));
           }
-          rowBuilder.addValue(beamMap);
-          break;
-        case DATETIME:
-          // Iceberg uses a long for micros.
-          // Beam DATETIME uses joda's DateTime, which only supports millis,
-          // so we do lose some precision here
-          rowBuilder.addValue(getBeamDateTimeValue(icebergValue));
-          break;
-        case BYTES:
-          // Iceberg uses ByteBuffer; Beam uses byte[]
-          rowBuilder.addValue(((ByteBuffer) icebergValue).array());
-          break;
-        case ROW:
-          Record nestedRecord = (Record) icebergValue;
-          Schema nestedSchema =
-              checkArgumentNotNull(
-                  field.getType().getRowSchema(),
-                  "Corrupted schema: Row type did not have associated nested 
schema.");
-          rowBuilder.addValue(icebergRecordToBeamRow(nestedSchema, 
nestedRecord));
-          break;
-        case LOGICAL_TYPE:
-          rowBuilder.addValue(getLogicalTypeValue(icebergValue, 
field.getType()));
-          break;
-        default:
+          beamMap = newMap.build();
+        }
+        rowBuilder.addValue(beamMap);
+        break;
+      case DATETIME:
+        // Iceberg uses a long for micros.
+        // Beam DATETIME uses joda's DateTime, which only supports millis,
+        // so we do lose some precision here
+        rowBuilder.addValue(getBeamDateTimeValue(icebergValue));
+        break;
+      case BYTES:
+        // Beam uses byte[]. Iceberg represents `binary` as a ByteBuffer but 
`fixed` as a byte[].
+        rowBuilder.addValue(
+            icebergValue instanceof byte[]
+                ? (byte[]) icebergValue
+                : ((ByteBuffer) icebergValue).array());
+        break;
+      case ROW:
+        Schema nestedSchema =
+            checkArgumentNotNull(
+                field.getType().getRowSchema(),
+                "Corrupted schema: Row type did not have associated nested 
schema.");
+        if (icebergValue instanceof Record) {
+          rowBuilder.addValue(icebergRecordToBeamRow(nestedSchema, (Record) 
icebergValue));
+        } else if (icebergValue instanceof StructLike) {
+          rowBuilder.addValue(structToRow(nestedSchema, (StructLike) 
icebergValue));
+        } else {
           throw new UnsupportedOperationException(
-              "Unsupported Beam type: " + field.getType().getTypeName());
-      }
+              "Unsupported row type: " + icebergValue.getClass());
+        }
+        break;
+      case LOGICAL_TYPE:
+        rowBuilder.addValue(getLogicalTypeValue(icebergValue, 
field.getType()));
+        break;
+      default:
+        throw new UnsupportedOperationException(
+            "Unsupported Beam type: " + field.getType().getTypeName());
     }
-    return rowBuilder.build();
   }
 
   private static DateTime getBeamDateTimeValue(Object icebergValue) {
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcOutputUtils.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcOutputUtils.java
index 147e2adda1a..8a3a543854d 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcOutputUtils.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcOutputUtils.java
@@ -106,8 +106,7 @@ final class CdcOutputUtils {
   static Row outputRow(
       List<String> metadataColumns,
       Schema outputSchema,
-      long commitSnapshotId,
-      long snapshotSequentNumber,
+      ChangelogDescriptor descriptor,
       ValueKind valueKind,
       Row dataAndRowMetadata) {
     if (metadataColumns.isEmpty()
@@ -115,6 +114,9 @@ final class CdcOutputUtils {
       return dataAndRowMetadata;
     }
 
+    long commitSnapshotId = descriptor.getCommitSnapshotId();
+    long snapshotSequentNumber = descriptor.getSnapshotSequenceNumber();
+
     List<@Nullable Object> values = new 
ArrayList<>(outputSchema.getFieldCount());
     for (Schema.Field field : dataAndRowMetadata.getSchema().getFields()) {
       if (!metadataColumns.contains(field.getName())) {
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcReadUtils.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcReadUtils.java
index daa0a2c73fb..34f26eb9cdf 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcReadUtils.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcReadUtils.java
@@ -70,6 +70,8 @@ import org.slf4j.LoggerFactory;
  */
 public final class CdcReadUtils {
   private static final Logger LOG = 
LoggerFactory.getLogger(CdcReadUtils.class);
+  // Heuristic for estimating the decoded byte size of a compressed file
+  static final int COMPRESSED_TO_DECODED_BYTES_ESTIMATE = 4;
 
   /**
    * Maximum size of an equality delete set to push down as a Parquet residual 
{@code IN}
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcResolver.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcResolver.java
new file mode 100644
index 00000000000..be219168896
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcResolver.java
@@ -0,0 +1,180 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiConsumer;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.ValueKind;
+import org.apache.iceberg.data.Record;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Helper class to reconcile CDC rows. Used by {@link ResolveChanges} (with 
Beam {@link Row}s) and
+ * {@link LocalResolveDoFn} (with Iceberg {@link Record}s).
+ *
+ * <p>For rows that share a given Primary Key, we determine the output 
ValueKind as follows:
+ *
+ * <ul>
+ *   <li>(delete, insert) pairs become {@code UPDATE_BEFORE} + {@code 
UPDATE_AFTER}
+ *   <li>singletons remain {@code DELETE} or {@code INSERT}
+ *   <li>matching delete+insert with identical non-PK fields are considered 
Copy-on-Write side
+ *       effects and are dropped
+ * </ul>
+ *
+ * <p>General implementation:
+ *
+ * <ol>
+ *   <li>Hash-index inserts by their non-PK field hash, for efficient 
Copy-on-Write detection.
+ *   <li>Skip matching (delete, insert) pairs with identical non-PK columns. A 
CoW operation deletes
+ *       and rewrites the whole file (minus some records that are actually 
marked for deletion).
+ *       Unchanged records are no-ops and should not be mistaken for updates.
+ *   <li>Walk the remaining deletes and inserts, emitting matched pairs as 
{@link
+ *       ValueKind#UPDATE_BEFORE} / {@link ValueKind#UPDATE_AFTER}.
+ *   <li>Emit any unmatched extras as {@link ValueKind#DELETE} / {@link 
ValueKind#INSERT}.
+ * </ol>
+ *
+ * <h3>Duplicate identifier values</h3>
+ *
+ * <p>Iceberg does not enforce PK uniqueness, so a single PK group may contain 
more than one delete
+ * and/or insert (although it would be unusual). In the normal case, 
identifier values are unique
+ * and a snapshot contributes at most one delete and one insert per PK.
+ *
+ * <p>If duplicates are encountered, we do not fail. Instead, we pair off the 
deletes and inserts,
+ * and any leftovers are emitted as plain {@code DELETE} / {@code INSERT}. The 
pairing is
+ * necessarily arbitrary as Iceberg only keeps track of commit-level 
sequencing. We do not have
+ * further insight within a commit to determine record ordering. To produce 
deterministic outputs in
+ * the duplicate case, both sides are ordered by {@link #nonPkHash} before 
pairing.
+ */
+abstract class CdcResolver<T> {
+  /** Hashes the non-PK fields of an element. Used as the index for O(n+m) CoW 
deduplication. */
+  protected abstract int nonPkHash(T element);
+
+  /**
+   * Returns true if two records (already known to share a PK) share identical 
non-PK fields. Called
+   * only when the two elements collide in the {@link #nonPkHash} index, so 
the implementation can
+   * stay simple (linear scan of non-PK fields).
+   */
+  protected abstract boolean nonPkEquals(T delete, T insert);
+
+  /**
+   * Resolves a Primary Key group of deletes and inserts. Caller provides 
{@code emit} which decides
+   * how to materialize each output.
+   *
+   * <p>In the rare case of duplicate PKs within a snapshot, one side may hold 
more than one record.
+   * When this happens, we re-order the lists by {@link #nonPkHash} so the 
result is deterministic.
+   */
+  final void resolve(List<T> deletes, List<T> inserts, BiConsumer<ValueKind, 
T> emit) {
+    // Fast path: with unique identifier values each side holds at most one 
record, so there is
+    // only one possible pairing and nothing to order.
+    if (deletes.size() > 1 || inserts.size() > 1) {
+      resolveOrdered(sortedByNonPkHash(deletes), sortedByNonPkHash(inserts), 
emit);
+    } else {
+      resolveOrdered(deletes, inserts, emit);
+    }
+  }
+
+  private List<T> sortedByNonPkHash(List<T> records) {
+    List<T> sorted = new ArrayList<>(records);
+    sorted.sort(Comparator.comparingInt(this::nonPkHash));
+    return sorted;
+  }
+
+  private void resolveOrdered(List<T> deletes, List<T> inserts, 
BiConsumer<ValueKind, T> emit) {
+    boolean hasDeletes = !deletes.isEmpty();
+    boolean hasInserts = !inserts.isEmpty();
+
+    if (hasInserts && hasDeletes) {
+      // First, check if any (delete, insert) pairs are duplicates that should 
not be
+      // included in the output
+      boolean[] dupDeletes = new boolean[deletes.size()];
+      boolean[] dupInserts = new boolean[inserts.size()];
+
+      // Map hash to insert-indices
+      Map<Integer, List<Integer>> insertHashToIdx = new HashMap<>();
+      for (int insertIdx = 0; insertIdx < inserts.size(); insertIdx++) {
+        int insertHash = nonPkHash(inserts.get(insertIdx));
+        insertHashToIdx.computeIfAbsent(insertHash, k -> new 
ArrayList<>()).add(insertIdx);
+      }
+      for (int deleteIdx = 0; deleteIdx < deletes.size(); deleteIdx++) {
+        int deleteHash = nonPkHash(deletes.get(deleteIdx));
+        @Nullable List<Integer> candidates = insertHashToIdx.get(deleteHash);
+        if (candidates != null) {
+          // check if candidates are just duplicates (e.g. from CoW)
+          for (int idx = 0; idx < candidates.size(); idx++) {
+            int insertIdx = candidates.get(idx);
+            if (!dupInserts[insertIdx]
+                && nonPkEquals(deletes.get(deleteIdx), 
inserts.get(insertIdx))) {
+              // this (delete, insert) pair is a duplicate --> should be 
skipped
+              dupDeletes[deleteIdx] = true;
+              dupInserts[insertIdx] = true;
+              candidates.remove(idx);
+              break;
+            }
+          }
+        }
+      }
+
+      // Emit matched pairs as UPDATE_BEFORE / UPDATE_AFTER.
+      int d = 0;
+      int i = 0;
+      while (d < deletes.size() && i < inserts.size()) {
+        // skip duplicates
+        while (d < deletes.size() && dupDeletes[d]) {
+          d++;
+        }
+        while (i < inserts.size() && dupInserts[i]) {
+          i++;
+        }
+
+        if (d < deletes.size() && i < inserts.size()) {
+          emit.accept(ValueKind.UPDATE_BEFORE, deletes.get(d));
+          emit.accept(ValueKind.UPDATE_AFTER, inserts.get(i));
+          d++;
+          i++;
+        }
+      }
+
+      // emit unmatched extras as DELETE / INSERT.
+      while (d < deletes.size()) {
+        if (!dupDeletes[d]) {
+          emit.accept(ValueKind.DELETE, deletes.get(d));
+        }
+        d++;
+      }
+      while (i < inserts.size()) {
+        if (!dupInserts[i]) {
+          emit.accept(ValueKind.INSERT, inserts.get(i));
+        }
+        i++;
+      }
+    } else if (hasInserts) {
+      for (T r : inserts) {
+        emit.accept(ValueKind.INSERT, r);
+      }
+    } else if (hasDeletes) {
+      for (T r : deletes) {
+        emit.accept(ValueKind.DELETE, r);
+      }
+    }
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcRowDescriptor.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcRowDescriptor.java
new file mode 100644
index 00000000000..3bf3e8a619f
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcRowDescriptor.java
@@ -0,0 +1,89 @@
+/*
+ * 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;
+
+import com.google.auto.value.AutoValue;
+import org.apache.beam.sdk.schemas.AutoValueSchema;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.schemas.SchemaCoder;
+import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
+import org.apache.beam.sdk.schemas.annotations.SchemaFieldNumber;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.TypeDescriptor;
+
+/**
+ * Shuffle key for bidirectional CDC rows.
+ *
+ * <p>The primary key isolates rows for update resolution. The snapshot 
sequence number and commit
+ * snapshot id carry commit-sourced metadata through {@link ResolveChanges}, 
where they can be
+ * appended to final output rows if requested.
+ */
+@DefaultSchema(AutoValueSchema.class)
+@AutoValue
+public abstract class CdcRowDescriptor {
+  @SuppressWarnings("nullness")
+  public static SchemaCoder<CdcRowDescriptor> coder(Schema identifierSchema) {
+    Schema descriptorSchema =
+        Schema.builder()
+            .addInt64Field("snapshotSequenceNumber")
+            .addInt64Field("commitSnapshotId")
+            .addRowField("primaryKey", identifierSchema)
+            .build();
+
+    return SchemaCoder.of(
+        descriptorSchema,
+        TypeDescriptor.of(CdcRowDescriptor.class),
+        descriptor ->
+            Row.withSchema(descriptorSchema)
+                .addValues(
+                    descriptor.getSnapshotSequenceNumber(),
+                    descriptor.getCommitSnapshotId(),
+                    descriptor.getPrimaryKey())
+                .build(),
+        row ->
+            CdcRowDescriptor.builder()
+                
.setSnapshotSequenceNumber(row.getInt64("snapshotSequenceNumber"))
+                .setCommitSnapshotId(row.getInt64("commitSnapshotId"))
+                .setPrimaryKey(row.getRow("primaryKey"))
+                .build());
+  }
+
+  public static Builder builder() {
+    return new AutoValue_CdcRowDescriptor.Builder();
+  }
+
+  @SchemaFieldNumber("0")
+  public abstract long getSnapshotSequenceNumber();
+
+  @SchemaFieldNumber("1")
+  public abstract long getCommitSnapshotId();
+
+  @SchemaFieldNumber("2")
+  public abstract Row getPrimaryKey();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    abstract Builder setSnapshotSequenceNumber(long sequenceNumber);
+
+    abstract Builder setCommitSnapshotId(long snapshotId);
+
+    abstract Builder setPrimaryKey(Row primaryKey);
+
+    abstract CdcRowDescriptor build();
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/ChangelogScanner.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/ChangelogScanner.java
index 17ab4c5d30c..979d2f97308 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/ChangelogScanner.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/ChangelogScanner.java
@@ -18,6 +18,7 @@
 package org.apache.beam.sdk.io.iceberg.cdc;
 
 import static java.lang.String.format;
+import static 
org.apache.beam.sdk.io.iceberg.cdc.CdcReadUtils.COMPRESSED_TO_DECODED_BYTES_ESTIMATE;
 import static 
org.apache.beam.sdk.io.iceberg.cdc.SerializableChangelogTask.Type.ADDED_ROWS;
 import static 
org.apache.beam.sdk.io.iceberg.cdc.SerializableChangelogTask.getDataFile;
 import static 
org.apache.beam.sdk.io.iceberg.cdc.SerializableChangelogTask.getLength;
@@ -662,10 +663,12 @@ class ChangelogScanner
    * path otherwise.
    *
    * <p>For LOCAL routing, all bi-directional tasks for this 
snapshot/partition group are emitted as
-   * a batch so that the downstream {@link LocalResolveDoFn} can resolve them 
together in-memory. //
-   * * The total byte size may exceed {@code splitSize}, but the in-memory // 
* footprint is bounded
-   * by the overlap byte estimate (the local resolver still does per-record PK 
// * routing to avoid
-   * buffering records outside the overlap range).
+   * a batch so that the downstream {@link LocalResolveDoFn} can resolve them 
together in-memory. We
+   * only take this path when the group's estimated decoded size fits within 
{@code splitSize}. This
+   * bounds a single thread to roughly that footprint in the worst case (when 
metrics are missing or
+   * there is a very large overlap). The footprint is typically much smaller 
though: when PK bounds
+   * are available the resolver further prunes records outside the overlap 
range via per-record PK
+   * routing.
    *
    * <p>Returns the number of tasks routed to LOCAL so the caller can update 
counters.
    */
@@ -698,7 +701,7 @@ class ChangelogScanner
         result.bidirectional.stream().map(t -> makeTask(t, 
table)).collect(Collectors.toList());
 
     // If the batch is small enough, we can route to LOCAL (in-memory) resolver
-    if (totalBytes <= splitSize(table)) {
+    if (totalBytes * COMPRESSED_TO_DECODED_BYTES_ESTIMATE <= splitSize(table)) 
{
       Instant ts = Instant.ofEpochMilli(snapshot.timestampMillis());
       multiOutputReceiver
           .get(SMALL_BIDIRECTIONAL_TASKS)
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/LocalResolveDoFn.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/LocalResolveDoFn.java
new file mode 100644
index 00000000000..a3188b3a024
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/LocalResolveDoFn.java
@@ -0,0 +1,245 @@
+/*
+ * 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;
+
+import static 
org.apache.beam.sdk.io.iceberg.IcebergUtils.icebergSchemaToBeamSchema;
+import static 
org.apache.beam.sdk.io.iceberg.cdc.SerializableChangelogTask.Type.ADDED_ROWS;
+import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import org.apache.beam.sdk.io.iceberg.IcebergScanConfig;
+import org.apache.beam.sdk.io.iceberg.IcebergUtils;
+import org.apache.beam.sdk.io.iceberg.TableCache;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.join.CoGroupByKey;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.ValueKind;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.StructLike;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.types.Type;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.StructLikeMap;
+import org.apache.iceberg.util.StructLikeUtil;
+import org.apache.iceberg.util.StructProjection;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Resolves a small bi-directional changelog group entirely in memory. This is 
the equivalent of
+ * {@link ReadFromChangelogs} + {@link CoGroupByKey} + {@link ResolveChanges}.
+ *
+ * <p>All tasks in a changelog group belong to the same Iceberg {@link 
Snapshot}. The upstream
+ * {@link ChangelogScanner} routes here only when the total size of the 
bi-directional group fits
+ * within {@link TableProperties#SPLIT_SIZE}.
+ *
+ * <p>The incoming batch's overlap region has already been computed in the 
scanning phase by {@link
+ * ChangelogScanner}. In this DoFn, we just process each task and route 
records:
+ *
+ * <ul>
+ *   <li>Records whose PK falls <b>outside</b> the overlap range cannot have 
an opposing-side match,
+ *       so they are emitted directly with {@code INSERT} or {@code DELETE} 
kind.
+ *   <li>Records whose PK falls <b>inside</b> the overlap range are stashed in 
a {@link
+ *       StructLikeMap} keyed by PK, then resolved by {@link CdcResolver}.
+ * </ul>
+ */
+class LocalResolveDoFn extends DoFn<KV<ChangelogDescriptor, 
List<SerializableChangelogTask>>, Row> {
+  private final IcebergScanConfig scanConfig;
+  private final org.apache.beam.sdk.schemas.Schema projectedBeamSchema;
+  private final org.apache.beam.sdk.schemas.Schema outputBeamSchema;
+
+  private transient @MonotonicNonNull OverlapRange overlap;
+  private transient @MonotonicNonNull List<Types.NestedField> nonPkFields;
+  private transient @MonotonicNonNull StructProjection projector;
+
+  LocalResolveDoFn(IcebergScanConfig scanConfig) {
+    this.scanConfig = scanConfig;
+    this.projectedBeamSchema =
+        CdcOutputUtils.readBeamSchemaWithRowMetadata(
+            scanConfig.getMetadataColumns(), scanConfig.getProjectedSchema());
+    this.outputBeamSchema =
+        CdcOutputUtils.outputSchema(
+            scanConfig, 
icebergSchemaToBeamSchema(scanConfig.getProjectedSchema()));
+  }
+
+  @Setup
+  public void setup() {
+    Schema tableSchema =
+        TableCache.get(scanConfig.getCatalogConfig(), 
scanConfig.getTableIdentifier()).schema();
+    Schema fullReadSchema =
+        
CdcOutputUtils.readSchemaWithRowMetadata(scanConfig.getMetadataColumns(), 
tableSchema);
+    this.overlap = OverlapRange.forScanConfig(scanConfig);
+    Set<String> pkFieldNames = new 
HashSet<>(overlap.recordIdSchema().identifierFieldNames());
+    // The dedup logic only inspects non-PK fields, so precompute them once.
+    List<Types.NestedField> nonPk = new ArrayList<>();
+    for (Types.NestedField f : tableSchema.columns()) {
+      if (!pkFieldNames.contains(f.name())) {
+        nonPk.add(f);
+      }
+    }
+    this.nonPkFields = nonPk;
+    this.projector =
+        StructProjection.create(
+            fullReadSchema,
+            CdcOutputUtils.readSchemaWithRowMetadata(
+                scanConfig.getMetadataColumns(), 
scanConfig.getProjectedSchema()));
+  }
+
+  @ProcessElement
+  public void process(
+      @Element KV<ChangelogDescriptor, List<SerializableChangelogTask>> 
element,
+      OutputReceiver<Row> out)
+      throws IOException {
+    ChangelogDescriptor descriptor = element.getKey();
+    Table table = TableCache.get(scanConfig.getCatalogConfig(), 
scanConfig.getTableIdentifier());
+    OverlapRange ovl = checkStateNotNull(overlap);
+
+    // {PK: (inserts | deletes)} for in-overlap records that need resolution.
+    // Records outside the overlap are emitted directly
+    StructLikeMap<PkGroup> pkGroups = 
StructLikeMap.create(ovl.recordIdSchema().asStruct());
+
+    @Nullable StructLike overlapLower = 
ovl.toStructLike(descriptor.getOverlapLower());
+    @Nullable StructLike overlapUpper = 
ovl.toStructLike(descriptor.getOverlapUpper());
+    for (SerializableChangelogTask task : element.getValue()) {
+      readAndRoute(descriptor, task, table, overlapLower, overlapUpper, 
pkGroups, out);
+    }
+
+    resolveAndEmit(descriptor, pkGroups, out);
+  }
+
+  /**
+   * Processes a {@link SerializableChangelogTask} and routes each record.
+   *
+   * <ul>
+   *   <li>Out of overlap: emit directly
+   *   <li>Inside overlap: stash in {@code pkGroups} to resolve in {@link 
#resolveAndEmit}
+   * </ul>
+   */
+  private void readAndRoute(
+      ChangelogDescriptor descriptor,
+      SerializableChangelogTask task,
+      Table table,
+      @Nullable StructLike overlapLower,
+      @Nullable StructLike overlapUpper,
+      StructLikeMap<PkGroup> pkGroups,
+      OutputReceiver<Row> out)
+      throws IOException {
+    OverlapRange ovl = checkStateNotNull(overlap);
+    boolean isInsert = task.getType() == ADDED_ROWS;
+    try (CloseableIterable<Record> records =
+        CdcReadUtils.changelogRecordsForTask(task, table, scanConfig, false)) {
+      for (Record rec : records) {
+        if (ovl.contains(rec, overlapLower, overlapUpper)) { // needs 
resolution
+          StructLike pk = StructLikeUtil.copy(ovl.recordIdProjection());
+          PkGroup group = pkGroups.computeIfAbsent(pk, k -> new PkGroup());
+          if (isInsert) {
+            group.inserts.add(rec);
+          } else {
+            group.deletes.add(rec);
+          }
+        } else { // safe to emit directly
+          emit(descriptor, rec, isInsert ? ValueKind.INSERT : 
ValueKind.DELETE, out);
+        }
+      }
+    }
+  }
+
+  /** Resolves each PK group using {@link CdcResolver}. */
+  private void resolveAndEmit(
+      ChangelogDescriptor descriptor, StructLikeMap<PkGroup> pkGroups, 
OutputReceiver<Row> out) {
+    CdcResolver<Record> resolver = new 
RecordResolver(checkStateNotNull(nonPkFields));
+    for (PkGroup group : pkGroups.values()) {
+      resolver.resolve(
+          group.deletes,
+          group.inserts,
+          (kind, rec) -> {
+            emit(descriptor, rec, kind, out);
+          });
+    }
+  }
+
+  /** Resolver specialization that hashes Iceberg Record non-PK fields. */
+  private static final class RecordResolver extends CdcResolver<Record> {
+    private final List<Types.NestedField> nonPkFields;
+
+    RecordResolver(List<Types.NestedField> nonPkFields) {
+      this.nonPkFields = nonPkFields;
+    }
+
+    @Override
+    protected int nonPkHash(Record rec) {
+      int hash = 1;
+      for (Types.NestedField field : nonPkFields) {
+        hash = 31 * hash + deepHash(rec.getField(field.name()));
+      }
+      return hash;
+    }
+
+    @Override
+    protected boolean nonPkEquals(Record delete, Record insert) {
+      for (Types.NestedField field : nonPkFields) {
+        // consistent with deepHash
+        if (!Objects.deepEquals(delete.getField(field.name()), 
insert.getField(field.name()))) {
+          return false;
+        }
+      }
+      return true;
+    }
+
+    /**
+     * Content hash consistent with {@link Objects#deepEquals}. Iceberg's 
generic model only ever
+     * produces a {@code byte[]} for {@link Type.TypeID#FIXED} columns, but we 
use {@link
+     * Class#isArray} to handle any array type.
+     */
+    private static int deepHash(@Nullable Object value) {
+      if (value != null && value.getClass().isArray()) {
+        return Arrays.deepHashCode(new Object[] {value});
+      }
+      return Objects.hashCode(value);
+    }
+  }
+
+  /** Prune to get the final projected record then output as a Beam Row. */
+  private void emit(
+      ChangelogDescriptor descriptor, Record rec, ValueKind kind, 
OutputReceiver<Row> out) {
+    StructLike projected = checkStateNotNull(projector).wrap(rec);
+    Row record = IcebergUtils.structToRow(projectedBeamSchema, projected);
+    out.builder(
+            CdcOutputUtils.outputRow(
+                scanConfig.getMetadataColumns(), outputBeamSchema, descriptor, 
kind, record))
+        .setValueKind(kind)
+        .output();
+  }
+
+  /** Two parallel lists of inserts/deletes that share a primary key. */
+  private static final class PkGroup {
+    final List<Record> inserts = new ArrayList<>();
+    final List<Record> deletes = new ArrayList<>();
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/OverlapRange.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/OverlapRange.java
new file mode 100644
index 00000000000..04e0429030a
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/OverlapRange.java
@@ -0,0 +1,102 @@
+/*
+ * 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;
+
+import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
+
+import java.util.Comparator;
+import org.apache.beam.sdk.io.iceberg.IcebergScanConfig;
+import org.apache.beam.sdk.io.iceberg.IcebergUtils;
+import org.apache.beam.sdk.io.iceberg.TableCache;
+import org.apache.beam.sdk.values.Row;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.StructLike;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.util.StructProjection;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Primary-key-projection and overlap-range comparison helper.
+ *
+ * <p>Used by {@link LocalResolveDoFn} and {@link ReadFromChangelogs} to 
decide whether a record's
+ * PK falls within an overlap of two opposing tasks. If so, the record needs 
to be compared with
+ * others to determine if it is part of an update pair.
+ */
+final class OverlapRange {
+  private final Schema recordIdSchema;
+  private final StructProjection recordIdProjection;
+  private final Comparator<StructLike> idComp;
+
+  private OverlapRange(
+      Schema recordIdSchema, StructProjection recordIdProjection, 
Comparator<StructLike> idComp) {
+    this.recordIdSchema = recordIdSchema;
+    this.recordIdProjection = recordIdProjection;
+    this.idComp = idComp;
+  }
+
+  static OverlapRange forScanConfig(IcebergScanConfig scanConfig) {
+    Schema tableSchema =
+        TableCache.get(scanConfig.getCatalogConfig(), 
scanConfig.getTableIdentifier()).schema();
+    Schema fullSchema =
+        
CdcOutputUtils.readSchemaWithRowMetadata(scanConfig.getMetadataColumns(), 
tableSchema);
+    StructProjection projection = StructProjection.create(fullSchema, 
scanConfig.recordIdSchema());
+    return new OverlapRange(
+        scanConfig.recordIdSchema(), projection, 
scanConfig.recordIdComparator());
+  }
+
+  StructProjection recordIdProjection() {
+    return recordIdProjection;
+  }
+
+  Schema recordIdSchema() {
+    return recordIdSchema;
+  }
+
+  /** Converts a Beam Row (overlap bound) back to an Iceberg {@link 
StructLike}. */
+  @Nullable
+  StructLike toStructLike(@Nullable Row beamBound) {
+    if (beamBound == null) {
+      return null;
+    }
+    return IcebergUtils.beamRowToIcebergRecord(recordIdSchema, beamBound);
+  }
+
+  /**
+   * Wraps the record to project its Primary Key, then checks if the PK is 
within the overlap {@code
+   * [lower, upper]} (inclusive). Can be paired with a subsequent {@link 
#recordIdProjection()} call
+   * to fetch the PK value.
+   *
+   * <p>Both ends are inclusive because the bounds are Iceberg file statistics 
(actual min/max PK
+   * values), making the overlap an intersection of two closed ranges. Note 
the error directions are
+   * not symmetric: being over-inclusive only costs extra buffering, since an 
unmatched record
+   * resolves to the same {@code INSERT} / {@code DELETE} it would have been 
emitted as, whereas
+   * excluding a boundary PK would split a genuine update into a spurious 
{@code INSERT} + {@code
+   * DELETE}.
+   *
+   * <p>If either bound is null, we conservatively assume it falls within the 
overlap.
+   */
+  boolean contains(Record rec, @Nullable StructLike lower, @Nullable 
StructLike upper) {
+    checkStateNotNull(recordIdProjection).wrap(rec);
+
+    if (lower == null || upper == null) {
+      return true;
+    }
+    return idComp.compare(recordIdProjection, lower) >= 0
+        && idComp.compare(recordIdProjection, upper) <= 0;
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/ReadFromChangelogs.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/ReadFromChangelogs.java
new file mode 100644
index 00000000000..f9a733300f7
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/ReadFromChangelogs.java
@@ -0,0 +1,494 @@
+/*
+ * 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;
+
+import static 
org.apache.beam.sdk.io.iceberg.IcebergUtils.icebergRecordToBeamRow;
+import static 
org.apache.beam.sdk.io.iceberg.IcebergUtils.icebergSchemaToBeamSchema;
+import static org.apache.beam.sdk.io.iceberg.IcebergUtils.structToRow;
+import static 
org.apache.beam.sdk.io.iceberg.cdc.CdcReadUtils.COMPRESSED_TO_DECODED_BYTES_ESTIMATE;
+import static 
org.apache.beam.sdk.io.iceberg.cdc.ChangelogScanner.LARGE_BIDIRECTIONAL_TASKS;
+import static 
org.apache.beam.sdk.io.iceberg.cdc.ChangelogScanner.UNIDIRECTIONAL_TASKS;
+import static 
org.apache.beam.sdk.io.iceberg.cdc.SerializableChangelogTask.Type.ADDED_ROWS;
+import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.apache.beam.sdk.Pipeline;
+import org.apache.beam.sdk.coders.KvCoder;
+import org.apache.beam.sdk.io.iceberg.IcebergScanConfig;
+import org.apache.beam.sdk.io.iceberg.IcebergUtils;
+import org.apache.beam.sdk.io.iceberg.SerializableDeleteFile;
+import org.apache.beam.sdk.io.iceberg.TableCache;
+import org.apache.beam.sdk.io.range.OffsetRange;
+import org.apache.beam.sdk.metrics.Counter;
+import org.apache.beam.sdk.metrics.Metrics;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.schemas.SchemaCoder;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.Flatten;
+import org.apache.beam.sdk.transforms.PTransform;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.transforms.Redistribute;
+import org.apache.beam.sdk.transforms.join.CoGroupByKey;
+import org.apache.beam.sdk.transforms.splittabledofn.RestrictionTracker;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionList;
+import org.apache.beam.sdk.values.PCollectionTuple;
+import org.apache.beam.sdk.values.PInput;
+import org.apache.beam.sdk.values.POutput;
+import org.apache.beam.sdk.values.PValue;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.TupleTag;
+import org.apache.beam.sdk.values.TupleTagList;
+import org.apache.beam.sdk.values.ValueKind;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.ChangelogScanTask;
+import org.apache.iceberg.StructLike;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.util.StructProjection;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * A {@link PTransform} that processes batches of {@link ChangelogScanTask}s 
and routes them
+ * accordingly:
+ *
+ * <ul>
+ *   <li>Records from Uni-directional batches are directly emitted, as INSERT 
or DELETE kind
+ *   <li>Records from Bi-directional batches are compared against the Primary 
Key overlap range:
+ *       <ul>
+ *         <li>if outside the overlap, emit directly as INSERT or DELETE kind
+ *         <li>if inside the overlap, key by (snapshot seq#, pk) and route to 
downstream {@link
+ *             CoGroupByKey} and final resolution by {@link ResolveChanges}
+ *       </ul>
+ * </ul>
+ *
+ * <p>We first key bi-directional rows by (snapshot sequence number, primary 
key) before sending to
+ * {@link CoGroupByKey} to ensure they stay isolated from other PKs or 
snapshots. Inserts are routed
+ * to
+ *
+ * <p>A {@link ChangelogScanTask} comes in three types:
+ *
+ * <ol>
+ *   <li><b>AddedRowsScanTask</b>: Indicates records have been inserted by a 
new DataFile.
+ *   <li><b>DeletedRowsScanTask</b>: Indicates records have been deleted using 
a DeleteFile.
+ *   <li><b>DeletedDataFileScanTask</b>: Indicates a whole DataFile has been 
deleted.
+ * </ol>
+ *
+ * <p>Each of these types need to be processed differently. More details in 
{@link
+ * CdcReadUtils#changelogRecordsForTask}.
+ *
+ * <p>CDC metadata has two entry points in this transform. Row metadata 
columns are requested from
+ * the Iceberg reader by {@link CdcReadUtils} and travel inside intermediate 
rows until final output
+ * assembly. Snapshot metadata columns come from the {@link 
ChangelogDescriptor} / {@link
+ * CdcRowDescriptor} carried with each task or shuffled row, and {@code 
_change_type} comes from the
+ * emitted change kind. Final user-visible rows are assembled by {@link 
CdcOutputUtils#outputRow},
+ * which appends all requested metadata as top-level columns in the configured 
order.
+ */
+public class ReadFromChangelogs extends PTransform<PCollectionTuple, 
ReadFromChangelogs.Output> {
+  private static final Counter numAddedRowsScanTasksCompleted =
+      Metrics.counter(ReadFromChangelogs.class, 
"numAddedRowsScanTasksCompleted");
+  private static final Counter numDeletedRowsScanTasksCompleted =
+      Metrics.counter(ReadFromChangelogs.class, 
"numDeletedRowsScanTasksCompleted");
+  private static final Counter numDeletedDataFileScanTasksCompleted =
+      Metrics.counter(ReadFromChangelogs.class, 
"numDeletedDataFileScanTasksCompleted");
+
+  private static final TupleTag<Row> UNIDIRECTIONAL_ROWS = new TupleTag<>();
+  private static final TupleTag<KV<CdcRowDescriptor, Row>> 
BIDIRECTIONAL_INSERTS = new TupleTag<>();
+  private static final TupleTag<KV<CdcRowDescriptor, Row>> 
BIDIRECTIONAL_DELETES = new TupleTag<>();
+
+  private final IcebergScanConfig scanConfig;
+
+  ReadFromChangelogs(IcebergScanConfig scanConfig) {
+    this.scanConfig = scanConfig;
+  }
+
+  @Override
+  public org.apache.beam.sdk.io.iceberg.cdc.ReadFromChangelogs.Output expand(
+      PCollectionTuple input) {
+    Schema fullRowSchema =
+        CdcOutputUtils.readBeamSchemaWithRowMetadata(
+            scanConfig.getMetadataColumns(), scanConfig.getSchema());
+    Schema projectedRowSchema =
+        
IcebergUtils.icebergSchemaToBeamSchema(scanConfig.getProjectedSchema());
+    Schema outputRowSchema = CdcOutputUtils.outputSchema(scanConfig, 
projectedRowSchema);
+
+    // === UNIDIRECTIONAL tasks ===
+    // (i.e. only deletes, or only inserts)
+    // take the fast approach of just reading and emitting CDC records
+    PCollection<Row> uniDirectionalRows =
+        input
+            .get(UNIDIRECTIONAL_TASKS)
+            .apply("Redistribute Uni-Directional Changes", 
Redistribute.arbitrarily())
+            .apply(
+                "Read Uni-Directional Changes",
+                ParDo.of(ReadDoFn.unidirectional(scanConfig))
+                    .withOutputTags(UNIDIRECTIONAL_ROWS, TupleTagList.empty()))
+            .get(UNIDIRECTIONAL_ROWS)
+            .setRowSchema(outputRowSchema);
+
+    // === BIDIRECTIONAL tasks ===
+    // (i.e. a task group containing a mix of deletes and inserts)
+    // read and route records according to their PK (see class java doc)
+    PCollectionTuple biDirectionalRows =
+        input
+            .get(LARGE_BIDIRECTIONAL_TASKS)
+            .apply("Redistribute Large Bi-Directional Changes", 
Redistribute.arbitrarily())
+            .apply(
+                "Read Bi-Directional Changes",
+                ParDo.of(ReadDoFn.bidirectional(scanConfig))
+                    .withOutputTags(
+                        BIDIRECTIONAL_INSERTS,
+                        
TupleTagList.of(BIDIRECTIONAL_DELETES).and(UNIDIRECTIONAL_ROWS)));
+    // Collect pruned (non-overlapping) rows from bi-directional reader
+    PCollection<Row> nonOverlappingRowsFromBiDirTasks =
+        
biDirectionalRows.get(UNIDIRECTIONAL_ROWS).setRowSchema(outputRowSchema);
+
+    // Flatten uni-directional rows from both sources
+    PCollection<Row> allUniDirectionalRows =
+        PCollectionList.of(uniDirectionalRows)
+            .and(nonOverlappingRowsFromBiDirTasks)
+            .apply("Flatten Uni-Directional Rows", Flatten.pCollections());
+
+    // Reify to preserve each record's timestamp (CoGBK overwrites timestamps 
with the window's
+    // end-of-window)
+    // Note: element timestamps are snapshot commit timestamp
+    KvCoder<CdcRowDescriptor, Row> keyedOutputCoder =
+        KvCoder.of(
+            CdcRowDescriptor.coder(scanConfig.rowIdBeamSchema()), 
SchemaCoder.of(fullRowSchema));
+    PCollection<KV<CdcRowDescriptor, Row>> keyedInsertsWithTimestamps =
+        
biDirectionalRows.get(BIDIRECTIONAL_INSERTS).setCoder(keyedOutputCoder);
+    PCollection<KV<CdcRowDescriptor, Row>> keyedDeletesWithTimestamps =
+        
biDirectionalRows.get(BIDIRECTIONAL_DELETES).setCoder(keyedOutputCoder);
+
+    return new org.apache.beam.sdk.io.iceberg.cdc.ReadFromChangelogs.Output(
+        input.getPipeline(),
+        allUniDirectionalRows,
+        keyedInsertsWithTimestamps,
+        keyedDeletesWithTimestamps);
+  }
+
+  public static class Output implements POutput {
+    private final Pipeline pipeline;
+    private final PCollection<Row> uniDirectionalRows;
+    private final PCollection<KV<CdcRowDescriptor, Row>> biDirectionalInserts;
+    private final PCollection<KV<CdcRowDescriptor, Row>> biDirectionalDeletes;
+
+    Output(
+        Pipeline p,
+        PCollection<Row> uniDirectionalRows,
+        PCollection<KV<CdcRowDescriptor, Row>> biDirectionalInserts,
+        PCollection<KV<CdcRowDescriptor, Row>> biDirectionalDeletes) {
+      this.pipeline = p;
+      this.uniDirectionalRows = uniDirectionalRows;
+      this.biDirectionalInserts = biDirectionalInserts;
+      this.biDirectionalDeletes = biDirectionalDeletes;
+    }
+
+    PCollection<Row> uniDirectionalRows() {
+      return uniDirectionalRows;
+    }
+
+    PCollection<KV<CdcRowDescriptor, Row>> biDirectionalInserts() {
+      return biDirectionalInserts;
+    }
+
+    PCollection<KV<CdcRowDescriptor, Row>> biDirectionalDeletes() {
+      return biDirectionalDeletes;
+    }
+
+    @Override
+    public Pipeline getPipeline() {
+      return pipeline;
+    }
+
+    @Override
+    public Map<TupleTag<?>, PValue> expand() {
+      return ImmutableMap.of(
+          UNIDIRECTIONAL_ROWS,
+          uniDirectionalRows,
+          BIDIRECTIONAL_INSERTS,
+          biDirectionalInserts,
+          BIDIRECTIONAL_DELETES,
+          biDirectionalDeletes);
+    }
+
+    @Override
+    public void finishSpecifyingOutput(
+        String transformName, PInput input, PTransform<?, ?> transform) {}
+  }
+
+  @DoFn.BoundedPerElement
+  private static class ReadDoFn<OutT>
+      extends DoFn<KV<ChangelogDescriptor, List<SerializableChangelogTask>>, 
OutT> {
+    private final IcebergScanConfig scanConfig;
+    private final boolean keyedOutput;
+    private final Schema projectedBeamRowSchema;
+    private final Schema outputBeamRowSchema;
+    private final Schema fullBeamRowSchema;
+    private transient @MonotonicNonNull OverlapRange overlap;
+    private transient @MonotonicNonNull StructProjection outputProjector;
+    private transient @MonotonicNonNull StructProjection pkProjector;
+
+    /** Used for uni-directional changes. Records are output immediately 
as-is. */
+    static ReadDoFn<Row> unidirectional(IcebergScanConfig scanConfig) {
+      return new ReadDoFn<>(scanConfig, false);
+    }
+
+    /**
+     * Used for bi-directional changes. Records are keyed by (snapshot 
sequence number, primary key)
+     * and sent to a CoGBK.
+     */
+    static ReadDoFn<KV<CdcRowDescriptor, Row>> bidirectional(IcebergScanConfig 
scanConfig) {
+      return new ReadDoFn<>(scanConfig, true);
+    }
+
+    private ReadDoFn(IcebergScanConfig scanConfig, boolean keyedOutput) {
+      this.scanConfig = scanConfig;
+      this.keyedOutput = keyedOutput;
+
+      this.projectedBeamRowSchema =
+          CdcOutputUtils.readBeamSchemaWithRowMetadata(
+              scanConfig.getMetadataColumns(), 
scanConfig.getProjectedSchema());
+      this.outputBeamRowSchema =
+          CdcOutputUtils.outputSchema(
+              scanConfig, 
icebergSchemaToBeamSchema(scanConfig.getProjectedSchema()));
+      this.fullBeamRowSchema =
+          CdcOutputUtils.readBeamSchemaWithRowMetadata(
+              scanConfig.getMetadataColumns(), scanConfig.getSchema());
+    }
+
+    @Setup
+    public void setup() {
+      this.overlap = OverlapRange.forScanConfig(scanConfig);
+    }
+
+    @ProcessElement
+    public void process(
+        @Element KV<ChangelogDescriptor, List<SerializableChangelogTask>> 
element,
+        RestrictionTracker<OffsetRange, Long> tracker,
+        MultiOutputReceiver out)
+        throws IOException {
+      Table table = TableCache.get(scanConfig.getCatalogConfig(), 
scanConfig.getTableIdentifier());
+
+      List<SerializableChangelogTask> tasks = element.getValue();
+      ChangelogDescriptor descriptor = element.getKey();
+      @Nullable Row overlapLower = descriptor.getOverlapLower();
+      @Nullable Row overlapUpper = descriptor.getOverlapUpper();
+
+      for (long l = tracker.currentRestriction().getFrom();
+          l < tracker.currentRestriction().getTo();
+          l++) {
+        if (!tracker.tryClaim(l)) {
+          return;
+        }
+
+        SerializableChangelogTask task = tasks.get((int) l);
+        processTaskRecords(descriptor, task, overlapLower, overlapUpper, 
table, out);
+      }
+    }
+
+    /**
+     * Processes a ChangelogScanTask and routes records accordingly:
+     *
+     * <p>If this DoFn is configured with {@link #unidirectional}, we simply 
read records and output
+     * directly to {@link #UNIDIRECTIONAL_ROWS}.
+     *
+     * <p>If this DoFn is configured with {@link #bidirectional}, we compare 
against the Primary Key
+     * overlap range. If within the overlap, we key by (snapshotId, PK) and 
out to either {@link
+     * #BIDIRECTIONAL_INSERTS} or {@link #BIDIRECTIONAL_DELETES}. Otherwise 
(not in overlap), we
+     * output the record directly to {@link #UNIDIRECTIONAL_ROWS}.
+     */
+    private void processTaskRecords(
+        ChangelogDescriptor descriptor,
+        SerializableChangelogTask task,
+        @Nullable Row overlapLowerRow,
+        @Nullable Row overlapUpperRow,
+        Table table,
+        MultiOutputReceiver outputReceiver)
+        throws IOException {
+      OverlapRange ovl = checkStateNotNull(overlap);
+      @Nullable StructLike overlapLower = ovl.toStructLike(overlapLowerRow);
+      @Nullable StructLike overlapUpper = ovl.toStructLike(overlapUpperRow);
+
+      boolean isInsert = task.getType() == ADDED_ROWS;
+      TupleTag<KV<CdcRowDescriptor, Row>> taggedOutput =
+          isInsert ? BIDIRECTIONAL_INSERTS : BIDIRECTIONAL_DELETES;
+      ValueKind kind = isInsert ? ValueKind.INSERT : ValueKind.DELETE;
+      long commitSnapshotId = descriptor.getCommitSnapshotId();
+      long commitSnapshotSequenceNumber = 
descriptor.getSnapshotSequenceNumber();
+
+      Schema readSchema = keyedOutput ? fullBeamRowSchema : 
projectedBeamRowSchema;
+      try (CloseableIterable<Record> records =
+          CdcReadUtils.changelogRecordsForTask(task, table, scanConfig, 
!keyedOutput)) {
+        for (Record rec : records) {
+          // uni-directional -- just output records (they are already 
projected by read pushdown)
+          if (!keyedOutput) {
+            Row row = icebergRecordToBeamRow(projectedBeamRowSchema, rec);
+            outputReceiver
+                .get(UNIDIRECTIONAL_ROWS)
+                .builder(
+                    CdcOutputUtils.outputRow(
+                        scanConfig.getMetadataColumns(),
+                        outputBeamRowSchema,
+                        descriptor,
+                        kind,
+                        row))
+                .setValueKind(kind)
+                .output();
+            continue;
+          }
+
+          // bi-directional -- compare overlap
+          if (ovl.contains(rec, overlapLower, overlapUpper)) {
+            // inside overlap -- read full row and output KV
+            Row row = icebergRecordToBeamRow(readSchema, rec);
+            Row pk = structToRow(scanConfig.rowIdBeamSchema(), 
pkProjector().wrap(rec));
+            outputReceiver
+                .get(taggedOutput)
+                .builder(
+                    KV.of(
+                        CdcRowDescriptor.builder()
+                            .setCommitSnapshotId(commitSnapshotId)
+                            
.setSnapshotSequenceNumber(commitSnapshotSequenceNumber)
+                            .setPrimaryKey(pk)
+                            .build(),
+                        row))
+                .setValueKind(kind)
+                .output();
+
+          } else {
+            // outside overlap -- get projected record and output
+            StructLike projected = outputProjector().wrap(rec);
+            Row row = structToRow(projectedBeamRowSchema, projected);
+            outputReceiver
+                .get(UNIDIRECTIONAL_ROWS)
+                .builder(
+                    CdcOutputUtils.outputRow(
+                        scanConfig.getMetadataColumns(),
+                        outputBeamRowSchema,
+                        descriptor,
+                        kind,
+                        row))
+                .setValueKind(kind)
+                .output();
+          }
+        }
+      }
+
+      trackMetrics(task.getType());
+    }
+
+    private StructProjection outputProjector() {
+      if (outputProjector == null) {
+        outputProjector =
+            StructProjection.create(
+                CdcOutputUtils.readSchemaWithRowMetadata(
+                    scanConfig.getMetadataColumns(),
+                    TableCache.get(scanConfig.getCatalogConfig(), 
scanConfig.getTableIdentifier())
+                        .schema()),
+                CdcOutputUtils.readSchemaWithRowMetadata(
+                    scanConfig.getMetadataColumns(), 
scanConfig.getProjectedSchema()));
+      }
+      return outputProjector;
+    }
+
+    private StructProjection pkProjector() {
+      if (pkProjector == null) {
+        pkProjector =
+            StructProjection.create(
+                CdcOutputUtils.readSchemaWithRowMetadata(
+                    scanConfig.getMetadataColumns(),
+                    TableCache.get(scanConfig.getCatalogConfig(), 
scanConfig.getTableIdentifier())
+                        .schema()),
+                scanConfig.recordIdSchema());
+      }
+      return pkProjector;
+    }
+
+    private void trackMetrics(SerializableChangelogTask.Type type) {
+      switch (type) {
+        case ADDED_ROWS:
+          numAddedRowsScanTasksCompleted.inc();
+          break;
+        case DELETED_ROWS:
+          numDeletedRowsScanTasksCompleted.inc();
+          break;
+        case DELETED_FILE:
+          numDeletedDataFileScanTasksCompleted.inc();
+          break;
+      }
+    }
+
+    private String getKind(SerializableChangelogTask.Type taskType) {
+      switch (taskType) {
+        case ADDED_ROWS:
+          return "INSERT";
+        case DELETED_ROWS:
+          return "DELETE";
+        case DELETED_FILE:
+        default:
+          return "DELETE-DF";
+      }
+    }
+
+    @GetSize
+    public double getSize(
+        @Element KV<ChangelogDescriptor, List<SerializableChangelogTask>> 
element,
+        @Restriction OffsetRange restriction) {
+      // TODO(ahmedabu98): can we make this estimate more accurate?
+      long size = 0;
+
+      for (long l = restriction.getFrom(); l < restriction.getTo(); l++) {
+        SerializableChangelogTask task = element.getValue().get((int) l);
+        size += task.getLength() * COMPRESSED_TO_DECODED_BYTES_ESTIMATE;
+        size +=
+            task.getAddedDeletes().stream()
+                    .mapToLong(SerializableDeleteFile::getFileSizeInBytes)
+                    .sum()
+                * COMPRESSED_TO_DECODED_BYTES_ESTIMATE;
+        size +=
+            task.getExistingDeletes().stream()
+                    .mapToLong(SerializableDeleteFile::getFileSizeInBytes)
+                    .sum()
+                * COMPRESSED_TO_DECODED_BYTES_ESTIMATE;
+      }
+
+      return size;
+    }
+
+    @GetInitialRestriction
+    public OffsetRange getInitialRange(
+        @Element KV<ChangelogDescriptor, List<SerializableChangelogTask>> 
element) {
+      return new OffsetRange(0, element.getValue().size());
+    }
+
+    @SplitRestriction
+    public void splitRestriction(
+        @Restriction OffsetRange restriction, OutputReceiver<OffsetRange> out) 
{
+      // Split into individual tasks for maximum initial parallelism
+      for (long i = restriction.getFrom(); i < restriction.getTo(); i++) {
+        out.output(new OffsetRange(i, i + 1));
+      }
+    }
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergUtilsTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergUtilsTest.java
index 7e707717f3c..80e5e2195f9 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergUtilsTest.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergUtilsTest.java
@@ -270,7 +270,10 @@ public class IcebergUtilsTest {
     }
 
     @Test
-    public void testFixed() {}
+    public void testFixed() {
+      byte[] bytes = new byte[] {1, 2, 3, 4};
+      checkRowValueToRecordValue(Schema.FieldType.BYTES, bytes, 
Types.FixedType.ofLength(4), bytes);
+    }
 
     @Test
     public void testBinary() {
@@ -500,7 +503,10 @@ public class IcebergUtilsTest {
     }
 
     @Test
-    public void testFixed() {}
+    public void testFixed() {
+      byte[] bytes = new byte[] {1, 2, 3, 4};
+      checkRecordValueToRowValue(Types.FixedType.ofLength(4), bytes, 
Schema.FieldType.BYTES, bytes);
+    }
 
     @Test
     public void testBinary() {
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/CdcResolverTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/CdcResolverTest.java
new file mode 100644
index 00000000000..effcba47510
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/CdcResolverTest.java
@@ -0,0 +1,156 @@
+/*
+ * 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;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.empty;
+import static org.junit.Assert.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link CdcResolver}. */
+@RunWith(JUnit4.class)
+public class CdcResolverTest {
+  private static final TestResolver RESOLVER = new TestResolver();
+
+  @Test
+  public void duplicateDeleteInsertIsDropped() {
+    List<String> emitted =
+        resolve(
+            Collections.singletonList(item("same", 7)), 
Collections.singletonList(item("same", 7)));
+
+    assertThat(emitted, empty());
+  }
+
+  @Test
+  public void changedDeleteInsertBecomesUpdatePair() {
+    List<String> emitted =
+        resolve(
+            Collections.singletonList(item("before", 1)),
+            Collections.singletonList(item("after", 2)));
+
+    assertThat(emitted, contains("UPDATE_BEFORE:before", 
"UPDATE_AFTER:after"));
+  }
+
+  @Test
+  public void duplicateUpdateAndSingletonsResolveByMultiplicity() {
+    List<String> emitted =
+        resolve(
+            Arrays.asList(item("copy", 1), item("old", 2), 
item("deleted-only", 3)),
+            Arrays.asList(item("copy", 1), item("new", 4)));
+
+    assertThat(emitted, contains("UPDATE_BEFORE:old", "UPDATE_AFTER:new", 
"DELETE:deleted-only"));
+  }
+
+  @Test
+  public void hashCollisionOnlyConsumesEqualInsertOnce() {
+    List<String> emitted =
+        resolve(
+            Arrays.asList(item("copy", 9), item("deleted-only", 9)),
+            Collections.singletonList(item("copy", 9)));
+
+    assertThat(emitted, contains("DELETE:deleted-only"));
+  }
+
+  @Test
+  public void hashMatchAloneDoesNotDeduplicate() {
+    List<String> emitted =
+        resolve(
+            Collections.singletonList(item("before", 42)),
+            Collections.singletonList(item("after", 42)));
+
+    assertThat(emitted, contains("UPDATE_BEFORE:before", 
"UPDATE_AFTER:after"));
+  }
+
+  /**
+   * A PK group can hold several records on a side only when identifier values 
are duplicated. The
+   * pairing is arbitrary in that case, but it must not depend on the order 
the caller supplies.
+   */
+  @Test
+  public void pairingDoesNotDependOnInputOrder() {
+    List<String> inOrder =
+        resolve(
+            Arrays.asList(item("d1", 10), item("d2", 20)),
+            Arrays.asList(item("i1", 30), item("i2", 40)));
+    List<String> insertsReversed =
+        resolve(
+            Arrays.asList(item("d1", 10), item("d2", 20)),
+            Arrays.asList(item("i2", 40), item("i1", 30)));
+
+    assertThat(
+        inOrder,
+        contains("UPDATE_BEFORE:d1", "UPDATE_AFTER:i1", "UPDATE_BEFORE:d2", 
"UPDATE_AFTER:i2"));
+    assertEquals(inOrder, insertsReversed);
+  }
+
+  /** When the two sides differ in size, input order must not decide which 
record is a DELETE. */
+  @Test
+  public void unmatchedExtraDoesNotDependOnInputOrder() {
+    List<String> inOrder =
+        resolve(
+            Arrays.asList(item("d1", 10), item("d2", 20)),
+            Collections.singletonList(item("i1", 30)));
+    List<String> deletesReversed =
+        resolve(
+            Arrays.asList(item("d2", 20), item("d1", 10)),
+            Collections.singletonList(item("i1", 30)));
+
+    assertThat(inOrder, contains("UPDATE_BEFORE:d1", "UPDATE_AFTER:i1", 
"DELETE:d2"));
+    assertEquals(inOrder, deletesReversed);
+  }
+
+  private static Item item(String nonPkValue, int hash) {
+    return new Item(nonPkValue, hash);
+  }
+
+  private static List<String> resolve(List<Item> deletes, List<Item> inserts) {
+    List<String> emitted = new ArrayList<>();
+    RESOLVER.resolve(
+        deletes, inserts, (kind, item) -> emitted.add(kind.name() + ":" + 
item.nonPkValue));
+    return emitted;
+  }
+
+  private static class TestResolver extends CdcResolver<Item> {
+    @Override
+    protected int nonPkHash(Item element) {
+      return element.hash;
+    }
+
+    @Override
+    protected boolean nonPkEquals(Item delete, Item insert) {
+      return delete.nonPkValue.equals(insert.nonPkValue);
+    }
+  }
+
+  private static class Item {
+    private final String nonPkValue;
+    private final int hash;
+
+    private Item(String nonPkValue, int hash) {
+      this.nonPkValue = nonPkValue;
+      this.hash = hash;
+    }
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ChangelogScannerTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ChangelogScannerTest.java
index 3c43b3ecc29..1e4b6ba5802 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ChangelogScannerTest.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ChangelogScannerTest.java
@@ -102,6 +102,26 @@ public class ChangelogScannerTest {
     assertNull(result.overlapUpper);
   }
 
+  @Test
+  public void analyzeFilesTreatsFilesTouchingAtOneKeyAsOverlapping() {
+    // The insert file's max PK equals the delete file's min PK, so they 
intersect at exactly one
+    // value. Bounds are inclusive file statistics, so this must not be pruned 
to unidirectional.
+    FakeAddedRowsTask insert = new FakeAddedRowsTask(dataFile("insert", 10L, 
20L), 11L);
+    FakeDeletedDataFileTask delete = new 
FakeDeletedDataFileTask(dataFile("delete", 20L, 30L), 13L);
+
+    ChangelogScanner.AnalysisResult result =
+        ChangelogScanner.analyzeFiles(
+            true,
+            ImmutableList.of(insert, delete),
+            SINGLE_RECORD_ID_SCHEMA,
+            comparator(SINGLE_RECORD_ID_SCHEMA));
+
+    assertThat(result.unidirectional, empty());
+    assertThat(result.bidirectional, containsInAnyOrder(insert, delete));
+    assertEquals(20L, record(result.overlapLower).getField("id"));
+    assertEquals(20L, record(result.overlapUpper).getField("id"));
+  }
+
   @Test
   public void analyzeFilesFindsOverlapDespiteInputOrder() {
     FakeDeletedDataFileTask laterDelete =
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/LocalResolveDoFnTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/LocalResolveDoFnTest.java
new file mode 100644
index 00000000000..7e287035319
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/LocalResolveDoFnTest.java
@@ -0,0 +1,340 @@
+/*
+ * 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;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+import static org.junit.Assert.assertEquals;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.beam.sdk.io.iceberg.IcebergCatalogConfig;
+import org.apache.beam.sdk.io.iceberg.IcebergScanConfig;
+import org.apache.beam.sdk.io.iceberg.IcebergUtils;
+import org.apache.beam.sdk.io.iceberg.TestDataWarehouse;
+import org.apache.beam.sdk.transforms.DoFnTester;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.TimestampedValue;
+import org.apache.beam.sdk.values.ValueInSingleWindow;
+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.ImmutableMap;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
+import org.apache.iceberg.ChangelogOperation;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.expressions.ExpressionParser;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.types.Types;
+import org.joda.time.Instant;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.rules.TestName;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Integration tests for {@link LocalResolveDoFn}. */
+@RunWith(JUnit4.class)
+public class LocalResolveDoFnTest {
+  private static final org.apache.iceberg.Schema CDC_SCHEMA =
+      new org.apache.iceberg.Schema(
+          ImmutableList.of(
+              Types.NestedField.required(1, "id", Types.LongType.get()),
+              Types.NestedField.optional(2, "visible", Types.StringType.get()),
+              Types.NestedField.optional(3, "hidden", Types.StringType.get())),
+          ImmutableSet.of(1));
+
+  private static final org.apache.iceberg.Schema FIXED_CDC_SCHEMA =
+      new org.apache.iceberg.Schema(
+          ImmutableList.of(
+              Types.NestedField.required(1, "id", Types.LongType.get()),
+              Types.NestedField.optional(2, "visible", Types.StringType.get()),
+              Types.NestedField.optional(3, "data", 
Types.FixedType.ofLength(4))),
+          ImmutableSet.of(1));
+
+  @ClassRule public static final TemporaryFolder TEMPORARY_FOLDER = new 
TemporaryFolder();
+
+  @Rule public TestDataWarehouse warehouse = new 
TestDataWarehouse(TEMPORARY_FOLDER, "default");
+  @Rule public TestName testName = new TestName();
+
+  @Test
+  public void copyOnWriteRewriteOfIdenticalRowsIsDropped() throws Exception {
+    TableIdentifier tableId = tableId();
+    Table table = warehouse.createTable(tableId, CDC_SCHEMA, null, 
tableProperties());
+    IcebergScanConfig scanConfig = scanConfig(table, tableId);
+    DataFile oldFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-old.parquet",
+            table.schema(),
+            ImmutableList.of(record(1L, "shown", "same-hidden")));
+    DataFile newFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-new.parquet",
+            table.schema(),
+            ImmutableList.of(record(1L, "shown", "same-hidden")));
+
+    List<ValueInSingleWindow<Row>> output =
+        process(
+            scanConfig,
+            descriptor(tableId, 1L, 1L),
+            ImmutableList.of(
+                task(SerializableChangelogTask.Type.DELETED_FILE, oldFile, 
table, 300L),
+                task(SerializableChangelogTask.Type.ADDED_ROWS, newFile, 
table, 300L)),
+            new Instant(0L));
+
+    assertThat(output, empty());
+  }
+
+  @Test
+  public void hiddenOnlyUpdateIsResolvedBeforeProjection() throws Exception {
+    TableIdentifier tableId = tableId();
+    Table table = warehouse.createTable(tableId, CDC_SCHEMA, null, 
tableProperties());
+    IcebergScanConfig scanConfig = scanConfig(table, tableId);
+    DataFile oldFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-old.parquet",
+            table.schema(),
+            ImmutableList.of(record(1L, "shown", "old-hidden")));
+    DataFile newFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-new.parquet",
+            table.schema(),
+            ImmutableList.of(record(1L, "shown", "new-hidden")));
+    Instant timestamp = new Instant(1234L);
+
+    List<ValueInSingleWindow<Row>> output =
+        process(
+            scanConfig,
+            descriptor(tableId, 1L, 1L),
+            ImmutableList.of(
+                task(SerializableChangelogTask.Type.DELETED_FILE, oldFile, 
table, 301L),
+                task(SerializableChangelogTask.Type.ADDED_ROWS, newFile, 
table, 301L)),
+            timestamp);
+
+    assertThat(
+        
output.stream().map(LocalResolveDoFnTest::kindAndProjectedRow).collect(Collectors.toList()),
+        contains("UPDATE_BEFORE:1:shown:2", "UPDATE_AFTER:1:shown:2"));
+    assertEquals(
+        ImmutableList.of(timestamp, timestamp),
+        
output.stream().map(ValueInSingleWindow::getTimestamp).collect(Collectors.toList()));
+  }
+
+  @Test
+  public void copyOnWriteRewriteWithFixedColumnIsDropped() throws Exception {
+    TableIdentifier tableId = tableId();
+    Table table = warehouse.createTable(tableId, FIXED_CDC_SCHEMA, null, 
tableProperties());
+    IcebergScanConfig scanConfig = scanConfig(table, tableId);
+    // Distinct byte[] instances with identical content. The CoW no-op is only 
dropped when the
+    // `fixed` column is hashed/compared by content; an identity hashCode 
would leak a spurious
+    // UPDATE pair.
+    DataFile oldFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-old.parquet",
+            table.schema(),
+            ImmutableList.of(fixedRecord(1L, "shown", new byte[] {1, 2, 3, 
4})));
+    DataFile newFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-new.parquet",
+            table.schema(),
+            ImmutableList.of(fixedRecord(1L, "shown", new byte[] {1, 2, 3, 
4})));
+
+    List<ValueInSingleWindow<Row>> output =
+        process(
+            scanConfig,
+            descriptor(tableId, 1L, 1L),
+            ImmutableList.of(
+                task(SerializableChangelogTask.Type.DELETED_FILE, oldFile, 
table, 300L),
+                task(SerializableChangelogTask.Type.ADDED_ROWS, newFile, 
table, 300L)),
+            new Instant(0L));
+
+    assertThat(output, empty());
+  }
+
+  @Test
+  public void differingFixedColumnBecomesUpdatePair() throws Exception {
+    TableIdentifier tableId = tableId();
+    Table table = warehouse.createTable(tableId, FIXED_CDC_SCHEMA, null, 
tableProperties());
+    IcebergScanConfig scanConfig = scanConfig(table, tableId);
+    // Same PK and projected fields, but the `fixed` column differs, so this 
must NOT be treated as
+    // a CoW duplicate -- guards against the fixed column being ignored during 
resolution.
+    DataFile oldFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-old.parquet",
+            table.schema(),
+            ImmutableList.of(fixedRecord(1L, "shown", new byte[] {1, 2, 3, 
4})));
+    DataFile newFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-new.parquet",
+            table.schema(),
+            ImmutableList.of(fixedRecord(1L, "shown", new byte[] {5, 6, 7, 
8})));
+
+    List<ValueInSingleWindow<Row>> output =
+        process(
+            scanConfig,
+            descriptor(tableId, 1L, 1L),
+            ImmutableList.of(
+                task(SerializableChangelogTask.Type.DELETED_FILE, oldFile, 
table, 302L),
+                task(SerializableChangelogTask.Type.ADDED_ROWS, newFile, 
table, 302L)),
+            new Instant(0L));
+
+    assertThat(
+        
output.stream().map(LocalResolveDoFnTest::kindAndProjectedRow).collect(Collectors.toList()),
+        contains("UPDATE_BEFORE:1:shown:2", "UPDATE_AFTER:1:shown:2"));
+  }
+
+  @Test
+  public void recordsOnOverlapBoundsAreResolvedAsUpdates() throws Exception {
+    TableIdentifier tableId = tableId();
+    Table table = warehouse.createTable(tableId, CDC_SCHEMA, null, 
tableProperties());
+    IcebergScanConfig scanConfig = scanConfig(table, tableId);
+    DataFile oldFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-old.parquet",
+            table.schema(),
+            ImmutableList.of(record(1L, "shown", "old"), record(2L, "shown", 
"old")));
+    DataFile newFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-new.parquet",
+            table.schema(),
+            ImmutableList.of(record(1L, "shown", "new"), record(2L, "shown", 
"new")));
+
+    List<ValueInSingleWindow<Row>> output =
+        process(
+            scanConfig,
+            descriptor(tableId, 1L, 2L),
+            ImmutableList.of(
+                task(SerializableChangelogTask.Type.DELETED_FILE, oldFile, 
table, 303L),
+                task(SerializableChangelogTask.Type.ADDED_ROWS, newFile, 
table, 303L)),
+            new Instant(0L));
+
+    assertThat(
+        
output.stream().map(LocalResolveDoFnTest::kindAndProjectedRow).collect(Collectors.toList()),
+        containsInAnyOrder(
+            "UPDATE_BEFORE:1:shown:2",
+            "UPDATE_AFTER:1:shown:2",
+            "UPDATE_BEFORE:2:shown:2",
+            "UPDATE_AFTER:2:shown:2"));
+  }
+
+  private List<ValueInSingleWindow<Row>> process(
+      IcebergScanConfig scanConfig,
+      ChangelogDescriptor descriptor,
+      List<SerializableChangelogTask> tasks,
+      Instant timestamp)
+      throws Exception {
+    try (DoFnTester<KV<ChangelogDescriptor, List<SerializableChangelogTask>>, 
Row> tester =
+        DoFnTester.of(new LocalResolveDoFn(scanConfig))) {
+      tester.processTimestampedElement(TimestampedValue.of(KV.of(descriptor, 
tasks), timestamp));
+      return tester.getMutableOutput(tester.getMainOutputTag());
+    }
+  }
+
+  private TableIdentifier tableId() {
+    return TableIdentifier.of("default", testName.getMethodName());
+  }
+
+  private IcebergScanConfig scanConfig(Table table, TableIdentifier tableId) {
+    return IcebergScanConfig.builder()
+        .setCatalogConfig(
+            IcebergCatalogConfig.builder()
+                .setCatalogName("name")
+                .setCatalogProperties(
+                    ImmutableMap.of("type", "hadoop", "warehouse", 
warehouse.location))
+                .build())
+        .setTableIdentifier(tableId)
+        .setSchema(IcebergUtils.icebergSchemaToBeamSchema(table.schema()))
+        .setKeepFields(ImmutableList.of("id", "visible"))
+        .setUseCdc(true)
+        .build();
+  }
+
+  private static ChangelogDescriptor descriptor(
+      TableIdentifier tableId, long lowerInclusive, long upperInclusive) {
+    org.apache.beam.sdk.schemas.Schema pkSchema =
+        
org.apache.beam.sdk.schemas.Schema.builder().addInt64Field("id").build();
+    return ChangelogDescriptor.builder()
+        .setTableIdentifierString(tableId.toString())
+        .setSnapshotSequenceNumber(1)
+        .setCommitSnapshotId(1)
+        
.setOverlapLower(Row.withSchema(pkSchema).addValue(lowerInclusive).build())
+        
.setOverlapUpper(Row.withSchema(pkSchema).addValue(upperInclusive).build())
+        .build();
+  }
+
+  private static Record record(long id, String visible, String hidden) {
+    GenericRecord record = GenericRecord.create(CDC_SCHEMA);
+    record.setField("id", id);
+    record.setField("visible", visible);
+    record.setField("hidden", hidden);
+    return record;
+  }
+
+  private static Record fixedRecord(long id, String visible, byte[] data) {
+    GenericRecord record = GenericRecord.create(FIXED_CDC_SCHEMA);
+    record.setField("id", id);
+    record.setField("visible", visible);
+    record.setField("data", data);
+    return record;
+  }
+
+  private static SerializableChangelogTask task(
+      SerializableChangelogTask.Type type, DataFile dataFile, Table table, 
long snapshotId) {
+    return SerializableChangelogTask.builder()
+        .setType(type)
+        .setDataFile(dataFile, 
table.spec().partitionToPath(dataFile.partition()), true)
+        .setAddedDeletes(ImmutableList.of())
+        .setExistingDeletes(ImmutableList.of())
+        .setSpecId(table.spec().specId())
+        .setOperation(
+            type == SerializableChangelogTask.Type.ADDED_ROWS
+                ? ChangelogOperation.INSERT
+                : ChangelogOperation.DELETE)
+        .setOrdinal(0)
+        .setCommitSnapshotId(snapshotId)
+        .setStart(0L)
+        .setLength(dataFile.fileSizeInBytes())
+        .setJsonExpression(ExpressionParser.toJson(Expressions.alwaysTrue()))
+        .build();
+  }
+
+  private static Map<String, String> tableProperties() {
+    return ImmutableMap.of(TableProperties.FORMAT_VERSION, "2");
+  }
+
+  private static String kindAndProjectedRow(ValueInSingleWindow<Row> value) {
+    ValueKind kind = value.getValueKind();
+    Row row = value.getValue();
+    return kind.name()
+        + ":"
+        + row.getInt64("id")
+        + ":"
+        + row.getString("visible")
+        + ":"
+        + row.getSchema().getFieldCount();
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/OverlapRangeTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/OverlapRangeTest.java
new file mode 100644
index 00000000000..69e47182e17
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/OverlapRangeTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import org.apache.beam.sdk.io.iceberg.IcebergCatalogConfig;
+import org.apache.beam.sdk.io.iceberg.IcebergScanConfig;
+import org.apache.beam.sdk.io.iceberg.IcebergUtils;
+import org.apache.beam.sdk.io.iceberg.TestDataWarehouse;
+import org.apache.beam.sdk.values.Row;
+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.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.StructLike;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.types.Types;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.rules.TestName;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link OverlapRange}. */
+@RunWith(JUnit4.class)
+public class OverlapRangeTest {
+  private static final org.apache.iceberg.Schema SINGLE_PK_SCHEMA =
+      new org.apache.iceberg.Schema(
+          ImmutableList.of(
+              Types.NestedField.required(1, "id", Types.IntegerType.get()),
+              Types.NestedField.optional(2, "data", Types.StringType.get())),
+          ImmutableSet.of(1));
+
+  private static final org.apache.iceberg.Schema COMPOSITE_PK_SCHEMA =
+      new org.apache.iceberg.Schema(
+          ImmutableList.of(
+              Types.NestedField.optional(3, "data", Types.StringType.get()),
+              Types.NestedField.required(1, "account", Types.StringType.get()),
+              Types.NestedField.optional(4, "extra", Types.IntegerType.get()),
+              Types.NestedField.required(2, "sequence", 
Types.IntegerType.get())),
+          ImmutableSet.of(1, 2));
+
+  @ClassRule public static final TemporaryFolder TEMPORARY_FOLDER = new 
TemporaryFolder();
+  @Rule public TestDataWarehouse warehouse = new 
TestDataWarehouse(TEMPORARY_FOLDER, "default");
+  @Rule public final TestName testName = new TestName();
+
+  @Test
+  public void containsUsesInclusiveSingleColumnBounds() throws Exception {
+    OverlapRange range = overlapRange(SINGLE_PK_SCHEMA);
+    StructLike lower = range.toStructLike(pkRow(range.recordIdSchema(), 10));
+    StructLike upper = range.toStructLike(pkRow(range.recordIdSchema(), 20));
+
+    assertFalse(range.contains(singlePkRecord(9), lower, upper));
+    assertTrue(range.contains(singlePkRecord(10), lower, upper));
+    assertTrue(range.contains(singlePkRecord(15), lower, upper));
+    assertTrue(range.contains(singlePkRecord(20), lower, upper));
+    assertFalse(range.contains(singlePkRecord(21), lower, upper));
+  }
+
+  @Test
+  public void containsUsesLexicographicCompositeBounds() throws Exception {
+    OverlapRange range = overlapRange(COMPOSITE_PK_SCHEMA);
+    StructLike lower = range.toStructLike(pkRow(range.recordIdSchema(), "a", 
2));
+    StructLike upper = range.toStructLike(pkRow(range.recordIdSchema(), "b", 
1));
+
+    assertFalse(range.contains(compositePkRecord("a", 1), lower, upper));
+    assertTrue(range.contains(compositePkRecord("a", 2), lower, upper));
+    assertTrue(range.contains(compositePkRecord("a", 9), lower, upper));
+    assertTrue(range.contains(compositePkRecord("b", 0), lower, upper));
+    assertTrue(range.contains(compositePkRecord("b", 1), lower, upper));
+    assertFalse(range.contains(compositePkRecord("b", 2), lower, upper));
+  }
+
+  @Test
+  public void nullBoundsAreConservative() throws Exception {
+    OverlapRange range = overlapRange(SINGLE_PK_SCHEMA);
+    StructLike lower = range.toStructLike(pkRow(range.recordIdSchema(), 10));
+    StructLike upper = range.toStructLike(pkRow(range.recordIdSchema(), 20));
+
+    assertNull(range.toStructLike(null));
+    assertTrue(range.contains(singlePkRecord(1), null, upper));
+    assertTrue(range.contains(singlePkRecord(100), lower, null));
+    assertTrue(range.contains(singlePkRecord(100), null, null));
+  }
+
+  @Test
+  public void recordIdProjectionUsesIdentifierFieldsFromFullRecord() throws 
Exception {
+    OverlapRange range = overlapRange(COMPOSITE_PK_SCHEMA);
+    StructLike lower = range.toStructLike(pkRow(range.recordIdSchema(), 
"acct", 7));
+    StructLike upper = range.toStructLike(pkRow(range.recordIdSchema(), 
"acct", 7));
+
+    assertTrue(range.contains(compositePkRecord("acct", 7), lower, upper));
+
+    assertEquals("acct", range.recordIdProjection().get(0, String.class));
+    assertEquals(7, (int) range.recordIdProjection().get(1, Integer.class));
+  }
+
+  private OverlapRange overlapRange(org.apache.iceberg.Schema schema) throws 
IOException {
+    TableIdentifier tableId = TableIdentifier.of("default", 
testName.getMethodName());
+    IcebergCatalogConfig catalogConfig =
+        IcebergCatalogConfig.builder()
+            .setCatalogProperties(
+                ImmutableMap.of("type", "hadoop", "warehouse", 
warehouse.location))
+            .build();
+    catalogConfig.catalog().createTable(tableId, schema);
+    IcebergScanConfig scanConfig =
+        IcebergScanConfig.builder()
+            .setCatalogConfig(catalogConfig)
+            .setTableIdentifier(tableId)
+            .setSchema(IcebergUtils.icebergSchemaToBeamSchema(schema))
+            .setUseCdc(true)
+            .build();
+    return OverlapRange.forScanConfig(scanConfig);
+  }
+
+  private static Row pkRow(Schema recordIdSchema, Object... values) {
+    return 
Row.withSchema(IcebergUtils.icebergSchemaToBeamSchema(recordIdSchema))
+        .addValues(values)
+        .build();
+  }
+
+  private static Record singlePkRecord(int id) {
+    GenericRecord record = GenericRecord.create(SINGLE_PK_SCHEMA);
+    record.setField("id", id);
+    record.setField("data", "v" + id);
+    return record;
+  }
+
+  private static Record compositePkRecord(String account, int sequence) {
+    GenericRecord record = GenericRecord.create(COMPOSITE_PK_SCHEMA);
+    record.setField("data", "payload");
+    record.setField("account", account);
+    record.setField("extra", 100);
+    record.setField("sequence", sequence);
+    return record;
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ReadFromChangelogsTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ReadFromChangelogsTest.java
new file mode 100644
index 00000000000..69591e6eaa7
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ReadFromChangelogsTest.java
@@ -0,0 +1,366 @@
+/*
+ * 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;
+
+import static org.junit.Assert.assertEquals;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.beam.sdk.coders.KvCoder;
+import org.apache.beam.sdk.io.iceberg.IcebergCatalogConfig;
+import org.apache.beam.sdk.io.iceberg.IcebergScanConfig;
+import org.apache.beam.sdk.io.iceberg.IcebergUtils;
+import org.apache.beam.sdk.io.iceberg.SerializableDeleteFile;
+import org.apache.beam.sdk.io.iceberg.TestDataWarehouse;
+import org.apache.beam.sdk.schemas.Schema;
+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.values.KV;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionTuple;
+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.ImmutableMap;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
+import org.apache.iceberg.ChangelogOperation;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.GenericAppenderFactory;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.deletes.PositionDelete;
+import org.apache.iceberg.deletes.PositionDeleteWriter;
+import org.apache.iceberg.encryption.EncryptedFiles;
+import org.apache.iceberg.expressions.ExpressionParser;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.types.TypeUtil;
+import org.apache.iceberg.types.Types;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.rules.TestName;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link ReadFromChangelogs}. */
+@RunWith(JUnit4.class)
+public class ReadFromChangelogsTest {
+  private static final org.apache.iceberg.Schema CDC_SCHEMA =
+      new org.apache.iceberg.Schema(
+          ImmutableList.of(
+              Types.NestedField.required(1, "id", Types.LongType.get()),
+              Types.NestedField.optional(2, "visible", Types.StringType.get()),
+              Types.NestedField.optional(3, "hidden", Types.StringType.get())),
+          ImmutableSet.of(1));
+
+  @ClassRule public static final TemporaryFolder TEMPORARY_FOLDER = new 
TemporaryFolder();
+
+  @Rule public TestDataWarehouse warehouse = new 
TestDataWarehouse(TEMPORARY_FOLDER, "default");
+  @Rule public TestName testName = new TestName();
+  @Rule public TestPipeline pipeline = TestPipeline.create();
+
+  @Test
+  public void unidirectionalTasksEmitProjectedRowsOnly() throws IOException {
+    TableIdentifier tableId = tableId();
+    Table table = warehouse.createTable(tableId, CDC_SCHEMA, null, 
tableProperties());
+    IcebergScanConfig scanConfig = scanConfig(table, tableId, 
ImmutableList.of("id", "visible"));
+
+    DataFile addedFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-added.parquet",
+            table.schema(),
+            ImmutableList.of(record(10L, "added", "added-hidden")));
+    DataFile deletedRowsFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-deleted-rows.parquet",
+            table.schema(),
+            ImmutableList.of(
+                record(20L, "deleted-row", "deleted-row-hidden"),
+                record(21L, "not-deleted", "not-deleted-hidden")));
+    DeleteFile addedPositionDelete =
+        writePositionDelete(table, deletedRowsFile, 
"deleted-rows-pos-delete.parquet", 0L);
+    DataFile deletedFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-deleted-file.parquet",
+            table.schema(),
+            ImmutableList.of(record(30L, "deleted-file", 
"deleted-file-hidden")));
+
+    List<SerializableChangelogTask> tasks =
+        ImmutableList.of(
+            task(
+                SerializableChangelogTask.Type.ADDED_ROWS,
+                addedFile,
+                ImmutableList.of(),
+                ImmutableList.of(),
+                table,
+                100L),
+            task(
+                SerializableChangelogTask.Type.DELETED_ROWS,
+                deletedRowsFile,
+                ImmutableList.of(addedPositionDelete),
+                ImmutableList.of(),
+                table,
+                100L),
+            task(
+                SerializableChangelogTask.Type.DELETED_FILE,
+                deletedFile,
+                ImmutableList.of(),
+                ImmutableList.of(),
+                table,
+                100L));
+
+    ReadFromChangelogs.Output output =
+        input(ImmutableList.of(KV.of(descriptor(), tasks)), ImmutableList.of())
+            .apply(new ReadFromChangelogs(scanConfig));
+
+    assertEquals(
+        
IcebergUtils.icebergSchemaToBeamSchema(scanConfig.getProjectedSchema()),
+        output.uniDirectionalRows().getSchema());
+    PAssert.that(
+            output.uniDirectionalRows().apply("Format Unidirectional", 
ParDo.of(new FormatRow())))
+        .containsInAnyOrder(
+            "INSERT:10:added:2", "DELETE:20:deleted-row:2", 
"DELETE:30:deleted-file:2");
+    PAssert.that(output.biDirectionalInserts()).empty();
+    PAssert.that(output.biDirectionalDeletes()).empty();
+
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void bidirectionalTasksKeepFullRowsForDownstreamResolution() throws 
IOException {
+    TableIdentifier tableId = tableId();
+    Table table = warehouse.createTable(tableId, CDC_SCHEMA, null, 
tableProperties());
+    IcebergScanConfig scanConfig = scanConfig(table, tableId, 
ImmutableList.of("id", "visible"));
+    DataFile oldFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-old.parquet",
+            table.schema(),
+            ImmutableList.of(record(1L, "shown", "old-hidden")));
+    DataFile newFile =
+        warehouse.writeRecords(
+            testName.getMethodName() + "-new.parquet",
+            table.schema(),
+            ImmutableList.of(record(1L, "shown", "new-hidden")));
+    List<SerializableChangelogTask> tasks =
+        ImmutableList.of(
+            task(
+                SerializableChangelogTask.Type.DELETED_FILE,
+                oldFile,
+                ImmutableList.of(),
+                ImmutableList.of(),
+                table,
+                200L),
+            task(
+                SerializableChangelogTask.Type.ADDED_ROWS,
+                newFile,
+                ImmutableList.of(),
+                ImmutableList.of(),
+                table,
+                200L));
+
+    ReadFromChangelogs.Output output =
+        input(ImmutableList.of(), ImmutableList.of(KV.of(descriptor(200L, 
200L, 1L, 1L), tasks)))
+            .apply(new ReadFromChangelogs(scanConfig));
+
+    PAssert.that(output.uniDirectionalRows()).empty();
+    PAssert.that(
+            output.biDirectionalDeletes().apply("Format Deletes", ParDo.of(new 
FormatKeyedRow())))
+        .containsInAnyOrder("DELETE:200:200:1:shown:old-hidden:3");
+    PAssert.that(
+            output.biDirectionalInserts().apply("Format Inserts", ParDo.of(new 
FormatKeyedRow())))
+        .containsInAnyOrder("INSERT:200:200:1:shown:new-hidden:3");
+
+    pipeline.run().waitUntilFinish();
+  }
+
+  private PCollectionTuple input(
+      List<KV<ChangelogDescriptor, List<SerializableChangelogTask>>> 
unidirectional,
+      List<KV<ChangelogDescriptor, List<SerializableChangelogTask>>> 
largeBidirectional) {
+    Schema rowIdBeamSchema =
+        IcebergUtils.icebergSchemaToBeamSchema(
+            TypeUtil.select(CDC_SCHEMA, CDC_SCHEMA.identifierFieldIds()));
+    KvCoder<ChangelogDescriptor, List<SerializableChangelogTask>> coder =
+        ChangelogScanner.coder(rowIdBeamSchema);
+    PCollection<KV<ChangelogDescriptor, List<SerializableChangelogTask>>> uni =
+        unidirectional.isEmpty()
+            ? pipeline.apply("Empty Unidirectional", Create.empty(coder))
+            : pipeline.apply("Create Unidirectional", 
Create.of(unidirectional).withCoder(coder));
+    PCollection<KV<ChangelogDescriptor, List<SerializableChangelogTask>>> 
large =
+        largeBidirectional.isEmpty()
+            ? pipeline.apply("Empty Large Bidirectional", Create.empty(coder))
+            : pipeline.apply(
+                "Create Large Bidirectional", 
Create.of(largeBidirectional).withCoder(coder));
+    return PCollectionTuple.of(ChangelogScanner.UNIDIRECTIONAL_TASKS, uni)
+        .and(ChangelogScanner.LARGE_BIDIRECTIONAL_TASKS, large);
+  }
+
+  private TableIdentifier tableId() {
+    return TableIdentifier.of("default", testName.getMethodName());
+  }
+
+  private IcebergScanConfig scanConfig(
+      Table table, TableIdentifier tableId, List<String> keepFields) {
+    return IcebergScanConfig.builder()
+        .setCatalogConfig(
+            IcebergCatalogConfig.builder()
+                .setCatalogName("name")
+                .setCatalogProperties(
+                    ImmutableMap.of("type", "hadoop", "warehouse", 
warehouse.location))
+                .build())
+        .setTableIdentifier(tableId)
+        .setSchema(IcebergUtils.icebergSchemaToBeamSchema(table.schema()))
+        .setKeepFields(keepFields)
+        .setUseCdc(true)
+        .build();
+  }
+
+  private ChangelogDescriptor descriptor() {
+    return ChangelogDescriptor.builder()
+        .setTableIdentifierString(tableId().toString())
+        .setSnapshotSequenceNumber(100L)
+        .setCommitSnapshotId(100L)
+        .build();
+  }
+
+  private ChangelogDescriptor descriptor(
+      long sequenceNumber, long snapshotId, long lowerInclusive, long 
upperInclusive) {
+    Schema pkSchema = Schema.builder().addInt64Field("id").build();
+    return ChangelogDescriptor.builder()
+        .setTableIdentifierString(tableId().toString())
+        .setSnapshotSequenceNumber(sequenceNumber)
+        .setCommitSnapshotId(snapshotId)
+        
.setOverlapLower(Row.withSchema(pkSchema).addValue(lowerInclusive).build())
+        
.setOverlapUpper(Row.withSchema(pkSchema).addValue(upperInclusive).build())
+        .build();
+  }
+
+  private static Record record(long id, String visible, String hidden) {
+    GenericRecord record = GenericRecord.create(CDC_SCHEMA);
+    record.setField("id", id);
+    record.setField("visible", visible);
+    record.setField("hidden", hidden);
+    return record;
+  }
+
+  private static DeleteFile writePositionDelete(
+      Table table, DataFile dataFile, String filename, long... positions) 
throws IOException {
+    GenericAppenderFactory appenderFactory =
+        new GenericAppenderFactory(table.schema(), table.spec());
+    PositionDeleteWriter<Record> writer =
+        appenderFactory.newPosDeleteWriter(
+            EncryptedFiles.plainAsEncryptedOutput(
+                table.io().newOutputFile(dataFile.location() + "." + 
filename)),
+            FileFormat.PARQUET,
+            null);
+    try {
+      for (long position : positions) {
+        writer.write(PositionDelete.<Record>create().set(dataFile.location(), 
position));
+      }
+    } finally {
+      writer.close();
+    }
+    return writer.toDeleteFile();
+  }
+
+  private static SerializableChangelogTask task(
+      SerializableChangelogTask.Type type,
+      DataFile dataFile,
+      List<DeleteFile> addedDeletes,
+      List<DeleteFile> existingDeletes,
+      Table table,
+      long snapshotId) {
+    return SerializableChangelogTask.builder()
+        .setType(type)
+        .setDataFile(dataFile, 
table.spec().partitionToPath(dataFile.partition()), true)
+        .setAddedDeletes(serializableDeletes(addedDeletes, table))
+        .setExistingDeletes(serializableDeletes(existingDeletes, table))
+        .setSpecId(table.spec().specId())
+        .setOperation(
+            type == SerializableChangelogTask.Type.ADDED_ROWS
+                ? ChangelogOperation.INSERT
+                : ChangelogOperation.DELETE)
+        .setOrdinal(0)
+        .setCommitSnapshotId(snapshotId)
+        .setStart(0L)
+        .setLength(dataFile.fileSizeInBytes())
+        .setJsonExpression(ExpressionParser.toJson(Expressions.alwaysTrue()))
+        .build();
+  }
+
+  private static List<SerializableDeleteFile> serializableDeletes(
+      List<DeleteFile> deletes, Table table) {
+    return deletes.stream()
+        .map(
+            delete ->
+                SerializableDeleteFile.from(
+                    delete, table.spec().partitionToPath(delete.partition()), 
true))
+        .collect(Collectors.toList());
+  }
+
+  private static Map<String, String> tableProperties() {
+    return ImmutableMap.of(TableProperties.FORMAT_VERSION, "2");
+  }
+
+  private static class FormatRow extends DoFn<Row, String> {
+    @ProcessElement
+    public void process(@Element Row row, ValueKind kind, 
OutputReceiver<String> out) {
+      out.output(
+          kind.name()
+              + ":"
+              + row.getInt64("id")
+              + ":"
+              + row.getString("visible")
+              + ":"
+              + row.getSchema().getFieldCount());
+    }
+  }
+
+  private static class FormatKeyedRow extends DoFn<KV<CdcRowDescriptor, Row>, 
String> {
+    @ProcessElement
+    public void process(
+        @Element KV<CdcRowDescriptor, Row> element, ValueKind kind, 
OutputReceiver<String> out) {
+      Row row = element.getValue();
+      CdcRowDescriptor descriptor = element.getKey();
+      out.output(
+          kind.name()
+              + ":"
+              + descriptor.getCommitSnapshotId()
+              + ":"
+              + descriptor.getSnapshotSequenceNumber()
+              + ":"
+              + descriptor.getPrimaryKey().getInt64("id")
+              + ":"
+              + row.getString("visible")
+              + ":"
+              + row.getString("hidden")
+              + ":"
+              + row.getSchema().getFieldCount());
+    }
+  }
+}

Reply via email to