Skip to content

[FLINK-40298][table] Opt-in name-based state schema evolution for RowData - #28973

Open
weiqingy wants to merge 5 commits into
apache:masterfrom
weiqingy:FLINK-37732-pr3-rowdata-evolution
Open

weiqingy wants to merge 5 commits into
apache:masterfrom
weiqingy:FLINK-37732-pr3-rowdata-evolution

Conversation

@weiqingy

@weiqingy weiqingy commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

This is the third PR of the FLIP-527 implementation, split into a stack of small, independently reviewable PRs under the umbrella issue FLINK-37732. Landing order:

Step Sub-task Scope
PR-1 FLINK-40296 Object-level migrate hook on TypeSerializerSnapshot (merged)
PR-2 FLINK-40297 Route TTL-aware value migration through the hook (merged)
PR-3 (this PR) FLINK-40298 Opt-in name-based schema evolution for RowData
PR-3b FLINK-40932 Arm ListState<RowData> and MapState<K, RowData> (#29449, draft)
PR-4 FLINK-40299 End-to-end state migration coverage on RocksDB, and the user documentation

PR-1 and PR-2 have merged, so this branch is now a single commit on master and the diff below is this step alone. This is where the feature becomes observable: PR-1 and PR-2 were behavior-neutral, because the hook added in PR-1 defaults to returning its argument and this PR adds the first override.

What is the purpose of the change

Lets a RowData state value survive a backward-compatible schema change instead of failing the restore, behind a new opt-in option that defaults to off.

Compatibility becomes name based. When the field layouts differ but the difference is a supported backward-compatible change, RowDataSerializerSnapshot.resolveSchemaCompatibility returns compatibleAfterMigration() rather than incompatible(). Fields are matched by the names persisted by FLINK-40120. Added fields must be nullable; removed fields and changed leaf types are rejected; nested ROW fields are validated by recursion. The migrate override then remaps the row into the new layout, placing fields by name, null filling added fields, preserving the row kind, and recursing into nested ROW values.

The opt-in is carried as an in-memory, non-persisted flag on the serializer rather than as anything written into the snapshot, so nothing about the option reaches the snapshot format.

A snapshot written before field names were persisted carries no names. Rather than being rejected, it falls back to positional mapping, bounded to the one edit under which position is a stable identity: the old layout must be a prefix of the new one and every appended field must be nullable. Without names, an insertion in the middle is indistinguishable from a retype plus an append, and the two demand opposite migrations, so nothing broader can be admitted safely.

Why the flag is set where it is

The flag is set only on a serializer that is a state's own value serializer, and only when the keyed state backend actually performs object-level value migration. Both halves are load-bearing, and together they are what makes the feature fail closed.

The governing invariant is that a serializer may carry the flag only if some backend will actually invoke migrate on that exact serializer. Where compatibility reports compatibleAfterMigration and nothing calls migrate, the backend accepts the restore and later writes the old binary layout back through a serializer of the new arity, which corrupts the value silently rather than failing.

Two consequences follow. First, a RowData serializer nested below a composite serializer, as in interval and outer join buffers, is never a state's own value serializer, so it never carries the flag and its state is rejected on restore rather than migrated. Second, KeyedStateBackend gains an @Internal capability method defaulting to false, which only the RocksDB backend overrides. The heap backend accepts compatibleAfterMigration and relies on the next checkpoint rewriting already deserialized objects, which is sound for schema-free objects but not for RowData, whose restored value is a BinaryRowData view over the old bytes. Operator state, broadcast state and the batch backend never invoke the hook either. All of them therefore leave the flag off and reject the restore.

ListState<RowData> and MapState<K, RowData> are in FLIP scope. They are armed in PR-3b, which extends this same seam by exactly one structural level, matching what the RocksDB list and map states already unwrap before they migrate. Propagating migration through composite serializers is outside the FLIP and stays follow-up work.

Brief change log

  • Add table.exec.state.schema-evolution.enabled, default false, and expose it to the serializer layer through SerializerConfig
  • Add an @Internal marker interface so a keyed state path can hand a serializer the opt-in without flink-core knowing about RowData
  • Add an @Internal KeyedStateBackend capability, default false, overridden only by the RocksDB backend, and arm the serializer only on that path
  • Remove five redundant serializer pre-initializations in StreamingRuntimeContext, which duplicated what DefaultKeyedStateStore does immediately afterwards with an equivalent factory
  • Implement name-based compatibility, the bounded positional fallback, and the migrate override in RowDataSerializerSnapshot

Verifying this change

This change added tests and can be verified as follows:

  • Compatibility and migration matrix: nullable field added, NOT NULL field added, field dropped, fields reordered, leaf type changed, nested ROW evolved, ARRAY<ROW> changed
  • Same-typed reorder and rename: a reorder of two fields of the same type has an identical LogicalType array, so it is covered explicitly to confirm it is matched by name rather than accepted positionally
  • Positional fallback: a name-less prior snapshot migrates an appended nullable field, and is rejected for an appended NOT NULL field, a dropped field, and a field retyped mid-layout. An unchanged layout still resolves as compatible as is, so enabling the option on an older savepoint does not force a rewrite of state whose schema did not change.
  • Opt-in semantics: option off preserves today's behavior, the flag is not persisted across a snapshot write and read, and duplicate() carries it
  • Fail-closed: a state descriptor shaped like an interval join buffer does not arm the nested RowData serializer and an evolved schema is rejected, and a backend that does not perform object-level migration leaves the serializer unarmed
  • The option survives construction, copy() and configure()

Does this pull request potentially affect one of the following parts:

  • Dependencies (does it add or upgrade a dependency): no
  • The public API, i.e., is any changed class annotated with @Public(Evolving): yes. SerializerConfig is @PublicEvolving and gains an @Internal default method, and KeyedStateBackend gains an @Internal default method. Both are source and binary compatible and no existing implementor needs to change. A new table option is added.
  • The serializers: yes
  • The runtime per-record code paths (performance sensitive): no, the added work is on the restore path. With the option off, the only added cost is a boolean check during compatibility resolution.
  • Anything that affects deployment or recovery: yes, it changes which restores are accepted when the option is enabled. With the option off, restore behavior is unchanged.
  • The S3 file system connector: no

Documentation

  • Does this pull request introduce a new feature? yes
  • If yes, how is the feature documented? The generated execution config option page, plus JavaDocs. The prose documentation the FLIP calls for, covering which schema changes are supported versus rejected, lands in PR-4 alongside the end-to-end coverage that exercises the behavior it describes.

Notes for reviewers comparing this against the FLIP

Four points where the implementation is narrower than, or additional to, the approved design. Flagging them here rather than leaving them to be found.

  • A new @Internal capability on KeyedStateBackend. The FLIP does not describe one. It became necessary because the flag alone does not establish that anything will act on it: HeapKeyedStateBackend accepts compatibleAfterMigration and relies on the next checkpoint rewriting already-deserialized objects, which is correct for schema-free objects but not for RowData, whose restored value is a BinaryRowData view over the old bytes. Operator state, broadcast state and the batch backend accept it and never invoke the hook either. Rather than enumerate the backends that do not migrate, the capability states which one does.
  • The positional fallback is bounded. The FLIP authorises a fallback to position-based mapping for a name-less prior snapshot without constraining it. This restricts it to an append-only change: the old layout must be a prefix of the new one and every appended field must be nullable. Without names, an insertion in the middle is indistinguishable from a retype followed by an append, and the two require opposite migrations.
  • Key serializers are not armed. The FLIP says the flag is applied uniformly without distinguishing key from value serializers. Here it reaches value serializers only. The effect is the same, since RowData as a state key is out of scope either way.
  • The option is exposed through SerializerConfig. The FLIP notes that persisting field names unconditionally keeps the opt-in a table-layer concern and avoids plumbing configuration into the serialization layer. Reading the option still requires it to reach the layer that arms the serializer, so SerializerConfig gains an @Internal accessor mirroring the table option by key. Suggestions for a cleaner route are welcome.

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Code (Opus 5)


@flinkbot

flinkbot commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands The @flinkbot bot supports the following commands:
  • @flinkbot run azure re-run the last Azure build

@weiqingy

weiqingy commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

PR-1 and PR-2 have been merged, so this PR has been rebased onto master. The diff now only contains this step.

This is the step where the feature is actually turned on, so the part where I’d most like a second opinion is where the opt-in flag is allowed to be set, under “Why the flag is set where it is” above.

The rule is: a serializer should only carry the flag on a path that actually calls migrate. Everywhere else should leave the flag off and reject the restore.

@gyfora @RocMarshal, could you take a look when you get a chance? Thanks.

…Data

Make RowData state survive a backward-compatible schema change behind
table.exec.state.schema-evolution.enabled, default false.

Compatibility becomes name based when the flag is armed: added fields must
be nullable, removed fields and changed leaf types are rejected, and nested
ROW fields are validated by recursion. A prior snapshot carrying no field
names falls back to positional mapping bounded to an append-only change,
since without names an insertion is indistinguishable from a retype.

The flag is in-memory and never persisted. It is set only on a state's own
value serializer, and only on a keyed backend that performs object-level
value migration, so a serializer can carry it only where migrate is
actually invoked. A RowData serializer nested below a composite serializer,
the heap and batch backends, and operator and broadcast state all leave it
off and reject the restore rather than accepting a migration that nobody
performs.

Generated-by: Claude Code (Opus 5)
@weiqingy
weiqingy force-pushed the FLINK-37732-pr3-rowdata-evolution branch from bf8ac29 to fdbe7b1 Compare October 8, 2026 04:44
@RocMarshal
RocMarshal requested a balanced review from Copilot October 8, 2026 06:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Nested ROW nullability narrowing is incorrectly accepted and can preserve null values under a NOT NULL schema.

4 open findings
What changed in this PR

Adds opt-in, RocksDB-gated schema evolution for keyed RowData state.

Changes:

  • Adds configuration and backend capability gating.
  • Implements name-based and positional migration.
  • Adds serializer, arming, and compatibility tests.
File Description
RowDataStateSchemaEvolutionArmingTest.java Tests serializer arming shapes.
RowDataSerializerSchemaEvolutionTest.java Tests compatibility and migration.
RowDataSerializer.java Implements schema evolution.
InternalTypeInfo.java Propagates the opt-in.
ExecutionConfigOptionsTest.java Tests option propagation.
ExecutionConfigOptions.java Defines the option.
RocksDBKeyedStateBackend.java Advertises migration support.
StreamingRuntimeContextTest.java Tests runtime-context arming.
StateSchemaEvolvingTestSerializer.java Adds an arming test utility.
StateSchemaEvolutionArmingTest.java Tests capability gating.
StreamingRuntimeContext.java Delegates serializer initialization.
KeyedStateBackend.java Adds the capability method.
DefaultKeyedStateStore.java Arms eligible serializers.
AbstractKeyedStateBackend.java Handles direct registration.
SerializerConfigImplTest.java Tests configuration lifecycle.
StateSchemaEvolvingSerializer.java Adds the internal serializer contract.
SerializerConfigImpl.java Reads and copies the option.
SerializerConfig.java Exposes the internal accessor.
execution_config_configuration.html Documents the option.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

@github-actions github-actions Bot added the community-reviewed PR has been reviewed by the community. label Oct 8, 2026
…cover direct backend arming

A nested ROW field pair skipped the exact type check so its contents could
be compared by the nested snapshot recursion. That recursion compares the
ROW's fields and never the ROW's own nullability, so narrowing a nullable
ROW to NOT NULL was accepted and migration then carried an existing null
straight into the non-null field. Settle nullability before delegating,
which matches how a leaf field is treated, since LogicalType#equals
compares nullability too.

Arming a descriptor registered directly through
AbstractKeyedStateBackend#getOrCreateKeyedState had no coverage: the
existing tests drive DefaultKeyedStateStore and StreamingRuntimeContext,
and their fake backend overrides getOrCreateKeyedState. Window and async
operators reach that method directly. Cover both capability values against
a real AbstractKeyedStateBackend, which the mock backend now reports
through an opt-in builder flag.

Generated-by: Claude Code (Opus 5)
@weiqingy

weiqingy commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

@RocMarshal Thanks for the review. Copilot’s comments have been addressed. This is ready for review whenever you have time.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved migration paths can corrupt values during opt-out TTL transitions or nested RAW serializer reconfiguration.

3 open findings
3 resolved since last review

🧠 Review effort: Balanced

…e when the option is off

Enabling or disabling TTL on restore turns an unarmed compatibleAsIs into
compatibleAfterMigration, so TtlAwareSerializer hands every value to
RowDataSerializerSnapshot#migrate even with state schema evolution off.
migrate remapped by name unconditionally: a same-typed rename, which the
option-off resolution matched by position, lost its value, and a same-typed
reorder swapped values. Return the value unchanged unless the snapshot is
armed, which restores the default hook's behavior for the option-off path.

Generated-by: GitHub Copilot CLI (Claude Opus 5.5)
…onfiguring when the row evolves

An armed evolution resolves to compatibleAfterMigration, and migrate then
rebuilds only the ROW spine: every other field keeps the bytes its old
serializer wrote. A RAW field whose Kryo registration order changed still
has an equal RawType, so it passed the type check, while its nested
snapshot asked for a reconfigured serializer. That verdict was collapsed
into compatibleAfterMigration, and the copied payload kept the old
registration ids under the new serializer.

The evolution paths now require every aligned non-ROW field to be
compatible as is, and apply the same rule recursively to nested ROW
fields, including nested rows whose own layout is unchanged. A
reconfiguration is rejected rather than masked by another field's
migration. The identical-layout path is unchanged.

Generated-by: GitHub Copilot CLI (Claude Opus 5.5)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Persisted-state rewriting needs human review, particularly while end-to-end RocksDB restore coverage remains deferred.

1 open finding
2 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Low severity Document legacy positional behavior and undetected same-type field reorders

flink-table/​flink-table-api-java/​src/​main/​java/​org/​apache/​flink/​table/​api/​config/​ExecutionConfigOptions.java:63

Unsupported paths retain legacy compatibility behavior; they do not always reject schema changes. For example, an unarmed serializer accepts (a INT, b INT) → (b INT, a INT) positionally because hasSameLayoutAs ignores field names when evolution is disabled. The restore succeeds without moving values to their new named positions. Describe unsupported backends, pre-built serializers, and nested composite serializers as retaining legacy behavior, and warn that same-typed reorders are not detected. Regenerate the execution configuration documentation after updating this description.

🧠 Review effort: Balanced

… in the schema-evolution option

The description said that a RowData the option does not cover is
still rejected at restore and that other backends still fail. Where
the option does not apply, and when it is disabled, the restore keeps
matching fields by position: a change in field count or type fails,
but a reorder or rename among same-typed fields is not detected and
the values stay where they were. Say so, and note that state written
before field names were stored (Flink 2.3 or earlier) is matched by
position as well, except for appended nullable fields.

Generated-by: GitHub Copilot CLI (Claude Opus 5.5)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Descriptor reuse and positional accumulator identities can admit restores that leave state values under the wrong schema.

2 open issues
1 resolved since last review

Review effort: Balanced

if (kvState == null) {
if (!stateDescriptor.isSerializerInitialized()) {
stateDescriptor.initializeSerializerUnlessSet(executionConfig);
stateDescriptor.initializeSerializerUnlessSet(stateValueSerializerFactory());
Comment on lines +199 to +201
if (config != null
&& config.isStateSchemaEvolutionEnabled()
&& typeSerializer instanceof RowDataSerializer) {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-reviewed PR has been reviewed by the community.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants