Skip to content

[FLINK-40647][runtime] Support best-effort schema expansion for existing sink tables during table creation - #4540

Merged
lvyanquan merged 18 commits into
apache:masterfrom
haruki-830:FLINK-40647
Sep 26, 2026
Merged

lvyanquan merged 18 commits into
apache:masterfrom
haruki-830:FLINK-40647

Conversation

@haruki-830

Copy link
Copy Markdown
Contributor

What is the purpose of this pull request?

This PR introduces an opt-in best-effort schema expansion capability for existing sink tables during the initial CreateTableEvent.

When the target table already exists and its schema is narrower than the incoming schema, some sinks may ignore input columns that do not exist in the target table, potentially causing silent data loss.

When enabled, the framework attempts conservative schema expansion, including adding missing nullable non-key columns and safely widening non-key column types. Unsupported, unsafe, or failed operations are delegated to the sink's existing handling without introducing new framework-level fail-fast behavior.

Brief change log

  • Add the sink option existing-table.schema-expansion.enabled, disabled by default.
  • Add an optional MetadataApplier extension for querying and normalizing the existing target schema.
  • Perform best-effort schema expansion during initial table creation.
  • Log derived DDL events and verify the target schema after successful operations.
  • Integrate the extension with both Paimon and Fluss sinks.
  • Add related tests and documentation.

Documentation

  • Does this pull request introduce a new feature? yes
  • If yes, how is the feature documented? docs and JavaDocs

JIRA issue

https://issues.apache.org/jira/browse/FLINK-40647

@haruki-830
haruki-830 marked this pull request as ready for review September 14, 2026 07:29
@leonardBang
leonardBang requested review from loserwang1024 and lvyanquan and removed request for lvyanquan September 14, 2026 08:44

@lvyanquan lvyanquan 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.

Thank you for your contribution. I’ve left a few comments.

@lvyanquan

lvyanquan commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Could we add an end-to-end test that exercises the full configuration pipeline? The current tests are thorough at the algorithm level (ExistingTableSchemaExpanderTest) and partially cover real connector behavior (FlussMetadataApplierTest, PaimonMetadataApplierTest), but they all bypass the wiring path:

YAML option → Composer → SchemaOperatorFactory → SchemaRegistry / BatchSchemaOperator → ExistingTableSchemaExpander → MetadataApplier

No test currently verifies that the existing-table.schema-expansion.enabled flag actually reaches the expander through the regular streaming, distributed streaming, and batch execution paths. A regression in any of these wiring hops would not be caught.

@haruki-830
haruki-830 force-pushed the FLINK-40647 branch 2 times, most recently from 4787aa6 to 647bdbf Compare September 17, 2026 06:50
@haruki-830
haruki-830 force-pushed the FLINK-40647 branch 4 times, most recently from 542efd3 to afd17b8 Compare September 17, 2026 08:40

@lvyanquan lvyanquan 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.

@loserwang1024

Copy link
Copy Markdown
Contributor

From my side, I have tow advice in design side, @haruki-830 @leonardBang , @lvyanquan , WDYT?

[Suggestion] Add a check-only mode for pipelines where the target schema is managed externally

Not every user wants CDC to manage the target table's schema. When the table structure is owned by an external process (DBA change windows, a separate DDL orchestration system, data-platform governance), CDC should issue no DDL at all — but it must also not silently drop data when the upstream schema turns out to be wider than the existing target table. Currently this PR has no story for that user group: with the new option off, we are back to the pre-existing silent-drop behavior.

I'd like to request a validation-only mode alongside the new expansion option, e.g.:

sink:
  existing-table.schema-expansion.mode: OFF | CHECK | EXPAND

In CHECK mode, when the initial CreateTableEvent encounters an existing target table, the framework computes the same diff it already computes in ExistingTableSchemaExpander, but instead of deriving and applying DDL it:

  • passes only if every upstream column maps to a target column that can contain it (reusing the existing canContain rules), and
  • fails the job otherwise, with a single aggregated SchemaEvolveException that lists every difference (table, column, upstream type vs. target type) together with the suggested ALTER TABLE statements the external process can review and apply.

Why I think this is worth it:

  • The diff and type-compatibility logic already exists in this PR; CHECK is essentially "compute the plan, then throw instead of apply", so the incremental cost is small.
  • It serves a use case EXPAND intentionally does not: users whose contract is "CDC never touches my DDL, but must fail loudly instead of dropping columns". For them, a precise failure message is strictly better than both silent loss and auto-evolution.
  • We run a similar validation-first mode in an internal deployment and have found the aggregated per-column error message (with suggested repair SQL) very effective for on-call triage — users fix the target table on their side and restart, without CDC ever having mutated their schema.
  • One semantic point worth deciding explicitly: CHECK should be independent of schema.change.behavior, since it guards the initial table state rather than runtime schema evolution. The docs should state this.

[Concern] A transient failure of a supported expansion silently degrades into permanent column loss

For differences the expander has already classified as supportable and safe, a transient network/database error during the derived DDL (or the post-expansion verification) currently results in:

  1. a WARN + DELEGATE_TO_SINK from ExistingTableSchemaExpander;
  2. the call site (expandExistingTableSchemaIfNeeded) discarding the result and proceeding to apply the original CreateTableEvent, which succeeds once the network recovers;
  3. for Paimon, an existing table + CreateTableEvent is treated as redundant and skipped — so the missing columns are never added, and since the initial CreateTableEvent is one-shot, nothing retriggers the expansion later;
  4. the sink then silently drops those columns' data forever.

That is exactly the silent data loss this PR set out to fix — now reachable through a transient-error window that is actually widened by the expansion itself (≥3 extra round trips: diff query, DDL, read-back verification).

It is also inconsistent with how the same class of failure is handled elsewhere in the framework: a runtime AddColumnEvent that fails in EVOLVE mode is rethrown, fails the job, and converges via failover + idempotent replay. The initial backfill of columns deserves no weaker a guarantee.

Suggested fix (any of these would work, in order of preference):

  • Propagate the exception on derived-DDL / verification failures (narrow the catch-all in ExistingTableSchemaExpander.applySchemaChange) so it flows into the existing applyAndUpdateEvolvedSchemaChange error handling. This is safe: the expander is idempotent — the PR's own tests (testAddsMissingColumnsAsNullableIdempotently, testWidensNarrowTargetTypeIdempotently) prove replay converges to NO_ACTION — so failover retry has no destructive side effects. Unsupported/incompatible differences could still go through the UnsupportedSchemaChangeEventException path so TRY_EVOLVE keeps its tolerant semantics.
  • Or gate fail-fast behind a strict variant (e.g. mode: EXPAND_STRICT, or fail fast when schema.change.behavior=EVOLVE), keeping the best-effort default unchanged.
  • As a complement, a bounded retry with backoff for transient errors inside the expander would shrink the window without paying for a failover.

Independently of which semantics is chosen: ExpansionResult is currently invisible — EXPANDED, NO_ACTION, and DELEGATE_TO_SINK are behaviorally identical after the call. At minimum please expose the outcome (a counter/metric, or include it in SchemaChangeResponse) so operators can detect "expansion did not take effect and columns are being dropped" from something other than WARN logs.

@haruki-830
haruki-830 force-pushed the FLINK-40647 branch 2 times, most recently from fe85458 to f2aed1e Compare September 18, 2026 01:58
Comment thread docs/content/docs/core-concept/schema-evolution.md Outdated

@loserwang1024 loserwang1024 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 now

Comment thread docs/content.zh/docs/core-concept/schema-evolution.md Outdated

@leonardBang leonardBang 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 for this PR — the overall design (especially the CHECK mode and the safe-widening type rules) is well thought out, and the E2E coverage across regular/batch/distributed topologies is solid. I have a few suggestions focused on configuration clarity and the TRY_EXPAND failure boundary. Leaving them as inline comments for discussion.

Comment thread docs/content/docs/core-concept/schema-evolution.md Outdated
春栖 and others added 15 commits September 22, 2026 22:59
Rewrite the test following MySqlToPaimonE2eITCase conventions and fix
issues that prevented it from running:

- Drop the duplicate Container import that broke compilation
- Move scan.startup.mode: snapshot into the batch case only, so the
  streaming case keeps an unbounded source and stays RUNNING
- Wait for a terminal state in the batch case
- Restore scan.startup.mode: full for the Fluss source
- Give the pre-created Paimon table a primary key matching the source
- Pass the matching connector jars per SQL client invocation
…xpansion e2e

The pre-created Paimon target table used a fixed bucket (=4), which
mismatches the CDC Paimon sink's pre-partitioning: PaimonHashFunction
builds its routing schema with empty options and never queries the
catalog, so it assumes Paimon's default dynamic bucket. Records then get
routed to subtasks that do not own the target bucket, leaving the sink
partially written.

Use 'bucket' = '-1' (dynamic) so the pre-created table matches what the
sink itself would create.
…pansion e2e

The Fluss distributed path used scan.startup.mode: full, which bootstraps
the initial read from a KV snapshot. The tablet server runs with
kv.snapshot.interval: 0s (no snapshots), so the source emitted nothing and
the sink stayed empty. Switch to earliest, which reads the changelog from
the beginning and does not depend on a KV snapshot. The distributed
topology and the schema expansion under test are unaffected.

Generated-by: Codex
…n.enabled handling

The in-PR enabled key was never released, so the migration note in the
docs and the dedicated rejection logic in the YAML parser are
unnecessary. Unknown options are still rejected by the generic factory
validation. Keep only the YAML quoting hint for the new mode option.
Wait for the initial snapshot and its checkpoint to complete before querying Paimon in the regular streaming path.

Generated-by: OpenAI Codex
AI-Model: codex
Co-Authored-By: Codex <noreply@openai.com>
AI-Contributed/Feature: 0/0
AI-Contributed/UT: 5/5
Wait for the asynchronous Fluss batch insert to finish before starting the distributed streaming pipeline.

Generated-by: OpenAI Codex
AI-Model: codex
Co-Authored-By: Codex <noreply@openai.com>
AI-Contributed/Feature: 0/0
AI-Contributed/UT: 3/3
…line

Replace waitUntilJobFinished with a polling loop that verifies the 3 seed rows are actually readable from Fluss before starting the distributed streaming pipeline.

Generated-by: OpenAI Codex
AI-Model: codex
Co-Authored-By: Codex <noreply@openai.com>
AI-Contributed/Feature: 0/0
AI-Contributed/UT: 16/16
The validatePaimonSinkResult and validateFlussSinkResult methods already poll for up to 3 minutes, so the extra waitUntilStreamSplitReady and source-table polling loop were unnecessary and actually harmful: the former could hang for 5 minutes on missing log messages, and the latter submitted dozens of Flink batch SQL jobs that interfered with the CDC pipeline.

Generated-by: OpenAI Codex
AI-Model: codex
Co-Authored-By: Codex <noreply@openai.com>
AI-Contributed/Feature: 0/0
AI-Contributed/UT: 18/18
Co-Authored-By: Codex <noreply@openai.com>
AI-Model: gpt-5.6-sol
AI-Contributed/Feature: 0/0
AI-Contributed/UT: 20/20
…arden its failure handling

Restrict the new existing-table schema expansion option to streaming
pipelines and fix several correctness and test-harness issues found while
validating it end to end.

* Batch mode: revert the BatchSchemaOperator wiring added by this feature,
  so batch pipelines keep the pre-existing sink behavior. The translator now
  warns when the option is set in batch mode, and the docs state that
  expansion is streaming only.
* Rename the default mode OFF to DISABLED. A bare `OFF` YAML scalar is
  parsed as the boolean `false`, so the option now rejects non-textual
  values instead of coercing them, which also drops the parser workaround.
* Fail fast when a sink lacks ExistingTableSchemaExpansionSupport instead of
  degrading to the sink's own handling under TRY_EXPAND, which previously
  hid a connector misconfiguration.
* Split TRY_EXPAND into a probing phase (delegating to the sink when the
  table cannot be expanded safely) and an applying phase (propagating
  failures). Before this change a failure while applying or verifying
  derived DDL fell back to applying the original CreateTableEvent to an
  already existing table, silently dropping the missing columns.
* Centralize the lazy Paimon catalog creation in getCatalog() and reset the
  cached instance on close so the applier stays reusable.
