Skip to content

[FLINK-37732][table] FLIP-527: name-based state schema evolution for RowData#28678

Draft
weiqingy wants to merge 6 commits into
apache:masterfrom
weiqingy:FLINK-37732-impl
Draft

[FLINK-37732][table] FLIP-527: name-based state schema evolution for RowData#28678
weiqingy wants to merge 6 commits into
apache:masterfrom
weiqingy:FLINK-37732-impl

Conversation

@weiqingy

@weiqingy weiqingy commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What is the purpose of the change

This pull request adds an opt-in, name-based state schema evolution path for RowData keyed state, implementing FLIP-527 (JIRA: FLINK-37732).

Today RowDataSerializer.resolveSchemaCompatibility returns incompatible() on any field-level change, so a Flink SQL job whose keyed state holds a RowData whose schema evolves — for example an upstream Avro/CDC source adds a nullable column — cannot restore from a savepoint. This change lets such state migrate on restore by matching fields by name (nullable additions, reordering, and nested-ROW recursion), rather than by position.

The feature is opt-in and off by default (table.exec.state.schema-evolution.enabled = false) and, in this first PR, scoped to the RocksDB state backend. The opt-in is enforced at serializer construction: when the option is off, RowData state serializers are built name-less exactly as today, so resolveSchemaCompatibility still returns incompatible() and restore behavior is unchanged. The design fails closed — nothing migrates unless a user explicitly enables the feature.

This PR is intended to accompany the FLIP-527 discussion and is opened as a draft; it is not intended to merge until the FLIP is approved.

Brief change log

  • (core) Add an object-level migrate(TypeSerializerSnapshot<T> old, T value) default method to TypeSerializerSnapshot (default = identity; only RowData overrides it), so a serializer can remap a value written by a prior, compatible schema.
  • (table) Add String[] fieldNames to RowDataSerializer and bump RowDataSerializerSnapshot V3 → V4 (append field names; V4 reads V3 with names absent → position-based behavior, preserving old savepoints).
  • (table) Implement name-based resolveSchemaCompatibility (returns compatibleAfterMigration() only when both snapshots carry names and the change is backward-compatible: nullable additions, reorder, nested ROW; rejects removals, renames, type changes, non-nullable additions) plus the migrate remap into a GenericRowData of the new arity (preserves RowKind, nulls for added fields, recurses nested ROW).
  • (table) Add the table.exec.state.schema-evolution.enabled option (default false).
  • (runtime) Drive the object-level migrate through TtlAwareSerializer.migrateValueFromPriorSerializer (polymorphic; preserves the TTL timestamp; no flink-table dependency), so TTL and non-TTL value state share one migration path.
  • (table/core) Gate field-name population on the option through a single global point: a new @PublicEvolving SerializerConfig.isStateSchemaEvolutionEnabled() (default false) that InternalTypeInfo.createSerializer reads to build a name-retaining RowData serializer only when the option is on. This covers every operator whose keyed-state serializer is built from an InternalTypeInfo descriptor (regular/interval/temporal joins, group aggregate, deduplicate, rank, over-aggregate) in one place, with no per-operator code.

Verifying this change

This change added tests and can be verified as follows:

  • Serializer-level evolution and rejection cases (nullable-add at various positions, reorder, nested ROW, combinations; rejections for removal, rename, type change, non-nullable add) in flink-table-type-utils.
  • V3 → V4 snapshot backward-read (a genuine V3-layout snapshot restores with fieldNames null and position-based behavior).
  • The opt-in flag transport/copy regression (SerializerConfigImplTest) — the flag survives configure()/copy() on the shared job Configuration.
  • A deterministic RocksDB-backed migration test driving the full stack (RocksDB restore → TtlAwareSerializer.migrateValueFromPriorSerializerRowDataSerializerSnapshot.migrate) for value state (TTL and non-TTL) and map value, plus a leaf type-change rejection.

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 — a new @PublicEvolving default method TypeSerializerSnapshot.migrate(...), a new @PublicEvolving default method SerializerConfig.isStateSchemaEvolutionEnabled(), and a new @PublicEvolving config option table.exec.state.schema-evolution.enabled. All are default/additive, so existing implementors remain source- and binary-compatible.
  • The serializers: yesRowDataSerializer gains optional field names and its snapshot format is bumped V3 → V4 (backward-compatible read of V3).
  • The runtime per-record code paths (performance sensitive): no — field names are metadata used only at compatibility resolution and restore-time migration; per-record serialization/deserialization is unchanged and equals/hashCode ignore names.
  • Anything that affects deployment or recovery: JobManager (and its components), Checkpointing, Kubernetes/Yarn, ZooKeeper: yes — savepoint/checkpoint state restore. The snapshot format bump reads old (V3) snapshots unchanged, and the new migration path only activates when the opt-in is enabled; with the option off, restore compatibility is identical to today.
  • The S3 file system connector: no

Documentation

  • Does this pull request introduce a new feature? yes
  • If yes, how is the feature documented? docs (the table.exec.state.schema-evolution.enabled option is generated into the configuration reference) and JavaDocs on the new public methods. A dedicated documentation page describing supported schema changes and the RocksDB scope is a planned follow-up.

Scope note for reviewers: this first PR is RocksDB-scoped and covers operators whose keyed-state serializer is built from an InternalTypeInfo descriptor. Operators that pre-build their state serializer (window, lookup, process-table, sink materializer), outer joins (composite-wrapped value), and other backends (ForSt, Heap) are intentionally out of scope and fail closed (their state does not evolve yet); each is tracked as a follow-up under FLINK-37732.


Was generative AI tooling used to co-author this PR?
  • Yes (Claude Code, Opus 4.8)

Generated-by: Claude Code (Opus 4.8)

weiqingy added 6 commits June 21, 2026 22:59
…apshot

Add a default migrate(TypeSerializerSnapshot<T> oldSerializerSnapshot, T value)
method to TypeSerializerSnapshot. The default returns the value unchanged, so a
value already deserialized with the prior serializer is re-serialized as-is.
Serializers whose in-memory representation is coupled to the schema override it
to transform the value into the new layout during restore.

Consistent with resolveSchemaCompatibility, the hook is invoked on the new
snapshot and receives the old snapshot as its argument. It is inert in this
commit -- no serializer overrides it yet, so behavior is unchanged everywhere.

Generated-by: Claude Code (Opus 4.8)
…3->V4

Add a nullable String[] fieldNames to RowDataSerializer and a recursive
name-retaining factory withFieldNames(RowType) that retains field names at every
nesting level. The existing constructors stay names-less, so all current callers
(including state keys) are unchanged and the feature is inert: nothing populates
field names yet.

Bump RowDataSerializerSnapshot from V3 to V4. writeSnapshot appends a field-names
block (a boolean, then one UTF per field when present) after the types section and
before the nested-serializer section; readSnapshot reads it only when
readVersion >= 4, so a V3 snapshot restores with fieldNames null. The names are
carried through snapshotConfiguration() and restoreSerializer().

resolveSchemaCompatibility and migrate are unchanged here; equals/hashCode continue
to ignore fieldNames so a feature-off restore is behaviour-identical to today.

This is a forward snapshot-format bump: a V4 serializer restores a V3 snapshot, but
an older V3-only engine cannot read a V4 snapshot.

Generated-by: Claude Code (Opus 4.8)
…gration for RowData

Teach RowDataSerializerSnapshot to evolve RowData state by matching fields on
name. resolveSchemaCompatibility now has three branches: an identical-types fast
path that preserves today's nested-composite behavior exactly; a name-based
evolution path (engaged only when both snapshots carry field names) that returns
compatibleAfterMigration when every added field is nullable, every old field
survives by name, leaf field types are unchanged, and nested ROW fields recurse;
and incompatible() otherwise (a differing layout with field names absent on either
side preserves today's behavior).

A nested ROW field is exempted from the leaf type-equality check and validated by
recursion instead, so an evolved nested ROW is accepted while a nested incompatible
change is still rejected.

Override the object-level migrate hook to remap an old RowData into the new layout:
fields placed by name, added and null fields become null, RowKind preserved, nested
ROW values remapped recursively. The remap is object-in/object-out; the state
backend performs deserialize/serialize.

Generated-by: Claude Code (Opus 4.8)
…tion

Add an opt-in boolean option table.exec.state.schema-evolution.enabled (default
false) to ExecutionConfigOptions, mirroring the table.exec.state.ttl precedent, and
its generated config-docs row. When enabled, RowData state-value serializers retain
field names so backward-compatible schema changes (added nullable fields,
reorder-by-name, nested ROW evolution) are migrated on state restore; when disabled
the serializer behaves exactly as today and any field-type change is rejected as
incompatible.

The option is inert in this commit -- no operator reads it yet. Default false leaves
existing jobs unaffected.

Generated-by: Claude Code (Opus 4.8)
…re migrate path

Rewrite TtlAwareSerializer.migrateValueFromPriorSerializer to migrate the
deserialized state value to the current schema: unwrap the prior value to its bare
form, apply the object-level migrate hook of the inner value serializer's snapshot,
then re-wrap in a TtlValue. The migrate hook is identity for serializers that do not
override it, so non-RowData and identical-schema state is byte-identical to before;
the RowData serializer overrides it to remap fields on a backward-compatible schema
change.

Dispatch is polymorphic through the flink-core TypeSerializerSnapshot.migrate hook,
so flink-runtime gains no dependency on flink-table. When both sides are TTL-enabled
the prior last-access timestamp is preserved, so migration does not reset state
expiry. The three RocksDB state classes and RocksDBKeyedStateBackend are unchanged:
list-element and map-value migration already funnel through this method.

Generated-by: Claude Code (Opus 4.8)
…e.exec.state.schema-evolution.enabled

Populate RowData state value field names through a single global, opt-in gate
instead of per-operator wiring. When table.exec.state.schema-evolution.enabled is
set (default false), InternalTypeInfo.createSerializer builds a name-retaining
RowData serializer (RowDataSerializer.withFieldNames); otherwise it returns today's
name-less serializer. Because every Table operator whose keyed-state serializer is
built from an InternalTypeInfo descriptor flows through this method, the option
covers regular/interval/temporal joins, group aggregate, deduplicate, rank, and
over-aggregate at once, with no per-operator code.

The flag travels with no new transport: the planner merges the table config into the
job Configuration that backs SerializerConfig, which is serialized into the JobGraph
and read at runtime state registration. A new default method
SerializerConfig.isStateSchemaEvolutionEnabled() (default false) exposes it;
SerializerConfigImpl reads it from the backing Configuration and copies the key in
configure() so copy()/configure() preserve it. flink-core mirrors the option key by
string because it cannot depend on flink-table-api-java.

The gate names key serializers as well as value serializers, which is safe on RocksDB
because the backend rejects key-schema migration on every key path. RowDataSerializer
.duplicate() carries field names through, because the state backend registers a
duplicate of the serializer and name-based compatibility depends on the names being
present.

Operators that pre-build their state serializer (window, lookup, process-table, sink
materializer), outer joins (composite-wrapped value), and backends other than RocksDB
are not covered here.

Generated-by: Claude Code (Opus 4.8)
@flinkbot

flinkbot commented Jul 7, 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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants