[FLINK-40647][runtime] Support best-effort schema expansion for existing sink tables during table creation - #4540
Conversation
lvyanquan
left a comment
There was a problem hiding this comment.
Thank you for your contribution. I’ve left a few comments.
|
Could we add an end-to-end test that exercises the full configuration pipeline? The current tests are thorough at the algorithm level ( No test currently verifies that the |
4787aa6 to
647bdbf
Compare
542efd3 to
afd17b8
Compare
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
[Concern] A transient failure of a supported expansion silently degrades into permanent column loss
|
fe85458 to
f2aed1e
Compare
leonardBang
left a comment
There was a problem hiding this comment.
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.
…ing sink tables during table creation
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
469dda0 to
fe288ee
Compare
…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)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Review SummarySecond-round review on
The behaviour tightening you flagged is fine, and keeping Remaining issue
Checklist
Leonard VerdictReady 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
leonardBang
left a comment
There was a problem hiding this comment.
Thanks @haruki-830 and @lvyanquan for the contribution, +1 from my side.
I'll merge once the CI green.
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
existing-table.schema-expansion.enabled, disabled by default.MetadataApplierextension for querying and normalizing the existing target schema.Documentation
JIRA issue
https://issues.apache.org/jira/browse/FLINK-40647