Repository navigation
Core, Flink: Share the inserted-row tracker across DynamicIcebergSink writers of one table - #17
Conversation
…rs of one table A schema evolution mid-checkpoint makes DynamicWriter open a second TaskWriter for the same table, keyed on the new schema ID, with an empty inserted-row map. In upsert mode its re-write of a key the first writer already wrote in that checkpoint falls through to an equality delete, which is committed at the same sequence number as its target and can never apply, leaving both versions of the key visible until compaction makes the duplicate permanent. Give all writers of one table (WriteTarget minus schemaId), per partition, one shared inserted-row tracker for the checkpoint so the second writer's delete becomes a position delete against the first writer's data file. Core gains BaseTaskWriter.InsertedRowTracker and a BaseEqualityDeltaWriter constructor that accepts an externally owned tracker; the Flink delta writers thread a tracker resolver from RowDataTaskWriterFactory, and DynamicWriter owns the trackers and clears them with writers.clear(). Writers whose equality-key types differ keep a private tracker and warn. Applied to flink/v1.20, v2.0 and v2.1. Upstream: apache#18262. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Only the Flink 2.0 module is consumed from this fork; the v1.20 and v2.1 copies added no coverage for a build that is never published. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…them per table Types.StructType.equals also compares field names and docs, and the dynamic sink evolves column docs on its own, so a doc change on a key column would have disabled tracker sharing and re-opened the duplicate-row bug. Accept a key type when field IDs, types and optionality match by position, which is what StructLikeMap compares. Record the key type once per table scope so a promoted identity-partition key is detected before the per-partition lookup, keep the factory's tracker resolver out of serialization, and clear trackers in a finally block on close. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
a0d5a21 to
b632129
Compare
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Cover the paths the first tests left out: a row delete (CDC UPDATE_BEFORE or DELETE) through another writer; key-type compatibility by field ID, type, optionality and field count, ignoring names, docs and defaults; a factory resolver shared by two task writers; a deserialized factory, whose transient resolver is gone, falling back to private trackers; a resolver tracker of another key type being ignored instead of failing the writer; CDC updates across a mid-checkpoint schema evolution without upsert mode; trackers being dropped at prepareCommit and close; tracker scope per table; and WriteTarget#withoutSchemaId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop BaseDeltaTaskWriter's old constructor: after the shared-tracker change both subclasses use the constructor that takes a tracker resolver, so the old one had no callers. In the core test, build writers without a shared tracker through the pre-existing BaseEqualityDeltaWriter constructor, and close a writer twice to cover the already-closed path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rayhondo
left a comment
There was a problem hiding this comment.
I'm not approving yet. Mostly I want to confirm the Flink 2.1 point below. The fix itself looks correct to me.
To confirm before approving
The fix only lands in flink/v2.0. The flink/v1.20 and flink/v2.1 copies keep the old behaviour. all-the-things master is on Flink 2.0.1 everywhere today, and mysql-replication-core resolves iceberg-flink-2.0. I vaguely remember earlier talk about moving streaming to Flink 2.1, though. @abrarsher23, did you check with the streaming folks whether we should patch flink/v2.1 as well? Can you confirm we don't need 2.1 any time soon? If we do, this should be ported before the upgrade, or the duplicate-row bug comes back.
Non-blocking
-
warnIfSplitsTablebuilds awithoutSchemaId()copy of every open writer's target each time a new writer is created, so the cost per checkpoint grows quadratically with the number of open writers. It's cheap at our scale, but caching the scope key per writer would avoid the extra allocations.
-
TableTrackers.acceptsKeyTypereturns true whilebyPartitionis empty, so the first partition's tracker decides the key type for the table. The per-tracker check inBaseDeltaTaskWriterfalls back to a private tracker, so the result stays correct. It does mean sharing can be lost without anything in the log beyond the fallback WARN.
Applies the flink/v2.0 change to flink/v2.1, so both published Flink modules retire a re-written key with a position delete when a schema change opens a second writer for a table within one checkpoint. The touched files were identical in the two modules, so the diff is the same. flink/v1.20 is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DynamicWriter.warnIfSplitsTable copied the target of every open writer with withoutSchemaId() each time a new writer was created. The new WriteTarget.sameScopeAs compares the same fields directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @rayhondo. Flink 2.1: we don't need 2.1 right now, but the release publishes Non-blocking:
(The full-suite and coverage numbers above are from 62edc85. 102990c only changes the log text; the targeted tests pass on it in both modules.) |
The WARN logged when a new writer cannot share the inserted-row tracker blamed a type promotion or a rename. Renames do share: key types are compared by position, field ID, optionality and type, not by name. The message now names what can actually differ: an equality field's type, optionality or position. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The approach looks correct to me, and nothing blocking from my side. I'd still like Peter (@pvary) and Max (@mxm) to look at it because they're the Flink experts, and apache#18288 is the natural place for that review to happen. Your version is the more complete of the two: it covers CDC as well as upsert, keeps one tracker per partition, and compares key types structurally rather than through |
|
LGTM. The failing checks are the MinIO image pull failure; porting apache#18247 should fix them. I'd like the upstream fix in apache#18288 to converge on this version so the fork can drop the patch later. |
Summary
Problem. In the Flink
DynamicIcebergSink, a table's schema can change in the middle of a checkpoint, for example when a column is added. If a row written before the change is updated or deleted after it, the table can end up with two visible copies of that row. This affects tables written with equality fields, in both upsert and CDC mode. Upstream issue: apache/iceberg#18262.Fix. All writers of a table now share one in-memory record of the rows written in the current checkpoint, the inserted-row tracker. The writer opened after the schema change finds the earlier row and removes it with a position delete, the same way a single writer already does.
Scope. The change is in
coreand in the two Flink modules this fork publishes,flink/v2.0andflink/v2.1, which get the same change.flink/v1.20is unchanged. A follow-up stacked on this PR, #20 (open), handles key type changes.Why the duplicate happens
id=7as row 0 of file A and records it in its tracker.id=7goes to B. B's tracker is empty, so B writes an equality delete forid=7.id=7.With a shared tracker, B finds
id=7in file A and writes a position delete for row 0 of file A. A position delete does apply within the same commit.A later update or delete of the key, in a later checkpoint, removes both copies. Snapshots and tags taken in between still show both.
What changes
core/.../io/BaseTaskWriter.javaInsertedRowTracker, and aBaseEqualityDeltaWriterconstructor that takes a shared tracker. A writer only clears a tracker it created. The API change is additive, andrevapiis clean.flink/v2.{0,1}/.../sink/RowDataTaskWriterFactory.java,BaseDeltaTaskWriter.java,PartitionedDeltaWriter.java,UnpartitionedDeltaWriter.javaBaseDeltaTaskWriter's old constructor is removed because it had no callers left.flink/v2.{0,1}/.../sink/dynamic/DynamicWriter.javaflink/v2.{0,1}/.../sink/dynamic/WriteTarget.javawithoutSchemaId()defines the sharing scope: table, branch, partition spec, upsert mode and equality fields.sameScopeAs()compares two scopes without copying.TestTaskEqualityDeltaWriter,TestDeltaTaskWriter,TestDynamicWriter,TestWriteTargetWriters share a tracker only when their key types match: the same field IDs, types and optionality, compared position by position. Renaming a key column or changing its doc still shares.
Not fixed in this PR
All of these happen within one checkpoint.
int→long,float→double)Not sharing inserted-row tracker …. Addressed in #20 (open).intkeys orfloatkeys go to different writer subtasksNot sharing inserted-row tracker ….identityortruncatepartition column is promotedRisk
DynamicWriterinstead of the writer.RowDataTaskWriterFactoryandBaseTaskWriterare unaffected: the non-dynamic Flink sink, Spark and Kafka Connect.Rollout and rollback
version.txt,AFFIRM_VERSIONS.md).1.11.0-affirm-patch.3and publish.1.11.0-affirm-patch.2. Position deletes are standard Iceberg files that every reader applies, so there is nothing to migrate.How to tell it's working
Opening another writer for table … Re-writes of a key across the schema change are retired with position deletes while the writers share an inserted-row tracker. Core, Flink: Share inserted-row trackers across equality key promotions in the dynamic sink #20 replaces this with an INFO that is logged only when sharing actually happens.Not sharing inserted-row tracker ….Testing
Unit tests (JDK 21), all passing:
iceberg-coreiceberg-dataiceberg-flink-2.0iceberg-flink-2.1The skipped tests were skipped before this change too.
coreanddataran at1921606d2, and the Flink suites at62edc85be. The later commits touch only the Flink modules, and the last one,102990cf0, only changes a log message.revapiandspotlessCheckare clean when run locally; this PR's CI doesn't run revapi.Coverage: JaCoCo shows every changed executable line (141 of 141) and every changed branch (62 of 62) exercised, for both Flink modules.
The tests catch the bug: giving each writer its own tracker again, as before this PR, makes 7 of the 19
TestDynamicWritertests fail, in both Flink modules.End to end (Flink 2.0): the real writer fed by a live database change stream. In one checkpoint, a row was inserted, a column added, and the same row updated.
New tests by area
UPDATE_BEFORE/DELETE) works through another writer. A tracker of a different key type is rejected. Writers without a shared tracker use the existing constructor unchanged.RowDataTaskWriterFactoryDynamicWriter, one checkpoint with a schema changeDELETEthrough the second writer. CDC update without upsert mode. Equality-field type promotion, which falls back without failing.DynamicWriter, tracker lifetimeprepareCommitandclose, so the next checkpoint uses an equality delete as before. Trackers are per table.WriteTargetwithoutSchemaId()equality.sameScopeAs()agrees with it for every pair of targets.Worth knowing
The failing CI checks are unrelated to this change. The base branch's own CI has failed the same way since 09-25, and so did Release 1.11.0-affirm-patch.2 #15, the previous PR into this branch:
core-testsnever finishes. The S3 tests in:iceberg-aws:testcan't start their MinIO container, so the job runs until GitHub stops it at about 6 hours, which shows as cancelled or failed.kafka-connect-testsfails or is cancelled. Its integration tests can't start their Docker Compose setup.This PR doesn't touch any of that code.
TestTaskEqualityDeltaWriterpasses insidecore-testsbefore the job is cancelled, and the Flink, Spark and Hive checks pass.apache/iceberg#18288 is an open upstream PR for the same issue with the same approach. I've commented there on two differences: it compares key names and docs, and it shares only in upsert mode.