Repository navigation
DynamicCommitter dedup can allow duplicate commits after Flink job restart/rescale when a prior commit succeeded on the catalog but the response was lostΒ #16008
Description
Apache Iceberg version
1.10.1 (latest release)
Query engine
Flink
Please describe the bug π
Affected component
iceberg-flink β org.apache.iceberg.flink.sink.dynamic.DynamicCommitter / DynamicWriteResultAggregator
Affected versions
Observed on main in flink/v2.0/, v2.1/, and v1.20/ (the relevant logic is identical across all three).
Summary
DynamicCommitter deduplicates commits on the triplet (flink.job-id, flink.operator-id, flink.max-committed-checkpoint-id) stored in snapshot summaries. The dedup is unsafe when:
- A commit succeeds on the catalog but the client never observes success (timeout, network partition, TM failure mid-commit).
- The Flink job is subsequently restarted with a new JobID (via
SavepointRestore, rescale, or high-availability failover).
On restart, DynamicWriteResultAggregator re-stamps restored committables with the current (new) JobID, so the ancestor walk in DynamicCommitter.getMaxCommittedCheckpointId cannot find the successfully-committed snapshot (which carries the old JobID). The committable is committed a second time β duplicate rows in the table.
Root cause
operator-id is stable, job-id is not
DynamicWriteResultAggregator.open() (v2.0 line 80-81):
this.flinkJobId = getContainingTask().getEnvironment().getJobID().toString();
this.operatorId = getOperatorID().toString();operatorIdis derived deterministically from the operator UID (murmur hash) β stable across restarts whenuidPrefixis set, which is the recommended usage.flinkJobIdis the current job's JobID β fresh on everysubmitJobcall.
Restored committables are re-stamped with the new JobID
DynamicWriteResultAggregator.prepareSnapshotPreBarrier() (v2.0 line 96-111) produces committables using the currently-captured flinkJobId. If operator state (resultsByTableKeyAndSpec) is restored from savepoint but the commit of the last checkpoint was lost, the aggregator re-emits the committable with the new JobID instead of the one under which the commit originally succeeded.
Dedup keys on job-id first
DynamicCommitter.getMaxCommittedCheckpointId (v2.0 line 184-203):
if (flinkJobId.equals(snapshotFlinkJobId)
&& (snapshotOperatorId == null || snapshotOperatorId.equals(operatorId))) {Only snapshots whose summary matches the current flinkJobId are considered. A snapshot committed by the same operator under a prior JobID is invisible β maxCommittedCheckpointId returns INITIAL_CHECKPOINT_ID (-1L) β the committable is treated
as fresh.
The CAS-time MaxCommittedCheckpointIdValidator (v2.0 line 335-357) uses the same key and does not catch this case either.
Failure sequence (end-to-end)
- Checkpoint N completes. Writer produces manifest; committable
(jobId=J1, opId=O1, ckpt=N)is delivered toDynamicCommitter. operation.commit()succeeds on the catalog (snapshot written withjob-id=J1, operator-id=O1, max-committed-checkpoint-id=N) but the client never receives confirmation (timeout, connection reset, TM process death between REST ack and state persist, etc.).- Flink treats the checkpoint as not-yet-committed. Job fails or is rescaled via savepoint.
- New job starts with
JobID=J2. State is restored; aggregator'sresultsByTableKeyAndSpeccomes back with the unacked data. - On the next
prepareSnapshotPreBarrier, aggregator emits a committable(jobId=J2, opId=O1, ckpt=N)β re-stamped with J2. - Committer ancestor walk looks for snapshots with
job-id=J2β none exist (the J1 snapshot is present but filtered out) βmaxCommittedCheckpointId = -1. MaxCommittedCheckpointIdValidatoris constructed withJ2β also does not see the J1 snapshot β no mismatch raised.- Commit proceeds. The same data files are added again. Duplicate rows.
Minimal repro (sketch)
- Run a
DynamicIcebergSinkagainst any catalog, with a stableuidPrefixand checkpointing enabled. - Produce a batch that completes checkpoint N.
- Intercept the catalog
commit()call so it returns success on the server but throws a timeout / closes the connection client-side. - Let Flink fail the job and resume from savepoint (or trigger an external savepoint + fresh
submitJob). - Observe two snapshots in the table whose summaries carry
flink.max-committed-checkpoint-id=N,flink.operator-id=O1, and differentflink.job-idvalues β both containing the same data files.
Severity
Data correctness (silent duplication). No exception surfaces; the duplication is observable only by querying the table or by inspecting snapshot summaries. Expected to be rare in most deployments, but materially more likely under:
- Slow/unreliable storage backing the catalog (our case: HDFS datanode intermittency).
- Long commit latencies relative to the Flink RPC timeout.
- Frequent rescaling via savepoint+resubmit (Flink Operator autoscaler, session-cluster multiplex patterns).
Notes
- The same pattern likely exists in the non-dynamic
IcebergFilesCommitter; we haven't fully verified, but the dedup structure appears analogous and worth reviewing together.
References
iceberg/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java:127-203, 329-400iceberg/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java:78-111iceberg/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java:146-167
Willingness to contribute
- I can contribute a fix for this bug independently
- I would be willing to contribute a fix for this bug with guidance from the Iceberg community
- I cannot contribute a fix for this bug at this time
Activity
pvary commentedon Apr 17, 2026
cc: @mxm, @jordepic