* E2E: the SQL client creates Paimon artifacts as the JobManager user while
  the sink runs in the TaskManager container, so widening the shared volume
  permissions is required for applyCreateTable/applyAddColumn to work. The
  Fluss source table insert additionally needs the Fluss jar shipped through
  pipeline.jars, because copyJarToFlinkLib only reaches the JobManager and
  sql-client.sh has no --jar option; the read-back loop now surfaces a
  failed insert job instead of timing out silently.
* Drop the batch expansion E2E case, whose logic is shared with the
  streaming cases and already covered by unit tests.

Co-Authored-By: Codex <noreply@openai.com>
AI-Model: gpt-5.6-sol
AI-Contributed/Feature: 314/330
AI-Contributed/UT: 367/374
…luss cases

The inherited waitUntilJobState helpers inspect the first job the cluster
reports, which assumes a pipeline job is the only one on the cluster. The
Fluss cases now populate the source table through the SQL client before
submitting the pipeline, so those finished batch jobs are still listed and
can be picked up instead: waiting for RUNNING then sees a terminal state and
fails with "Job has been terminated".

Track the job id returned by submitPipelineJob and poll its status directly.
This also makes the expected-FAILED case meaningful, because the inherited
helper simply returned once its deadline passed without ever observing
FAILED.

Co-Authored-By: Codex <noreply@openai.com>
AI-Model: gpt-5.6-sol
AI-Contributed/Feature: 0/0
AI-Contributed/UT: 48/48
…types during expansion

Address the review feedback on the existing-table expansion analysis.

* Compare the pipeline schema's primary key with the existing target table's.
  A target table with matching columns and types but a different or absent
  primary key reported no differences, so CHECK passed and suppressed the
  CreateTableEvent, which in turn bypassed the connector's own key validation
  (FlussMetaDataApplier.sanityCheck) and let the job write under the wrong
  upsert semantics. Partition keys are only compared when the pipeline
  declares them, because most sources do not report partitioning and a
  two-sided comparison would flag externally partitioned target tables. Keys
  are compared as case-normalized sets, matching how the connectors' own
  checks compare them, so an ordering-only difference is not reported.
* TRY_EXPAND now delegates without issuing any DDL when keys differ, instead
  of mutating a table whose keys can never match the pipeline.
* Normalize the type of a column that is about to be added, so a type the
  target system cannot express is reported while probing rather than failing
  later when the derived DDL runs, which contradicted the documented
  capability-gap-is-phase-1 rule. The gate keeps the pipeline type in the
  emitted event.
* Render every unresolved difference after an expansion, not only the
  incompatibilities, which printed an empty list when the applied DDL silently
  had no effect on the target table.

The option documentation states the key comparison rule and the resulting
per-mode behavior in both languages.

AI-Contributed/Feature: 0/114
AI-Contributed/UT: 0/241
normalizeKeyNames(pipelineSchema.primaryKeys(), caseSensitive);
Set<String> targetPrimaryKeys =
normalizeKeyNames(targetSchema.primaryKeys(), caseSensitive);
if (!pipelinePrimaryKeys.equals(targetPrimaryKeys)) {

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 for the quick follow-up - the key comparison closes the gap I raised. One thing that now looks like a false positive: Paimon merges partition columns into the stored primary key, so a table that this pipeline created itself can never match the pipeline's declared PK here.

PaimonMetadataApplier#applyCreateTable copies every partition key into primaryKeys before catalog.createTable(...) (asserted by PaimonMetadataApplierTest: a pipeline schema with primaryKey("col1") + partitionKey("dt") yields table.primaryKeys() == [col1, dt]), and getExistingTableSchema reads that list back verbatim. On the next run the comparison above sees pipelinePrimaryKeys = {col1} vs targetPrimaryKeys = {col1, dt} and reports a mismatch even though nothing is wrong. The same holds when partitioning comes from the sink's partition.key option, where the pipeline schema does not carry partition keys at all.

The consequences per mode are that CHECK/EXPAND fail an otherwise valid job, and TRY_EXPAND takes the new skip path and issues no DDL, so the option silently does nothing for partitioned tables. I reproduced both with a target schema of {col1, dt} with PK [col1, dt] / partition [dt] against a pipeline schema that only adds one nullable column: TRY_EXPAND delegated with zero applied events, and CHECK threw with the primary-key message.

Would it make sense to compare the identity key after removing partition columns from both sides (e.g. targetPrimaryKeys - targetPartitionKeys vs pipelinePrimaryKeys - pipelinePartitionKeys), or otherwise treat a target PK equal to pipelinePK | pipelinePartitionKeys as compatible? A case like this in ExistingTableSchemaExpanderTest, and ideally a partitioned table in the Paimon e2e, would keep the two rules from drifting apart again.

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.

Reproduced and fixed. Against a real Paimon catalog, a table created from primaryKey("col1") + partitionKey("dt") reads back as [col1, dt], so the strict comparison flagged the pipeline's own table as key-incompatible.

The comparison now uses the identity part of the key - primary key minus partition columns on both sides - so a merged key is not a difference, while a genuinely different key still is (an explicit case guards that). Your second variant is covered too: with partitioning coming from the sink's configuration, the target side still reduces to [col1]. The message and the option docs say partition columns are excluded.

Both the framework and a real-catalog PaimonMetadataApplierTest case fail without the normalization. I did not extend the Paimon e2e - there the job also writes data, so a partitioned table has to line up with the sink's pre-partitioning rather than just being declared; glad to take that here or as a follow-up if you prefer.

@leonardBang

Copy link
Copy Markdown
Contributor

Review Summary

Second-round review on 4ae9e39c5. All three findings from the previous round are addressed, and the fixes hold up against the code and the new tests:

  • verifyExpansion now renders all three kinds of unresolved difference through the shared describeDifferences(plan) helper, so a no-op DDL no longer reports an empty [].
  • analyze() now calls validateTableKeys() and records key differences as incompatibilities, so CHECK/EXPAND fail instead of silently passing a table that identifies rows differently.
  • Missing columns now go through normalizeType(...) as a phase-1 gate, so an unrepresentable type is reported rather than surfacing as a DDL failure in phase 2.

The behaviour tightening you flagged is fine, and keeping TRY_EXPAND non-fatal on key mismatch is consistent with its best-effort contract.

Remaining issue

validateTableKeys() does not account for sinks that store partition columns inside the primary key. PaimonMetadataApplier#applyCreateTable merges every partition key into primaryKeys before catalog.createTable(...) - PaimonMetadataApplierTest asserts that a pipeline schema with primaryKey("col1") + partitionKey("dt") produces a table whose primaryKeys() is [col1, dt] - and getExistingTableSchema reads that augmented list back. The strict set comparison therefore reports a pipeline-created partitioned table as key-incompatible on the next run. CHECK/EXPAND fail a valid job, and TRY_EXPAND takes the new skip path and issues no DDL at all, so the option quietly does nothing for partitioned tables. Filed inline with a suggested normalisation.

Checklist

  • Normalise partition columns out of the primary key comparison
  • Add a partitioned-target unit case, and ideally a partitioned table in the Paimon e2e

Leonard Verdict

Ready to merge: With fixes

Reason: The three previous gaps are properly closed with matching tests. The remaining item is a false-positive risk in the new key check on the most common Paimon layout (partitioned primary-key tables), which can either fail valid jobs or silently disable expansion; it should be normalised before merge.

…ion keys

Paimon appends every partition column to the stored primary key when creating
a table, so a partitioned table that the pipeline itself created reads back
with a strictly larger primary key than the pipeline declared. The strict set
comparison introduced for key validation therefore reported that table as
key-incompatible: CHECK and EXPAND failed a valid job, and TRY_EXPAND took the
skip path and issued no DDL at all, silently disabling expansion for partitioned
tables.

Compare the identity part of the key instead, that is the primary key minus the
partition columns on both sides, so a merged partition column is not a
difference while a genuinely different key still is. This also covers
partitioning that comes from the sink's own configuration, where the pipeline
schema carries no partition key at all. The incompatibility message and the
option documentation state that partition columns are excluded.

AI-Contributed/Feature: 0/43
AI-Contributed/UT: 0/185
@yuxiqian yuxiqian added this to the V3.7.0 milestone Sep 24, 2026

@leonardBang leonardBang 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 @haruki-830 and @lvyanquan for the contribution, +1 from my side.

I'll merge once the CI green.

@lvyanquan
lvyanquan merged commit eef77bb into apache:master Sep 26, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants