Skip to content

Flink: Fix dynamic sink HASH keying of records sent to the table updater - #18602

Merged
mxm merged 4 commits into
apache:mainfrom
zhengxiang378928908-code:flink-dynamic-sink-hash-key-17651
Oct 28, 2026
Merged

mxm merged 4 commits into
apache:mainfrom
zhengxiang378928908-code:flink-dynamic-sink-hash-key-17651

Conversation

@zhengxiang378928908-code

@zhengxiang378928908-code zhengxiang378928908-code commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

With immediateUpdate disabled, a record that needs a table update gets its writer key from the table spec (or table schema id) but its raw row, which follows the record's own schema. When field IDs or column order differ, the partition source is read from the wrong position and the job fails with a ClassCastException. The key now uses the record's own schema, spec and row, which always describe the same layout (simplified as suggested in review). Unlike #17685 this leaves the main path alone: its row is already in the table layout, so keying it with the record schema breaks reordered columns (testHashDistributionWithReorderedColumns).

Fixes #17651.

Test plan: three new TestDynamicIcebergSink cases and one TestHashKeyGenerator case. testHashDistributionWithAlternatingSchemaVariants and testHashDistributionToNewBranchWithDataConversion fail on main with the reported ClassCastException and pass with this change; testHashDistributionWithReorderedColumns passes on both and guards the main path. testCacheHitForEqualSchemaInstances checks that equal schemas passed as new instances share one key selector. The 322 tests in org.apache.iceberg.flink.sink.dynamic on flink/v2.3 run with no failures (2 skipped); spotlessCheck, checkstyleMain and checkstyleTest pass. Backports to v1.20/v2.2 can follow once this is agreed.


AI Disclosure

With immediateUpdate disabled, a record that needs a table update was keyed
with the table spec or the table schema id but with its raw row, which is laid
out by the record's own schema. When the field IDs or column order of the
record differ from the table, the partition source is read from the wrong
position and the job fails with a ClassCastException (apache#17651).

Key such records in the table's layout only when both the table schema and
spec are known, converting the row first, and otherwise with the record's own
schema, spec and row.

Generated-by: Claude Code (Claude Opus 5.5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
: data.schema(),
foundSpec != null ? foundSpec : data.spec(),
data.rowData());
keyByTable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we just leave out the table schema and friends?

int writerKey = hashKeyGenerator.generateKey(data, null, null, null);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, done in 2650060. The table schema and spec are only both known here when just the branch is missing, so the extra case wasn't worth a second conversion of the row. The 322 tests in org.apache.iceberg.flink.sink.dynamic still run with no failures (2 skipped).

One small difference, for the record: when a record goes to a new branch and needs data conversion, the update path now keys it with the record's types, while emit() keys later records with the table's types. With equality-field distribution, an int key column written to a long table column hashes differently for negative values (Integer.hashCode vs Long.hashCode), so in upsert mode rows with the same key values may go to different writers, depending on the write parallelism. The same can already happen when a schema or spec update is needed. I haven't reproduced this end to end and didn't keep a special case for it.

As suggested in review, key records sent to the table updater with the
record's own schema, spec and row in all cases. The table schema and spec
are known here only when just the branch is missing, so the separate
table-layout case is not worth a second conversion of the row.

Generated-by: Claude Code (Claude Opus 5.5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BynXTKFbZXCrfmXQq5xnyi

@mxm mxm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @zhengxiang378928908-code! Some minor comments, but all in all looks good.

Comment on lines +325 to +338
for (int i = 0; i < 6; i++) {
boolean useVariantA = i % 2 == 0;
DynamicIcebergDataImpl row =
new DynamicIcebergDataImpl(
useVariantA ? variantA : variantB,
tableName,
SnapshotRef.MAIN_BRANCH,
useVariantA ? specA : specB);
row.rowProvided =
useVariantA
? Row.of("a" + i, LocalDate.of(2026, 10, 1 + i))
: Row.of("b" + i, "x" + i, LocalDate.of(2026, 10, 1 + i));
rows.add(row);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need all those iterations? Can we spell out the ones we need?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 198bab6: the test now writes four explicit records, alternating A, B, A, B. Without the fix it still fails with the ClassCastException from #17651.

Comment on lines +378 to +383
for (int i = 0; i < 4; i++) {
DynamicIcebergDataImpl row =
new DynamicIcebergDataImpl(reordered, tableName, "b1", reorderedSpec);
row.rowProvided = Row.of(LocalDate.of(2026, 10, 1 + i), "r" + i);
rows.add(row);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, let's be explicit about the records.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, two explicit records. It still fails without the fix.

Comment on lines +426 to +431
for (int i = 0; i < 4; i++) {
DynamicIcebergDataImpl row =
new DynamicIcebergDataImpl(reordered, tableName, SnapshotRef.MAIN_BRANCH, reorderedSpec);
row.rowProvided = Row.of(LocalDate.of(2026, 10, 1 + i), "r" + i);
rows.add(row);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, two explicit records.

Comment on lines +296 to +298
void testHashDistributionWithAlternatingSchemaVariantsImmediateUpdate() throws Exception {
runAlternatingSchemaVariants("alternating_variants_immediate", true);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this test? Immediate updates are already well-tested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. The remaining test runs with immediateUpdate = false, which is the path this PR changes.

try (CloseableIterable<Record> records = IcebergGenerics.read(table).build()) {
actual = Lists.newArrayList(records);
}
assertThat(actual).hasSize(6);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we reduce the number of input records, can we check the actual values?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. All three tests now check every written row by id and event_date (and extra in the alternating test).

// Shuffled records with immediateUpdate=false go to the update side output.
// Key them with the record's own schema, spec and row: the raw row is laid out by the
// record's schema, so the table's schema or spec cannot be used with it.
int writerKey = hashKeyGenerator.generateKey(data, null, null, null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the user always passes a new schema? We key by the schema reference which will create a new key selector for every record. Admittedly, this issue was there before but is now more pronounced.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. On this path it is actually new with this PR: before, the update path always passed a non-null schema (the resolved table schema when there was one, otherwise data.schema()), so the cache key used that schema's id rather than the object. With null the key holds the record's Schema itself, and Schema has no equals, so every record with a new Schema instance built a new key selector. Besides the cost, a fresh round-robin selector always starts at the same position, so those records would all go to one writer.

198bab6 keys the cache by schema.asStruct() instead, which has value equality. Identifier fields are already part of the key through the resolved equality fields. This only applies when no table schema id is passed, which is the update path; emit() still keys by the table schema id. testCacheHitForEqualSchemaInstances covers it.

- Spell out the test records and check the written values; drop the
  immediate-update variant of the alternating-schema test.
- Compare the record schema by struct in the key selector cache, so a new
  Schema instance per record no longer creates a new key selector on the
  update path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mxm mxm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, just a minor NIT.

// Shuffled records with immediateUpdate=false go to the update side output.
// Key them with the record's own schema, spec and row: the raw row is laid out by the
// record's schema, so the table's schema or spec cannot be used with it.
int writerKey = hashKeyGenerator.generateKey(data, null, null, null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be replaced with

Suggested change
int writerKey = hashKeyGenerator.generateKey(data, null, null, null);
int writerKey = hashKeyGenerator.generateKey(data);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f0e3b72. The single-argument generateKey declared throws Exception, so I dropped that clause as well; it only delegates to the overload that doesn't throw.

* is keyed.
*/
@Test
void testHashDistributionWithReorderedColumns() throws Exception {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure we need this test because it doesn't test the update path, but it doesn't hurt.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept it as a regression test for the main path with reordered columns; happy to drop it if you prefer.

Drop the unneeded throws clause from HashKeyGenerator#generateKey(DynamicRecord),
which only delegates to the overload that does not throw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
this.schemaId = tableSchemaId;
this.specId = tableSpecId;
this.schema = tableSchemaId == null ? schema : null;
this.schema = tableSchemaId == null ? schema.asStruct() : null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(unrelated): this is a little bit strange, why do we need the tableSchemId to decide the schema existence?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It decides how the schema is represented in the cache key. When a resolved table schema is passed, its ID identifies the schema within the table. Otherwise the key holds the input schema's struct.

In emit, the row is first converted to the resolved table schema, so records with different input schemas can share a selector once they map to the same table schema (and the rest of the key — spec, equality fields, distribution mode, parallelism — matches).

The deferred update path calls generateKey(record) with the record's original schema, spec and row, so the input schema has to be in the key. Comparing by asStruct() lets structurally equal schema instances reuse the selector.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema id is used when we already have a corresponding table schema (immediate update). The line above is for the non-immediate path where we do not have a table schema yet.

// Shuffled records with immediateUpdate=false go to the update side output.
// Key them with the record's own schema, spec and row: the raw row is laid out by the
// record's schema, so the table's schema or spec cannot be used with it.
int writerKey = hashKeyGenerator.generateKey(data);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain a bit more why does this change solve the bug mentioned in #17651?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change will always use schema and spec from the record and not from table schemas / specs. The issue before was that it could mix record schema / spec with the corresponding table schema / spec, which leads to undefined behavior.

@mxm
mxm merged commit b61ef96 into apache:main Oct 28, 2026
26 checks passed
@mxm

mxm commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Thanks @zhengxiang378928908-code for the PR! Thanks @pvary @openinx for reviewing!

@zhengxiang378928908-code Could you create the backport PR for the other Flink versions?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flink: Dynamic Sink — ClassCastException in HashKeyGenerator partition keying when records for one table alternate between writer schema variants

4 participants