feat(cdc): durable per-table schema history in the position (DBZ-3 Area 2 step 2) - #323
Merged
Merged
Conversation
…ea 2 step 2) Schema drift detection (step 1, #322) diffs successive RelationMessages held in an in-memory cache. That cache is empty on every process start, so a DDL applied while the connector was DOWN is invisible: the first RelationMessage after the restart has nothing to compare against and is accepted as ground truth. This carries a bounded record of observed shapes in the position itself, so continuity survives restarts the same way snapshot and CDC positions do. pgoutput carries no DDL — unlike the MySQL binlog, which carries the original ALTER TABLE text — so a fresh RelationMessage is the only signal a shape changed. That makes the restart gap a real hole, not a theoretical one. What's added: - `position.Position.SchemaHistory` — additive, `omitempty`, keyed by namespace.name (not RelationID: that's a pg_class OID, and drop-and-recreate would silently restart the history for what an operator calls the same table). A legacy (Version 0) position parses with no history and seeds cleanly. - `HashColumnSet` over the identity triple (Name, DataType, TypeModifier) — the same identity `diffRelations` uses, so the hash and the diff cannot disagree. Input is sorted because the hash is compared against one computed by a different process after a restart, and the separator cannot occur in a Postgres identifier, ruling out the ("a|b","c") vs ("a","b|c") collision. - `RecordSchemaVersion`, pruned oldest-first to `DefaultSchemaHistoryVersions` (10). Re-observing the current shape is a no-op: Postgres re-sends a RelationMessage on reconnect and when a new subscriber attaches, and appending on every sighting would fill the bounded history with duplicates of one shape and prune away the older ones carrying the drift signal — the mechanism would destroy its own evidence. - `CDCHandler.handleRelation` returns a `driftKind` (none/initial/in-process/across-restart). Returning it rather than only logging makes the decision assertable, and it is the seam the halt|dlq|evolve policy attaches to in step 3. No storage backend is introduced: riding in the existing position payload means invariant 5 (atomic state/checkpoint writes) is satisfied by the mechanism already in place rather than by a second thing to keep crash-consistent. The per-table bound keeps the payload from growing without limit, which matters because positions are written on every batch. Known limitation, deliberate: only a hash is retained, so drift across a restart reports THAT the schema changed, not which columns. Retaining full column sets for 10 versions per table would put roughly a kilobyte per table into a payload written on every batch. Detection is the requirement; the delta is not worth that cost. The log line says so plainly rather than implying more. Behavior is unchanged: this detects and reports. It does not halt, DLQ, or evolve. Making halt the default is step 3, and it is the breaking change that owes a migration note. Tests, each mutation-verified against the specific defect it targets: - carry-forward dropped from buildPosition -> DriftAcrossRestart, NoDriftAcrossCleanRestart, CarriesSchemaHistoryOnEveryRecord all fail - dedupe removed -> RepeatIsSilent, NoDriftAcrossCleanRestart, DedupesCurrentShape fail - pruning flipped to newest-first -> StaysBounded, PrunesOldestFirst fail - TypeModifier dropped from the hash -> DistinguishesShapes fails (this is the varchar(10)->varchar(20) case passing as "same shape") `CarriesSchemaHistoryOnEveryRecord` is the regression test for the failure mode buildPosition's own comment warns about, applied to the new field: positions are checkpointed per batch and a restart lands on whichever was last persisted, so one position dropping the history loses drift detection intermittently. Risk tier: 2. Serialized-format change, but additive and `omitempty`, so an N-1 reader ignores the field and an N reader treats its absence as "no history". No migration needed. Roadmap: DBZ-3 (Debezium parity), Area 2 step 2. Follows #322. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD
gosec G115 flags int -> int32 / int -> uint64 in the loop. Typed counters instead of a nolint directive: the bound is 50, so silencing the check would be noise where removing the conversion costs nothing.
Covers N-1 reading an N position — the rollback case. The existing test only covered N reading an N-1 position. It passes because ParseSDKPosition uses a permissive json.Unmarshal; mutation-verified by making the N-1 reader strict, which fails with 'unknown field "schema_history"'. Pinning it means adding DisallowUnknownFields later fails a test instead of silently making every rollback unrecoverable.
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.
Problem
Step 1 (#322) detects schema drift by diffing successive
RelationMessages held in an in-memory cache. That cache is empty on every process start, so aALTER TABLEapplied while the connector was down is invisible — the firstRelationMessageafter the restart has nothing to compare against and is accepted as ground truth.pgoutputcarries no DDL (unlike the MySQL binlog, which carries the originalALTER TABLEtext), so a freshRelationMessageis the only signal a shape changed. The restart gap is a real hole, not a theoretical one.What this does
Carries a bounded record of observed shapes in the position itself, so continuity survives restarts the same way snapshot and CDC positions do.
Position.SchemaHistoryomitempty. Keyed bynamespace.name— notRelationID, which is apg_classOID: a drop-and-recreate would silently restart the history for what an operator calls the same tableHashColumnSet(Name, DataType, TypeModifier)— the same identitydiffRelationsuses, so hash and diff cannot disagree. Sorted input (compared against a hash from a different process after a restart); separator can't occur in a Postgres identifier, ruling out the("a|b","c")vs("a","b|c")collisionRecordSchemaVersionDefaultSchemaHistoryVersions(10)CDCHandler.handleRelationdriftKind:none/initial/inProcess/acrossRestartRe-observing the current shape is a no-op. Postgres re-sends a
RelationMessageon reconnect and when a new subscriber attaches. Appending on every sighting would fill the bounded history with duplicates of one shape and prune away the older ones carrying the drift signal — the mechanism would destroy its own evidence.handleRelationreturns the kind rather than only logging it so the decision is assertable in a test, and because it is the seam thehalt|dlq|evolvepolicy attaches to in step 3.Why no new storage backend
Riding in the existing position payload means invariant 5 (atomic state/checkpoint writes) is satisfied by the mechanism already in place, rather than by a second thing to keep crash-consistent. The per-table bound keeps the payload from growing without limit — which matters because positions are written on every batch.
Known limitation (deliberate)
Only a hash is retained, so drift across a restart reports that the schema changed, not which columns. Retaining full column sets for 10 versions per table would put roughly a kilobyte per table into a payload written on every batch. Detection is the requirement; the delta isn't worth that cost. The log line says so plainly rather than implying more.
Behavior change
None. This detects and reports. It does not halt, DLQ, or evolve. Making
haltthe default is step 3 — that's the breaking change that owes a migration note.Adversarial self-review
Found and fixed while writing this:
previous_schema_hashlogged the new hash. I readLastSchemaVersionafterRecordSchemaVersionhad already mutated it, so the "previous" field in the cross-restart warning was the current shape. An operator correlating a drift alert against their DDL history would have been handed the wrong hash. Fixed by capturingprevbefore recording.buildPositionhandsSchemaHistorytoPositionby reference, andRecordSchemaVersionmutates it later. Verified safe:ToSDKPositionmarshals to JSON eagerly, so no live reference escapes. Documented at the call site rather than left to be rediscovered.diff.HasDrift()and "differs from durable history". Collapsed into oneswitchgated on whether a new version was actually recorded, so eachRelationMessageproduces at most one line.\x1f), soDistinguishesShapeslooked green under a mutation that never landed. Re-ran correctly — it does fail.Tests — every one mutation-verified
SchemaHistorycarry-forward inbuildPositionDriftAcrossRestart,NoDriftAcrossCleanRestart,CarriesSchemaHistoryOnEveryRecordRepeatIsSilent,NoDriftAcrossCleanRestart,DedupesCurrentShapeStaysBounded,PrunesOldestFirstTypeModifierfrom the hashDistinguishesShapes— this isvarchar(10)->varchar(20)passing as "same shape"CarriesSchemaHistoryOnEveryRecordis the regression test for the failure modebuildPosition's own comment warns about, applied to the new field: positions are checkpointed per batch and a restart lands on whichever was last persisted, so a single position dropping the history loses drift detection intermittently.What was actually run
go build,go vet,golangci-lint— clean on the touched files (5 pre-existing hits elsewhere in the repo from a newer local linter; CI lint is green onmain)source/positionandsource/logrepl— pass, and mutation-verified aboveRisk tier
1 — corrected. I first marked this Tier 2 on the reasoning that the format change is additive. That is the wrong test: CLAUDE.md puts serialization formats in Tier 1 regardless of whether a given change is additive, because "additive" is a claim about the change that has to be demonstrated, not asserted. So this needs DeVaris sign-off before merge; I am not admin-merging it.
Re-tiering also surfaced a gap. The rule is "never change a serialized format without a versioned migration path and an upgrade test", and I had only half of it:
LegacyPositionSeedsWithoutDriftDowngradeIsSafeThe rollback direction is the one that matters more: a rollback is already an incident, and a position the older build cannot parse would turn it into a worse one. It passes because
ParseSDKPositionuses a permissivejson.Unmarshal. Mutation-verified by making the N-1 reader strict — it fails withunknown field "schema_history". Pinning it means someone addingDisallowUnknownFieldslater fails a test rather than silently making every rollback unrecoverable.CurrentPositionVersionstays at 1: the field is additive andomitempty, and both directions are now covered by test, so there is nothing for a version bump to gate.Failure-mode analysis (Tier 1)
CarriesSchemaHistoryOnEveryRecordStaysBounded; bound is 10/tableomitempty; older build ignores itStableAcrossOrdering(sorted input, fixed separator)RepeatIsSilent,DedupesCurrentShapeDowngradeIsSafeNo data-path behavior changes: no ack, position-ordering, or checkpoint semantics are touched.
buildPositiongains one carried field. Records are unaffected.Roadmap
DBZ-3 (Debezium parity), Area 2 step 2. Follows #322.
Next: step 3 — the
halt | dlq | evolvepolicy, wherehaltas the default is the breaking change requiring the migration note.🤖 Generated with Claude Code
https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD