[FLINK-40298][table] Opt-in name-based state schema evolution for RowData - #28973
Draft
weiqingy wants to merge 3 commits into
Draft
[FLINK-40298][table] Opt-in name-based state schema evolution for RowData#28973weiqingy wants to merge 3 commits into
weiqingy wants to merge 3 commits into
Conversation
…apshot
Add a default method that lets a serializer snapshot transform an already
deserialized state value from the schema it was written with into the schema
the current serializer expects:
default T migrate(TypeSerializerSnapshot<T> oldSerializerSnapshot, T value)
Like resolveSchemaCompatibility, it is invoked on the new snapshot and receives
the old snapshot as its argument. The default returns the value unchanged, so
behavior is unaffected for every existing serializer: a value deserialized with
the prior serializer is structurally compatible with the current one and can be
re-serialized as is.
The javadoc states that migration is not applied recursively to nested
serializers. Unlike resolveSchemaCompatibility, which CompositeTypeSerializer-
Snapshot delegates to the nested snapshots, migrate has no delegating override,
so a composite returns its value unmigrated unless it decomposes the value
itself. That asymmetry is invisible at the call site and would otherwise fail
silently.
Generated-by: Claude Code (Opus 5)
…grate hook TtlAwareSerializer.migrateValueFromPriorSerializer is the single entry point through which the RocksDB state backend migrates state values on restore: AbstractRocksDBState, RocksDBListState and RocksDBMapState all call it after unwrapping the state shape they own. It deserialized with the prior serializer and re-serialized with the new one, leaving a serializer no opportunity to adapt the value in between. Route it through TypeSerializerSnapshot.migrate: unwrap the prior value to its bare, non-TTL form, migrate it, then re-wrap when this serializer is TTL-enabled, preserving the prior timestamp when the prior value carried one. Behavior is unchanged, because no serializer overrides the hook yet and its default returns the value unchanged. The hook receives the persisted prior snapshot rather than one re-derived by calling snapshotConfiguration() on the restored prior serializer. That round trip is lossy: PojoSerializerSnapshot substitutes a synthetic name for a field that no longer exists on the class, so a migrate override reconciling fields by name would see a fabricated schema. The backend already holds the persisted snapshot above the migration loop, and each caller now descends it alongside its serializer. The descent unwraps the TtlAware decorator first. Registering a new serializer mutates the previous snapshot's nested snapshots in place, so a list or map state's persisted element or value snapshot is a TtlAwareSerializerSnapshot rather than the snapshot the checkpoint wrote. A snapshot of an unexpected type now fails rather than falling back to a re-derived one; only an absent snapshot falls back. Generated-by: Claude Code (Opus 5)
…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)
Collaborator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
migratehook onTypeSerializerSnapshotRowDataEach PR depends on the one before it. 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.
Because this branch is stacked, the diff here also contains the PR-1 and PR-2 commits. For the isolated diff of this step alone, weiqingy#12 has the same head branch based on PR-2 instead of
master. It is a reference only and is not for merge.What is the purpose of the change
Lets a
RowDatastate 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.resolveSchemaCompatibilityreturnscompatibleAfterMigration()rather thanincompatible(). Fields are matched by the names persisted by FLINK-40120. Added fields must be nullable; removed fields and changed leaf types are rejected; nestedROWfields are validated by recursion. Themigrateoverride then remaps the row into the new layout, placing fields by name, null filling added fields, preserving the row kind, and recursing into nestedROWvalues.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
migrateon that exact serializer. Where compatibility reportscompatibleAfterMigrationand nothing callsmigrate, 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
RowDataserializer 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,KeyedStateBackendgains an@Internalcapability method defaulting tofalse, which only the RocksDB backend overrides. The heap backend acceptscompatibleAfterMigrationand relies on the next checkpoint rewriting already deserialized objects, which is sound for schema-free objects but not forRowData, whose restored value is aBinaryRowDataview 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.Extending coverage to
ListState<RowData>andMapState<K, RowData>, and propagating migration through composite serializers, is follow-up work.Brief change log
table.exec.state.schema-evolution.enabled, defaultfalse, and expose it to the serializer layer throughSerializerConfig@Internalmarker interface so a keyed state path can hand a serializer the opt-in without flink-core knowing aboutRowData@InternalKeyedStateBackendcapability, defaultfalse, overridden only by the RocksDB backend, and arm the serializer only on that pathStreamingRuntimeContext, which duplicated whatDefaultKeyedStateStoredoes immediately afterwards with an equivalent factorymigrateoverride inRowDataSerializerSnapshotVerifying this change
This change added tests and can be verified as follows:
ROWevolved,ARRAY<ROW>changedLogicalTypearray, so it is covered explicitly to confirm it is matched by name rather than accepted positionallyduplicate()carries itRowDataserializer and an evolved schema is rejected, and a backend that does not perform object-level migration leaves the serializer unarmedcopy()andconfigure()Does this pull request potentially affect one of the following parts:
@Public(Evolving): yes.SerializerConfigis@PublicEvolvingand gains an@Internaldefaultmethod, andKeyedStateBackendgains an@Internaldefaultmethod. Both are source and binary compatible and no existing implementor needs to change. A new table option is added.Documentation
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.
@Internalcapability onKeyedStateBackend. The FLIP does not describe one. It became necessary because the flag alone does not establish that anything will act on it:HeapKeyedStateBackendacceptscompatibleAfterMigrationand relies on the next checkpoint rewriting already-deserialized objects, which is correct for schema-free objects but not forRowData, whose restored value is aBinaryRowDataview 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.RowDataas a state key is out of scope either way.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, soSerializerConfiggains an@Internalaccessor mirroring the table option by key. Suggestions for a cleaner route are welcome.Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code (Opus 5)