[Feature][Connector-File] Add schema evolution support (ADD/DROP/RENAME/UPDATE column) for all file formats - #10744
Conversation
|
@davidzollo @dybyte can you help validating this feature. |
|
also to add => A proper fix would require the engine to pause checkpoint barriers while a schema change event is being applied, similar to how Flink handles watermarks and barriers. This would need a new coordination primitive in SinkWriter (e.g. beginSchemaChange / endSchemaChange lifecycle hooks) so the engine knows not to snapshot between the two steps. We are happy to contribute that engine-level fix as a follow-up issue if the community agrees on the approach. For now, the connector-level implementation is as safe as the API allows, and the limitation only materialises on a crash-and-restore event, not during normal operation |
DanielLeens
left a comment
There was a problem hiding this comment.
Thanks for the update. I pulled this locally and there is still one blocking correctness gap in the current implementation.
BaseMultipleTableFileSink#supports() now advertises ADD/DROP/RENAME/UPDATE whenever schema_evolution_enabled=true, but BinaryWriteStrategy#applySchemaChange() still throws FORMAT_NOT_SUPPORT. In the streaming Flink path, SinkExecuteProcessor inserts the schema broadcast operator based on sink instanceof SupportSchemaEvolutionSink, so a file sink configured with file_format_type=binary and schema_evolution_enabled=true will enter the main schema-evolution path and fail on the first schema change instead of being rejected up front.
Please either stop advertising schema evolution for formats that still cannot apply it, or reject schema_evolution_enabled=true during config validation for binary.
DanielLeens
left a comment
There was a problem hiding this comment.
I re-pulled the latest HEAD locally and checked the schema-change write path again.
The previous binary-format blocker is addressed in the current revision by the upfront validation in FileSinkConfig.
I still see one blocking correctness gap in the partitioned sink path.
Current chain:
SchemaChangeEvent
-> BaseFileSinkWriter.applySchemaChange(...)
-> AbstractWriteStrategy.applySchemaChange(...) rebuilds seaTunnelRowType and sinkColumnsIndexInRow
-> next row write enters generatorPartitionDir(...)
-> partition path still reads partitionFieldsIndexInRow / partitionFieldList from FileSinkConfig
The problem is that those partition metadata fields are still initialized once in FileSinkConfig and are not refreshed during applySchemaChange(...). So after an ADD / RENAME / DROP affecting partition columns, the current revision can still route rows to the wrong partition directory, and dropping a partition column can even turn into an out-of-bounds access in the partition-path generation.
So I still think this needs changes before merge.
Suggested fix order:
- rebuild mutable partition metadata together with the sink column indexes during
applySchemaChange(...), or - narrow the advertised support so partitioned file sinks are not included in schema evolution support yet.
6019b7f to
174a018
Compare
DanielLeens
left a comment
There was a problem hiding this comment.
Hi @ricky2129, I re-pulled the latest head locally and rechecked the schema-change write path again.
Runtime chain I verified:
schema change event
-> AbstractWriteStrategy.applySchemaChange()
-> rebuild sink-column mapping
-> writeRecordToFile()
-> partition path still reads fileSinkConfig.partitionFieldList / partitionFieldsIndexInRow
The two blockers from my earlier review are still open in the current head:
- The partitioned file-sink path still reads
partitionFieldList/partitionFieldsIndexInRowfromFileSinkConfig, whileapplySchemaChange()only rebuilds sink-column mapping. That means schema evolution for partitioned sinks can still route rows with stale partition metadata. - This is still a user-visible file sink capability change, but the current diff still does not update the matching
docs/enanddocs/zhdocumentation.
Conclusion
Conclusion: fix required before merge
- Blocking items
- Please either rebuild partition metadata together with schema metadata, or explicitly block
schema_evolution_enabled + partition_byuntil that path is safe. - Please add the matching English and Chinese docs for the supported scope and the current limits.
- Suggested improvements
- Once the partition path is closed and the docs land, this PR will be much closer.
The current Build check is also FAILURE, but the two correctness / documentation gaps above are the real blockers from my side.
|
Could you also update the docs for this change? It would be great to document the new option and its current limitations. |
|
Hi @ricky2129, I rechecked the current PR head locally as I rechecked the file-sink schema evolution chain and the latest maintainer comment: The implementation is much more complete than a local patch, but this is user-visible behavior and it introduces a new option/limitation surface. I agree with the latest maintainer request: docs need to explain the new option and the current schema-change/checkpoint limitation before merge. Fetched CI metadata also reports Conclusion: can merge after fixesBlocking items:
|
| this.seaTunnelRowType = | ||
| new DataTypeChangeEventDispatcher().reset(seaTunnelRowType).apply(event); |
There was a problem hiding this comment.
The DataTypeChangeEventDispatcher class is annotated with @deprecated. Should it be replaced with TableSchemaChangeEventDispatcher?
|
@davidzollo will be pushing some more changes, right now testing this out, you can review post that. |
|
Hi @ricky2129, thanks for the careful iterations here. I re-reviewed the current head Runtime path I checked: The write-path design is much safer than the earlier revisions:
I do not see a remaining correctness blocker in the schema-evolution file write path itself when conflicts are ignored. There are still two merge blockers at the current head: Blocking item 1: the current head has a deterministic test compile issue
new FileSinkConfig(config, BASE_ROW_TYPE)but FileSinkConfig(ReadonlyConfig pluginConfig, SeaTunnelRowType seaTunnelRowTypeInfo)The helper methods earlier in the same test already use Blocking item 2: docs still need to be updatedThis is a user-visible new option and behavior. Please document
Conclusion: fix required before mergeBlocking items:
Suggested non-blocking follow-up:
Overall, the implementation is close from my side. The earlier binary and partitioned-sink risks are handled now, and the remaining work is mainly getting the current head buildable and making the new user-facing behavior clear in docs. |
|
In FileSinkConfig, the method generatorPartitionDir() directly calls fileSinkConfig.getPartitionFieldsIndexInRow() for every write operation. This is a static index calculated based on the initial seaTunnelRowType when FileSinkConfig is constructed. If the upstream performs an ADD FIRST operation (shifting all column indices forward by 1) or drops columns that come before partition columns, partitionFieldsIndexInRow will point to incorrect row fields, resulting in wrong partition directory values. If the partition columns themselves are dropped, seaTunnelRow.getFields()[staleIndex] will eventually throw an ArrayIndexOutOfBoundsException. Should this issue also be fixed? |
|
Hi @ricky2129, I rechecked the latest head locally, including the new concern raised in the discussion. What I verified
Findings
Merge conclusionConclusion: merge after fixesBlocking items:
Non-blocking note:
The implementation is still close. The remaining blockers are mergeability and docs completeness, not a reopened core-path regression. |
01a01e2 to
f389005
Compare
|
Hi @davidzollo @dybyte @DanielLeens — pushed an update that brings this PR up to a fully working Resolves blocking items:
Addressing @davidzollo's question on partition_by + stale partition indices:
Fixed. The Adds the chain-order propagation fix (file-sink schema evolution requires this end-to-end):
Open design question for maintainers: Would you prefer: I've kept the current no-op behavior in this push so nothing changes for existing users. Happy |
|
Thanks for addressing the partition-index issue from the previous round. I re-reviewed the latest head locally. What this PR solves
Runtime pathReview findingsIssue 1: The user-visible schema_evolution_enabled feature still has no matching English and Chinese documentation
Merge conclusionConclusion: Merge after fixes Blocking items:
Non-blocking suggestions:
CI status:
|
DanielLeens
left a comment
There was a problem hiding this comment.
Hi @ricky2129, I re-pulled the latest head locally and re-reviewed it against upstream/dev.
What this PR fixes
- User pain: file sink schema evolution can corrupt writes after live ADD/DROP/RENAME/UPDATE COLUMN events if sink column mappings, partition mappings, or transform-chain schema propagation are not rebuilt correctly.
- Fix approach: the current head rebuilds both sink-column and partition-column indices in
applySchemaChange(), and it extends schema-change propagation across the file sink / transform chain. - One-line summary: the runtime correctness gap Daniel previously flagged on partition metadata is fixed on the current head.
Runtime chain I checked
schema event
-> BaseFileSinkWriter.applySchemaChange(...)
-> AbstractWriteStrategy.applySchemaChange() [193-245]
-> rebuildSinkColumnsIndex()
-> rebuildPartitionFieldsIndex()
-> onSchemaChanged()
next write
-> generatorPartitionDir(row) [427-472]
-> now reads writer-local partitionFieldsIndexInRow
support advertisement
-> BaseMultipleTableFileSink.supports() [132-141]
-> ADD / DROP / RENAME / UPDATE when schema_evolution_enabled=true
Key findings
- The stale partition-metadata blocker from the earlier review is fixed in the current runtime path.
- The remaining blocker is now documentation, not the main write-path code.
- The PR already advertises file sink schema evolution to the engine, but the docs still do not explain the new capability or the current partition-column limits.
Blocking issue
- The current docs still do not describe this user-visible capability change.
- Code side:
AbstractWriteStrategy.java:193-245,394-472andBaseMultipleTableFileSink.java:132-141 - Docs side:
docs/en/connectors/sink/LocalFile.md:45-80,docs/zh/connectors/sink/LocalFile.md:44-78, and the generic schema-evolution pages still do not list file sink support. - Risk: users cannot tell that file sink schema evolution is now supported, and they also cannot see the current fail-fast limit around dropping/renaming partition columns.
CI note
- The current
Buildisaction_requiredbecause GitHub did not detect a usable workflow run from the fork, so there is also no trustworthy green CI result yet.
Merge decision
Conclusion: can merge after fixes
- Blocking items
- Please add the matching English and Chinese docs for the supported scope, the enabling flag, and the current partition-column limits.
- Please trigger a valid Build run after that so the current head has real CI coverage.
- Suggested non-blocking follow-up
- No new code-level blocker from my side in the current runtime path.
|
@davidzollo @dybyte @DanielLeens — closing thought before merge. This PR delivers the file-sink half of schema evolution. End-to-end on a real CDC pipeline (MySQL → S3 Parquet), I found three CDC-source-side fixes are also required for the feature to work correctly in restart
3.tinyint(1) columns added via ALTER come through as TINYINT, not BOOLEAN — length info is dropped during MySqlSchema rebuild. Affects MySQL CDC + int_type_narrowing users. I have working implementations of all three on a private branch,validated end-to-end on a stage cluster. They're all CDC-source-side , so they fix schema delivery for all CDC sinks (Iceberg, JDBC, file), not justfile sink. Question: would you prefer (b) keep this PR file-sink-scoped and merge it; I'll open a separate "CDC source-side schema evolution hardening" PR within the week that depends on this PR (c) any other split you'd prefer I'd marginally lean (b) for cleaner review boundaries, but (a) is also fine if you'd rather not have a temporarily-broken feature in dev. Your call. |
|
Hi @ricky2129, thanks for laying out the trade-off so clearly. From Daniels side, I would lean (b): keep this PR file-sink-scoped, and open the CDC source-side hardening as a separate follow-up PR. Those three restart-path fixes affect the upstream CDC delivery contract for all downstream sinks, so they deserve their own review boundary instead of being folded into a file-sink feature PR at the end. The one important caveat is messaging and docs: until that follow-up lands, I would not describe this PR as fully end-to-end complete across restart-with-DDL scenarios. If the known restart limitation still exists today, please call it out explicitly in the docs / PR narrative so users do not read this as “schema evolution is fully closed end to end under recovery” yet. So my recommendation is:
Also, the current GitHub Build is still |
…uild, case-insensitive ops, RENAME position
…ix Parquet INT96 NPE, gate supports() on enabled flag - BinaryWriteStrategy.applySchemaChange() now throws FileConnectorException instead of silently corrupting hardcoded-index field reads - ParquetWriteStrategy.resolveObject() uses name.toLowerCase() when looking up Avro schema fields so mixed-case TIMESTAMP/BYTES column names don't NPE after schema change - BaseMultipleTableFileSink.supports() returns empty list when schema_evolution_enabled=false, correctly signalling to the engine that this sink will not handle schema events
…trategies Add safeProjectedRow() helper to AbstractWriteStrategy that mirrors SeaTunnelRow.copy(int[]) but substitutes null for any index exceeding the row's arity. Replace seaTunnelRow.copy(sinkColumnsIndexInRow...) with safeProjectedRow() in Text, CSV, Json, CanalJson, DebeziumJson, MaxWellJson strategies. Apply same bounds guard inline in ExcelGenerator and XmlWriter. Without this, any in-flight row serialised against an old (shorter) schema that arrives after an ADD_COLUMN schema change event would cause ArrayIndexOutOfBoundsException in SeaTunnelRow.copy() / getField().
…onfig constructor
…format at config validation
- CRITICAL: Add onSchemaChanged() to all 6 text/JSON write strategies (Text, Csv, Json, CanalJson, Debezium, Maxwell) to rebuild serializationSchema after every schema change. Without this, all text-format files written after a schema change used the stale original column layout, silently dropping or misrouting columns. - MAJOR: Block schema_evolution_enabled=true + partition_by combo at config construction time (same pattern as existing binary format block). Partition field indices in FileSinkConfig are derived once at init and cannot be safely rebuilt after a schema change, causing rows to be routed to wrong partition directories after ADD/DROP/RENAME_COLUMN. - MAJOR: Add safeProjectedRow() projection to XmlWriteStrategy and ExcelWriteStrategy write() methods. These two strategies were missed in the original safe-field-access pass; in-flight old-schema rows arriving after ADD_COLUMN would cause ArrayIndexOutOfBoundsException. - MAJOR: Fix Parquet and ORC finishAndCloseFile() to close ALL writers even when one fails. Previously a single IOException would terminate the forEach iteration, leaving remaining writers open and the beingWrittenWriter map uncleaned, causing stale writer reuse on next write after a schema rotation. - MINOR: Fix test compile error — testBinaryWithSchemaEvolutionEnabled ThrowsAtConfig was passing ReadonlyConfig to FileSinkConfig(Config,..) constructor. Aligned with all other test usages to pass raw Config. - TEST: Add testPartitionByWithSchemaEvolutionEnabledThrowsAtConfig to cover the new partition_by config validation.
safeProjectedRow() was incorrectly applied to XmlWriter.writeData() and ExcelGenerator.writeData(). Both classes hold their own sinkColumnsIndexInRow reference and index into the raw row directly via seaTunnelRow.getField(index). Feeding them a pre-projected row caused every column except index 0 to read the wrong field value. The existing bounds guard (index < row.getArity() ? getField(index) : null) inside writeData() is already the correct safe-access pattern for these strategies. safeProjectedRow() is only correct for strategies that receive a projected row and index from 0, not for index-by-original-position writers.
…dateEventFlag in Canal/Debezium/MaxWellJson strategies
…ransform and serialVersionUID mismatch SQLTransform did not override mapSchemaChangeEvent(). ZetaSQLEngine caches allColumnsCount at init() time using inputRowType.getFieldNames().length. After ALTER TABLE ADD COLUMN, select * iterates N+1 input fields but the pre-allocated output array is only N+4 (old size), causing ArrayIndexOutOfBoundsException on every data row post-schema-change. Fix: override mapSchemaChangeEvent() to rebuild inputCatalogTable from the event using AlterTableSchemaEventHandler, then null out sqlEngine and outputCatalogTable so tryOpen() reinitializes the engine with the new schema. Also add serialVersionUID=1L to BaseMultipleTableFileSink to prevent InvalidClassException on rolling restarts (adding SupportSchemaEvolutionSink changed the auto-computed UID).
…Transform, RowKindExtractorTransform, FilterFieldTransform AbstractCatalogSupportMapTransform now overrides mapSchemaChangeEvent() to rebuild inputCatalogTable from the AlterTableSchemaEventHandler and call transformTableSchema() eagerly. This forces all subclasses that cache field-index arrays at init time (rowContainerGenerator lambda in MultipleFieldOutputTransform, inputValueIndexList in FilterFieldTransform) to rebuild those arrays before the next data row arrives. Without this fix, MetadataTransform drops new columns added by ADD COLUMN events because rowContainerGenerator captures a stale inputFieldLength, causing System.arraycopy to copy only the original N fields and leaving the new column slot empty. TableRenameTransform and FieldRenameTransform already override mapSchemaChangeEvent() and are unaffected.
…eSchemaEventHandler in schema evolution Tracks full TableSchema (not just SeaTunnelRowType) across ALTER TABLE events so PK/constraints are preserved; derives SeaTunnelRowType via tableSchema.toPhysicalRowDataType() after each event.
…arquetTypeCoercionTest MySQL tinyint(1) maps to SeaTunnel BOOLEAN (with int_type_narrowing default true). Debezium delivers the value as Byte. Without coercion the Parquet writer's resolveObject BOOLEAN case returns the Byte raw, AvroWriteSupport expects Boolean, ClassCastException at write time. This was the v21 stage failure. The fix is 4 lines: in resolveObject's BOOLEAN case, coerce Byte (1/0) to Boolean (TRUE/FALSE) before returning. ParquetTypeCoercionTest reproduces the exact stage failure when the coercion is removed and asserts: - BOOLEAN column accepts Byte input from CDC tinyint(1) - All 11 canonical types (TINYINT, SMALLINT, INT, BIGINT, FLOAT, DOUBLE, DECIMAL(20,6), STRING, DATE, TIMESTAMP, BYTES) round-trip faithfully - Null values are preserved across all column types Test runs in <1s with no Testcontainers — catches today's bug AND every variant locally before any image is built.
…eSchemaEvolutionTest helper
Two test changes — both validate that the AlterTableSchemaEventHandler swap
(commit 01cc356f2) holds across schema evolution events:
1. ParquetWriteStrategyEvolutionTest (NEW, 2 tests, real Parquet I/O):
- testAddColumnRotatesFileAndPreservesAllRows: write 3 rows, ALTER ADD
COLUMN email, write 3 more rows. Assert two parquet files produced —
one with pre-ALTER schema [id, name], one with post-ALTER schema
[id, name, email]. All 6 rows preserved across rotation.
- testDropColumnRotatesFile: same shape for DROP COLUMN, asserts file
rotation produces a 3-col file and a 2-col file.
- Confirms the strategy correctly invalidates its cached Avro schema on
onSchemaChanged() and that the framework's snapshot-state-then-
beginTransaction flow gives the next write a distinct file path.
2. FileSchemaEvolutionTest fix:
- Existing helper setSeaTunnelRowTypeForTest() only set seaTunnelRowType,
not tableSchema. After 01cc356f2 applySchemaChange reads tableSchema —
leading to NPE in 11/16 existing tests on actual run.
- Replace the manual setter with the production setCatalogTable() path,
which sets both fields. Mirrors what real sinks do.
- All 16 tests now pass.
…verage Add a TYPE_ZOO_ROW_TYPE that mirrors what we see across our production tables (BIGINT for id, BOOLEAN for tinyint(1), TINYINT, SMALLINT, INT, DECIMAL(20,6) for amount, DOUBLE, STRING, TIMESTAMP, DATE) and exercise the schema-evolution contract against it: - testTypeZooAddBooleanColumnPreservesAllOriginalTypes: ADD COLUMN must not silently widen / narrow any existing column type. - testTypeZooDropDecimalColumnLeavesAllOtherTypesIntact: DROP COLUMN of a DECIMAL must not collapse other column types (regression guard). - testTypeZooRenameTimestampColumnKeepsType: RENAME of a TIMESTAMP column must preserve its data type — only the name changes. These widen the existing 16 tests (which only used INT/STRING/INT) to catch type-specific regressions in updateSinkColumnNames + rebuildSinkColumnsIndex + AlterTableSchemaEventHandler.apply paths. All 19 tests pass.
…to fix order-divergence on ALTER Adds a new SeaTunnelTransform.setInputCatalogTables() default-no-op API. After a schema-change event flows through the chain, the engine now calls this on each transform so it re-derives its state from upstream's actual produced schema instead of applying the ALTER locally to a stale view. AbstractMultiCatalogTransform overrides mapSchemaChangeEvent so the wrapper dispatches the ALTER to the right inner per-table transform and writes the wrapper's actual produced catalog into event.changeAfter. Downstream transforms read changeAfter to adopt that exact layout instead of re-applying ALTER on their own catalog (which would diverge from upstream's row order and break SQL projections / FilterField excludes after live ALTER ADD COLUMN). Drops leftover BUG3-DIAG / BUG1-DIAG instrumentation in SQLTransform and TransformFlowLifeCycle now that the diagnosis is closed. Adds five regression tests covering the chain-propagation edges (timestamp preservation, multi-catalog schema-change dispatch, SQL multi-catalog wrapping, production pipeline shape, and live-ALTER chain).
… fail-fast when ALTER arrives with flag=false Removes the FileSinkConfig guard that rejected schema_evolution_enabled=true together with partition_by — the rejection was overly conservative and made schema evolution unusable for any real CDC pipeline (every production CDC job partitions by date/table). The reasoning was "partition field indices can't be safely updated after schema change", which is no longer true: - partitionFieldsIndexInRow becomes a writer-local field, defensively copied from fileSinkConfig in the constructor (fileSinkConfig is shared across multi-writer instances and must not be mutated). - applySchemaChange now rebuilds partitionFieldsIndexInRow via name-based lookup against the post-ALTER seaTunnelRowType, mirroring the existing pattern for sinkColumnsIndexInRow. Partition column names are immutable; only their row positions shift on ADD/DROP of other columns. - Dropping a partition column itself is rejected at rebuild time with an explicit IllegalStateException — fail-fast instead of corrupting the partition tree. - generatorPartitionDir reads the writer-local list, not fileSinkConfig.getPartitionFieldsIndexInRow(). Replaces the silent-return at the head of applySchemaChange (when the flag is false) with a fail-fast FileConnectorException. Silently swallowing a real ALTER event left sinkColumnsIndexInRow stale — the next data row read row[idx] with old catalog assumptions and produced a confusing ClassCastException several rows later. The new guard tells the user exactly what to fix: either set schema_evolution_enabled=true on the sink, or set schema-changes.enabled=false on the CDC source so ALTER events are not emitted in the first place. Drops leftover BUG3-DIAG instrumentation in ParquetWriteStrategy and applySchemaChange.
…s after rebase Mechanical fixups so the rebased PR branch compiles and all schema-evolution tests pass on top of latest upstream/dev. No behaviour change in the schema-evolution feature itself. Adapted to upstream API changes: - CanalJsonSerializationSchema / DebeziumJsonSerializationSchema / MaxWellJsonSerializationSchema constructors now require a third arg (mergeUpdateEventFlag) when called with a Charset. onSchemaChanged() in each strategy now passes the field that was already stored in the writer. - FileSinkConfig constructor signature changed to take ReadonlyConfig (was Config). Test sites in FileSchemaEvolutionTest, ParquetWriteStrategyEvolutionTest, and ParquetTypeCoercionTest wrapped with ReadonlyConfig.fromConfig(...). Imports added. - FieldFieldMultiCatalogTransform was renamed to FilterFieldMultiCatalogTransform upstream. ChainTimestampPreservationTest and ProductionPipelineSchemaChangeTest updated accordingly. Test contract updates following the partition_by guard removal in this PR: - testPartitionByWithSchemaEvolutionEnabledThrowsAtConfig replaced with testPartitionByWithSchemaEvolutionEnabledIsAccepted — partition_by + schema_evolution is now supported (writer-local partitionFieldsIndexInRow rebuilds on every ALTER via name-based lookup; drop/rename of partition column is the only case rejected, with an explicit IllegalStateException at rebuild time). Restored applySchemaChange's silent no-op behavior when schema_evolution_enabled=false. The fail-fast variant is left as an open design question for maintainers — current behavior preserves backward compatibility but leaves a latent ClassCastException trap when schema-changes.enabled=true at source + schema_evolution_enabled=false at sink (documented inline). Awaiting maintainer guidance on the preferred UX. All 30 schema-evolution tests pass: - FileSchemaEvolutionTest (19) - ParquetWriteStrategyEvolutionTest (2), ParquetTypeCoercionTest (3) - ChainTimestampPreservationTest, MetadataMultiCatalogSchemaChangeTest, ProductionPipelineSchemaChangeTest, TransformChainLiveAlterTest, SQLMultiCatalogSchemaChangeTest
…eEvent arrives with schema_evolution_enabled=false Previously the sink silently returned (no-op), leaving sinkColumnsIndexInRow stale and causing a confusing ClassCastException several rows later. Now throws UnsupportedOperationException with an explicit message pointing to both fix paths: enable schema_evolution_enabled=true at the sink, or set schema-changes.enabled=false at the CDC source.
…all file sink connectors (en + zh) Document the schema_evolution_enabled option across all 9 file sink connectors in both English and Chinese. Covers: - Supported formats (all except binary) - Partition column drop constraint - Fail-fast behavior when schema_evolution_enabled=false with active CDC source - Known checkpoint atomicity limitation and dependency on follow-up CDC PR - Example CDC pipeline config
…all file sink connectors (en + zh) Document the schema_evolution_enabled option across all 9 file sink connectors in both English and Chinese. Covers: - Supported formats (all except binary) - Partition column drop constraint - Fail-fast behavior when schema_evolution_enabled=false with active CDC source - Known checkpoint atomicity limitation and dependency on follow-up CDC PR - Example CDC pipeline config
…nsupportedOperationException testDisabledFlagMakesApplySchemaChangeNoOp previously asserted no-op behavior. Now that schema_evolution_enabled=false throws UnsupportedOperationException, update the test to assert the exception and verify the error message contains both fix paths.
be18f8a to
65fcf45
Compare
|
@davidzollo added the changes |
|
Thanks for the ping. I checked the PR metadata again before replying. The current head is still One correction on the CI thread: the latest fork Build run If you already have a local fix that is not pushed yet, please push the new commit and ping me again. If the branch has not changed yet, please focus on getting the current CI green first, and I will re-check once the head actually moves. |
|
Thanks @davidzollo. I saw the approval. From Daniel's side, the current head is still the same |
Purpose
Implements
SupportSchemaEvolutionSink/SupportSchemaEvolutionSinkWriterfor the file sink connector, enabling CDC pipelines (MySQL/PostgreSQL → File) to handlelive schema changes without job restarts.
What changes were proposed in this pull request?
New config option
schema_evolution_enabled(boolean, defaultfalse) — opt-in flag, same pattern as the Iceberg sink. Whenfalse,supports()returns an empty list so theengine does not route schema events to this sink.
Core logic (
AbstractWriteStrategy)applySchemaChange(SchemaChangeEvent)— handles all 4 change types:ADD_COLUMN,DROP_COLUMN,RENAME_COLUMN,UPDATE_COLUMN, plus batchAlterTableColumnsEventfinishAndCloseFile()on schema change so files have clean schema boundaries. Old file closes with old schema; new file opens with newschema on the next write.
sinkColumnNameslist tracks which columns to write; updated per event with case-insensitive matchingrebuildSinkColumnsIndex()recomputessinkColumnsIndexInRowafter every schema changeonSchemaChanged()hook for subclass-specific cache invalidationgetFieldSafe(row, index)— null-safe field accessor for in-flight rows arriving after ADD_COLUMNsafeProjectedRow(row)— null-safe projected row copy for text-format strategiesfinishAndCloseFile()sobeingWrittenFileis always cleared even if a writer close throwsFormat-specific fixes
onSchemaChanged()nulls cached Avro schema and rebuildswritePathsAsInt96;resolveObject()usesname.toLowerCase()for Avro field lookup (fixesgetFieldSafe()used inwrite()safeProjectedRow()replacesrow.copy(int[])getField(index)applySchemaChange()throwsFileConnectorException— Binary format has a fixed schema and cannot evolveInterface wiring
BaseMultipleTableFileSinkimplementsSupportSchemaEvolutionSink;supports()returns all 4 types when enabled, empty list when disabledBaseFileSinkWriterimplementsSupportSchemaEvolutionSinkWriter; delegates towriteStrategy.applySchemaChange()Test coverage
FileSchemaEvolutionTest(14 tests):AlterTableColumnsEventgetFieldSafeout-of-bounds guardschema_evolution_enabled=false)finishAndCloseFilecall count on schema changebeingWrittenFile)Known limitation
Schema changes are not atomic with respect to checkpointing. If the engine takes a checkpoint in the narrow window between
finishAndCloseFile()completing andseaTunnelRowTypebeing updated, restoring from that checkpoint would write subsequent rows using the pre-change schema, silently omitting added columns.This is a known architectural limitation shared by all file-sink schema evolution implementations (including the Iceberg connector). The window is very small (~1ms)
and only materialises on a crash-and-restore event — not during normal pipeline operation. A running CDC pipeline with no crashes will never encounter this: the
moment the ALTER fires, all subsequent rows are immediately written with the new schema.
Resolving this fully requires engine-level protocol support and is out of scope for this PR.
Operational mitigation: pause the pipeline before planned DDL migrations, or verify output immediately after any schema change.
Does this PR introduce any UI changes?
No.
Does this PR introduce any breaking changes?
No. The feature is opt-in via
schema_evolution_enabled=false(default). Existing jobs are unaffected.Check list
New License Guide
incompatible-changes.mdto describe the incompatibility caused by this PR.