[Iceberg] CDC config and writer - #39997
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The current CdcWriteConfig defaults/validation can produce invalid configs (notably around shards-per-partition defaulting) and there are a couple of robustness/determinism issues that should be addressed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR introduces core components for an Iceberg CDC sink in the Java SDK: a delta task writer that consumes sorted CDC records and collapses per-key changes into at most one equality delete plus one final row per window, and a corresponding CdcWriteConfig configuration object. It also adds targeted tests validating both the writer’s flush/partition-routing behavior and the config’s validation/serialization behavior.
Changes:
- Added
RecordDeltaTaskWriterto collapse sorted CDC streams into Iceberg row-delta writes (equality deletes + final rows) without DV/position deletes. - Added
CdcWriteConfig(AutoValue) with validation for CDC sink options. - Added comprehensive unit tests for writer behavior and config validation/serialization.
File summaries
| File | Description |
|---|---|
| sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/RecordDeltaTaskWriter.java | Adds the delta writer that collapses CDC changes per PK and writes equality deletes + final rows, including partition fanout support. |
| sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/CdcWriteConfig.java | Adds sink configuration with defaults and validation for CDC-specific options. |
| sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/sink/RecordDeltaTaskWriterTest.java | Adds a flush “truth table” and partition-routing tests validating the writer’s semantics. |
| sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/sink/CdcWriteConfigTest.java | Adds tests covering defaults, validation rejection matrix, and Java serialization of the config. |
Review details
Suppressed comments (3)
sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/CdcWriteConfig.java:135
- Given the intended semantics ("unset shards_per_partition resolves to num_shards"), setting
shards_per_partitionto a fixed default inbuilder()prevents that and can causevalidate()to fail whennum_shardsis changed. With a resolved getter, this default should be removed so the value can remain unset.
static Builder builder() {
return new AutoValue_CdcWriteConfig.Builder()
.setSequenceNumberColumn(DEFAULT_SEQUENCE_NUMBER_COLUMN)
.setNumShards(DEFAULT_NUM_SHARDS)
.setShardsPerPartition(DEFAULT_NUM_SHARDS)
.setSorterMemoryMB(DEFAULT_SORTER_MEMORY_MB)
.setUpsert(false)
.setErrorHandling(false);
sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/CdcWriteConfig.java:188
validate()will throw a NullPointerException ifsnapshot_propertiescontains a null key (due tokey.startsWith(...)). Consider rejecting null keys explicitly so invalid user input fails with a clear IllegalArgumentException.
@Nullable Map<String, String> snapshotProperties = getSnapshotProperties();
if (snapshotProperties != null) {
for (String key : snapshotProperties.keySet()) {
checkArgument(
!key.startsWith("beam.cdc."),
"snapshot_properties key '%s' uses the reserved 'beam.cdc.' prefix; choose a "
+ "different key.",
key);
sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/CdcWriteConfig.java:216
- After introducing a resolved
getShardsPerPartition()method, the AutoValue property should be a nullable "override" (unset vs explicitly set). The builder currently only exposessetShardsPerPartition(int), which can't represent "unset"; adding an override setter keeps the external API while enabling correct defaulting behavior.
abstract Builder setNumShards(int numShards);
abstract Builder setShardsPerPartition(int shardsPerPartition);
- Files reviewed: 4/4 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…delta-writer-and-config
…delta-writer-and-config
| * Columns that define a row's identity (the Iceberg equality-delete fields). If unspecified, will | ||
| * try to use the destination table's identifier fields. | ||
| */ | ||
| abstract @Nullable List<String> getEqualityColumns(); |
There was a problem hiding this comment.
Is this the same as identifier fields (primary-key fields) ? I guess the difference is whether you are talking about the table in general or a specific delete file. If it's the prior probably we should name accordingly.
There was a problem hiding this comment.
"primary key" isn't in the Iceberg vocabulary, although the concept is similar.
"Equality columns" is a write property. It defaults to the table's "identifier fields" by default, so it's similar in that sense, but it can be overridden for a given write operation.
I'd rather keep it this way, it matches Spark and Flink too. Users will be familiar with it
| * If set, the change kind is read from this string column instead of the element's native {@link | ||
| * ValueKind}. The column is stripped from the data row and never written to Iceberg. | ||
| */ | ||
| abstract @Nullable String getChangeTypeColumn(); |
There was a problem hiding this comment.
Probably mention what values this column can include.
| /** | ||
| * If {@code true}, {@code UPDATE_BEFORE} records are dropped and {@code INSERT}/{@code | ||
| * UPDATE_AFTER} are applied as upserts (equality-delete-then-insert on the primary key). Defaults | ||
| * to {@code false}. |
There was a problem hiding this comment.
Also document the behavior if false.
| abstract @Nullable Map<String, String> getSnapshotProperties(); | ||
|
|
||
| /** | ||
| * If {@code true}, a poison record (unknown change type, missing/null sequence number, null |
There was a problem hiding this comment.
Is "poison record" the correct term ? To me this sounds like valid data regarding errors that occurred.
There was a problem hiding this comment.
The mentioned error-cases wouldn't be considered valid for this sink
| @Nullable List<String> equalityColumns = getEqualityColumns(); | ||
| checkArgument( | ||
| equalityColumns == null || !equalityColumns.isEmpty(), | ||
| "equality_columns must be non-empty or unset (leave unset to use the table's identifier " |
There was a problem hiding this comment.
Ah, seems like we just default to identifier fields ? Probably we should just document this in 'getEqualityColumns' call above.
There was a problem hiding this comment.
Already documented in getEqualityColumns: "If unspecified, will try to use the destination table's identifier fields"
|
|
||
| /** Closes every file and deletes it: a failed group must leave nothing behind. */ | ||
| public void abort() throws IOException { | ||
| close(); |
There was a problem hiding this comment.
Seems like there might be a leak here if the close() invocation throws an exception ?
Let's also add a test for this.
| // The shared partitionKey is mutated on every route() call; copy before keying the map. | ||
| PartitionKey copiedKey = partitionKey.copy(); | ||
| writer = newPartitionWriter(copiedKey); | ||
| writers.put(copiedKey, writer); |
There was a problem hiding this comment.
Probably this can cause an OOM if a bundle includes many primary keys and we hold that many writes in memory ?
There was a problem hiding this comment.
We create one writer per partition, not per primary key. There is a risk for high partition fanout, but it's much less severe and it's the same risk Flink has in its sink.
We have an advantage here because of shardsPerPartition, reducing it will limit the number of open writers per partition
| List<Types.NestedField> pkFields = deleteSchema.columns(); | ||
| this.pkPos = new int[pkFields.size()]; | ||
| List<Types.NestedField> allFields = schema.columns(); | ||
| for (int i = 0; i < pkFields.size(); i++) { |
There was a problem hiding this comment.
Nit suggestion from AI:
for (int i = 0; i < pkFields.size(); i++) {
Types.NestedField field = schema.findField(pkFields.get(i).fieldId());
if (field == null) {
throw new IllegalStateException("...");
}
this.pkPos[i] = schema.columns().indexOf(field);
}
There was a problem hiding this comment.
schema.findField searches for nested fields too. We're purposely only looking for top-level fields with schema.columns()
|
|
||
| @Rule public transient TemporaryFolder tmp = new TemporaryFolder(); | ||
|
|
||
| private static final Schema SCHEMA = |
There was a problem hiding this comment.
Also test for composite primary keys, other data types in the primary key.
|
|
||
| private static final Schema SCHEMA = | ||
| new Schema( | ||
| Types.NestedField.required(1, "id", Types.IntegerType.get()), |
There was a problem hiding this comment.
Also test for cases where the primary key is not the first field ?
|
Assigning reviewers: R: @kennknowles for label java. Note: If you would like to opt out of this review, comment Available commands:
The PR bot will only process comments in the main thread (not review comments). |
Adds the CDC sink's delta writer, which receives sorted rows (by PK, sequence number, change kind). The writer collapses changes and only writes one final state per key per window (an equality delete plus the surviving row). Because it collapses changes, it never needs to write a deletion vector.
Also adds the sink's CdcWriteConfig.
Need to merge #39981 first.
Part of #39979
Thank you for your contribution! Follow this checklist to help us incorporate your contribution quickly and easily:
addresses #123), if applicable. This will automatically add a link to the pull request in the issue. If you would like the issue to automatically close on merging the pull request, commentfixes #<ISSUE NUMBER>instead.CHANGES.mdwith noteworthy changes.See the Contributor Guide for more tips on how to make review process smoother.
To check the build health, please visit https://github.com/apache/beam/blob/master/.test-infra/BUILD_STATUS.md
GitHub Actions Tests Status (on master branch)
See CI.md for more information about GitHub Actions CI or the workflows README to see a list of phrases to trigger workflows.