Propagate new columns behind an opt-in, gated on the DDL event - #98
Open
orware wants to merge 1 commit into
Open
Propagate new columns behind an opt-in, gated on the DDL event#98orware wants to merge 1 commit into
orware wants to merge 1 commit into
Conversation
Fivetran tells us per table whether new columns should be synced
(TableSelection.include_new_columns), and the SDK guide makes acting on it
the connector's job. We never read the field: includedColumns() looks only at
TableSelection.columns, so a column added after a connection was set up is
never named in the VStream projection and its values never arrive. No error
is raised, syncs report success, and only a historical re-sync recovers.
Widening the projection is not something we can simply do up front, though.
Naming a column that did not exist at the replay position is exactly what
produces "column X not found in table Y" against a pre-DDL TABLE_MAP. So the
stream starts on Fivetran's selection as-is and rebuilds the projection only
once it observes the DDL event for the table.
Resuming there is safe because the cursor is already past the schema change:
vstreamer emits a GTID event carrying EncodePosition(vs.pos) immediately
before the DDL event, vs.pos already includes the DDL's own GTID, and vtgate
converts that GTID to the VGTID we consume in place. Only post-DDL row events
are replayed, so their TABLE_MAP carries the new column and the plan resolves
without help from vttablet's schema historian -- this needs no
--track-schema-versions.
The serializer needed the same treatment. columnSelection and columnWriters
are both built from TableSelection.columns, so a widened projection would
have been discarded on the way out, and a missing writer is a hard error
rather than a silent skip.
Both opt-ins must be set: the new propagate_new_columns source setting and
Fivetran's per-table include_new_columns. A column Fivetran explicitly
deselected is present-and-false in the map and stays excluded; only columns
it never named at all are adopted. The setting defaults to false and is
labelled experimental.
Scope, verified against a live branch with tracking off rather than inferred:
- Fixes new columns never arriving. The pre-DDL row is delivered on the
narrow projection and the post-DDL row carries the new column's value.
- Does not change hard stalls. When Fivetran's selection already names the
new column, filterExistingColumns keeps it (it exists live), so the first
window fails before reaching the DDL and the gate never fires. The error
is identical with the setting on or off and is still matched by
IsVStreamSchemaIncompatibilityError, so recovery stays with #96/#97.
- Does not change dropped columns on a lagging cursor. The width check
fires on the pre-DDL row before the DDL is reached, so that case still
depends on the historian. rebuildProjection drops vanished columns for
coherence, which matters only for a drop landing mid-Read.
Note for anyone tempted to loosen the projection instead: falling back to
SELECT * silently corrupts data across a mid-table ADD. analyzeExprs copies
Table.Fields positionally for a StarExpr and never calls findColumn, so
nothing catches the skew from the st.Fields[:len(tm.Types)] truncation. On a
lagging cursor with ADD COLUMN newcol AFTER a over (id, a, b, c), * delivered
b's value as newcol and c's value as b with no error at all. The explicit
projection is a guardrail, not the defect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
orware
force-pushed
the
feat/propagate-new-columns
branch
from
August 5, 2026 03:18
a8a8120 to
88d9e2e
Compare
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.
Important
Stacked on #97 — please merge that one first.
The base branch here is
feat/schema-incompat-cursor-reset(#97), notmain, so the diffbelow shows only this change rather than #97's as well. Once #97 merges, GitHub retargets
this PR to
mainautomatically and drops the already-merged commits — no action needed.The two are independent in substance: #97 recovers from a hard stall, this one stops new
columns from being silently dropped. They're stacked only because they both add a config
flag and both touch
Read(), so reviewing them in order avoids four keep-both conflicts.lib/connect_client.goitself merges cleanly between them.What this fixes
Fivetran tells us, per table, whether new columns should be synced —
TableSelection.include_new_columns— and the SDK's source-connector guide makes acting on it our job:We never read that field.
includedColumns()looks only atTableSelection.columns, so a column added after a connection was set up is never named in the VStream projection and its values never arrive. Nothing errors, syncs report success, and only a historical re-sync recovers. This is the silent half of the9r0duiqkwh1linvestigation.Why it waits for the DDL
We can't just widen the projection up front. Naming a column that didn't exist at the replay position is exactly what produces
column X not found in table Yagainst a pre-DDLTABLE_MAP. So the stream starts on Fivetran's selection as-is and rebuilds the projection only once it observes theVEventType_DDLevent for the table (whichhandleVStreamEventpreviously discarded — there was noDDLcase).Resuming there is safe because the cursor is already past the schema change:
vstreamer.goemits aGTIDevent carryingEncodePosition(vs.pos)immediately before the DDL event.vs.pos = AppendGTID(vs.pos, gtid)already ran when the preceding GTID event was processed, so the position includes the DDL's own GTID.vstream_manager.gosendEventsLockedconverts that GTID to theVGTIDwe consume in place, preserving order.Only post-DDL row events are replayed, so their
TABLE_MAPcarries the new column and the plan resolves. This needs no--track-schema-versions.mustSendDDLalready scopes DDL events to tables matching the stream's filter, and our filter names one table, so no statement parsing is needed here.Opt-in
Two gates, both required:
propagate_new_columns— new source setting, defaults tofalse, labelled[Experimental]in the setup form.include_new_columns.A column Fivetran explicitly deselected is present-and-
falsein the map and stays excluded; only columns it never named at all are adopted. That matchesinclude_new_columnssemantics, and it's how Convex's connector models the same choice (other_columns: InclusionDefault).The serializer needed the same change —
columnSelectionandcolumnWritersare both built fromTableSelection.columns, so a widened projection would have been discarded on the way out, and a missing writer is a hard error rather than a silent skip.Scope — verified against a live branch with tracking off, not inferred
Reproduced on a dedicated test database: seed row → capture cursor → unconsumed pre-DDL row →
ALTER TABLE repro ADD COLUMN c_new int→ post-DDL row withc_new=424242. Same stale cursor for every case.c_newsilently absentc_newabsent — gate holdsc_new=424242deliveredc_newcolumn c_new not found in table reproc_newConnector log for the fixed case:
The pre-DDL row arrives on the narrow projection — correct, the column didn't exist yet — and the post-DDL row carries the real value.
What this does not change:
filterExistingColumnskeeps it (it exists live), so the first window fails before reaching the DDL and the gate never fires. The error is identical with the setting on or off and is still matched byIsVStreamSchemaIncompatibilityError, so recovery stays with Recognise all recoverable schema incompatibilities in vstream plan failures #96/Add opt-in auto re-sync on schema incompatibility #97.cannot determine table columns) fires on the pre-DDL row before the DDL is reached, so that case still depends on the historian.rebuildProjectiondrops vanished columns for coherence, which only matters for a drop landing mid-Read.Please don't "fix" the stall by loosening the projection
Falling back to
SELECT *silently corrupts data across a mid-tableADD.analyzeExprscopiesTable.Fieldspositionally for aStarExprand never callsfindColumn, so nothing catches the skew from thest.Fields[:len(tm.Types)]truncation. On a lagging cursor withADD COLUMN newcol varchar(64) AFTER aover(id, a, b, c):idanewcolbca2-poisonb2-poisonc2-poisonSELECT *a2-poisonb2-poisonc2-poisonNo error; the sync reported success. The
AreTypesCompatibleguard only catches this when the shifted types happen to differ, so a same-typed tail sails through. The explicit projection from #92 is a guardrail, not the defect — it converted silent corruption into a loudFAILED_PRECONDITION.Relatedly:
Filter.FieldEventMode's zero value isERR_ON_MISMATCH, so we get strict errors without opting in. SettingBEST_EFFORTwould swap them for synthetic@1..@Nfield names that the serializer drops wholesale — silent total data loss. Don't.Tests
Build,
vet,gofmtandstaticcheckclean; full suite passes. New coverage:handleVStreamEventreports DDL as a schema change and doesn't move the cursor; other event types don't.syncstops at the DDL with the cursor at the DDL position, delivers the pre-DDL row, checkpoints before handing back — and ignores DDL entirely when not adopting, so existing behaviour is unchanged.Readwidens the projection after a DDL (asserting the actual filter strings), narrows it after a drop, and does not widen unless both opt-ins are set.Readdisarms adoption if the same position yields a DDL twice, so a resume that re-delivered its own DDL can't spin.rebuildProjectionadd/drop/both/no-op cases, and keeps the projection intact when the column lookup fails.propagate_new_columnsparsing: defaults off, rejects garbage.I verified the key test isn't vacuous by disabling the widening and confirming it fails.
🤖 Generated with Claude Code