fix(logrepl): stop emitting unchanged TOASTed columns as NULL - #317
Merged
Conversation
RelationSet.Values never inspected pglogrepl.TupleDataColumn.DataType.
On an UPDATE where a large/TOASTed column is unchanged, Postgres emits
that column with DataType 'u' (unchanged-TOAST) and no bytes on the
wire -- the same "no data" shape as a real NULL ('n'). The old code
decoded both identically, so an unchanged TOASTed column was reported
as NULL, and downstream writes silently overwrote real data with NULL
(invariant 6: schema handling must never silently mangle data).
Fix: Values() now switches on DataType. 'u' columns are omitted from
the returned map instead of decoded as nil. handleUpdate additionally
backfills an omitted column from the old tuple when it's available
(REPLICA IDENTITY FULL); with the default REPLICA IDENTITY, OldTuple
only carries key columns, so the column stays omitted -- documented
behavior, never a silent NULL. Also fixed a pre-existing bug in the
same function that wrapped the wrong error variable on decode failure.
Regression tests (fail without the fix, verified locally by reverting
each fix and re-running):
- internal/relationset_test.go: DB-less unit tests building pglogrepl
tuples by hand for the 'u' vs 'n' distinction, plus an integration
test against a real Postgres logical-replication stream with a
SET STORAGE EXTERNAL column (requires the docker-compose harness).
- logrepl/handler_test.go: handleUpdate-level tests covering both the
omit-by-default path and the REPLICA IDENTITY FULL backfill path.
Roadmap: Phase 0 hardening -- data integrity invariant 6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD
Fresh-context review of the TOAST/NULL fix flagged that the behavior change (unchanged TOASTed columns are now omitted from the payload rather than emitted as NULL) is reader-facing but undocumented. Add a Source CDC subsection to the connector spec description explaining the omission semantics, why NULL would be silent corruption, which sink shapes handle omission correctly, and the REPLICA IDENTITY FULL escape hatch for full-row-replace destinations. Edited connector.yaml (the source of truth) and regenerated README.md via conn-sdk-cli readmegen; readmegen is idempotent on the result so validate-generated-files passes. Docs-move-with-code follow-up to the fix in this PR; no code change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD
devarismeroxa
force-pushed
the
fix/toast-unchanged-column-null-corruption
branch
from
July 24, 2026 22:56
75dca43 to
95390e6
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.
Summary
First
tests/chaosharness in the repo (v0.19 Workstream 7 / DBZ-1), perdocs/design-documents/20260722-debezium-compete-roadmap.mdand the DBZ-1implementation plan. Two independent deliverables:
bridge (
pkg/connector.Source.Ack/pkg/connector.Persister), mid-snapshotand mid-stream. Verdict: the ack-before-persist window is a real,
structural GAP against a pruning (Postgres-replication-slot-like) upstream —
not a hypothetical one. Against a durable (Kafka-like) upstream the exact
same crash window in the exact same engine code produces only a benign
duplicate. Per instructions,
Source.Ack's ordering is not changed inthis PR — this is the demonstration + escalation.
conduit-connector-protocolv1 gRPC client (bufconn, no mock) provesfunnel.Worker.Ack(full batch,worker.go:493) andWorker.Nack'spartial-ack path (
worker.go:513) both deliver acks to a v1-protocol pluginas individually ordered messages, matching input order.
Risk tier: Tier 1 (data path — ack/position/checkpoint logic). Design doc:
the DBZ-1 implementation plan (pre-code, dated 2026-07-23). Requires DeVaris
Tier-1 sign-off (wk4). Do not merge without it.
The SIGKILL verdict — gap vs. duplicate, and why it's conditional
pkg/connector/source.go:207-238(Source.Ack) sends the ack to the plugin(
stream.Send, line 218) — which for a real connector like the Debezium/KafkaConnect wrapper drives
task.commitRecord/task.commit, advancing Postgres'sreplication-slot confirmed LSN — before persisting its own position
(
persister.Persist, lines 223-235).Persister.Persist(
persister.go:137-170) debounces: it batches and flushes asynchronously afterDefaultPersisterDelayThreshold(1s) or 10k items. So there is a real ~1s (or10k-record) window where Conduit has told a plugin "you may durably commit"
before Conduit's own bookkeeping has reached durable storage.
This PR's chaos test targets exactly that window with two scenarios:
before any automatic flush, so Conduit has persisted nothing when the kill
lands (models Debezium's initial full-table snapshot).
already landed (a valid but stale checkpoint), and the kill lands inside the
next debounce window before its flush fires.
Since the real
conduit-kafka-connect-wrapper(Debezium/Postgres) isn'tavailable in this repo (separate JVM repo, not a Go module dependency here),
the test drives the real engine code (
pkg/connector.Source,Persister,real on-disk
badgerDB — same backend production uses) against a syntheticupstreamStorewith aprunetoggle modeling the one behavior that actuallydecides the outcome: can the plugin's backing system still supply data from
before its own last commit?
prune=false(Kafka-like): same crash window, same engine code — restartresumes from the stale-but-valid checkpoint, re-delivers a few already-committed
positions as harmless duplicates, then completes delivery through
total.No gap, ever.
prune=true(Postgres-slot-like: WAL segments recycled once confirmed):identical crash window, identical engine code — restart asks to resume
from behind the already-committed/pruned watermark.
chaosPlugin.Opensurfaces this as a loud, structural error (modeling Postgres's real
"requested WAL segment has already been removed", not a silent skip) in
both the mid-snapshot and mid-stream variants. Confirmed by the test:
🔴 SEV-0 ESCALATION:
Source.Ack's ack-before-persist ordering is notcrash-safe when the connected plugin's commit is irreversible from Conduit's
point of view (real Postgres replication slots included). This is a design
characteristic of
pkg/connector/source.gotoday, not something this plandecides to change — per instructions, fixing the ordering (e.g. persisting
before acking, or some other reordering) is explicitly out of scope for this
PR: it touches
Source.Ack's core sequencing for every connector and needsits own separate Tier-1 design doc. This PR is the demonstrating test and the
loud writeup, not the fix.
Failure-mode analysis (crash at each step of ack→commit→persist)
Walking
pkg/connector/source.go:207-238's sequence:changed anywhere. Restart re-reads and reprocesses. Correct — a
duplicate, consistent with at-least-once. Metric that would show it: none
needed, no operator-visible effect. Recovery: automatic (resume from last
persisted position).
stream.Send(partial batch delivered to the plugin). Theplugin has committed a prefix of the batch; Conduit resumes from before the
entire batch (its own persist for this batch never ran). Structurally the
same risk as case 3 below, just with a partial batch.
persister.Persist's async flushcompletes — the window this PR's SIGKILL test targets. Demonstrated: gap
(pruning upstream) or benign duplicate (durable upstream) depending entirely
on the plugin's own retention behavior. Metric that would show it in
production, today: none — there is no replication-slot-lag metric, no
heartbeat-staleness signal, no ack-vs-persist-lag gauge exposed anywhere in
Conduit today. A production instance of this exact crash window would be
invisible until a downstream data-quality incident surfaced it. This is a
real observability gap this workstream inherits, not fixes (named for
DBZ-3/DBZ-4, Phase 2). Recovery: operational only — restore from the last
known-good destination/position snapshot, or replay from an earlier LSN if
the replication slot still retains it; there is no automated self-healing
(correctly, per CLAUDE.md's no-distributed-snapshot-machinery scope
discipline).
flushNowtransaction commit(torn-write risk, invariant 5). Verified, not assumed:
badger'stransactional commit is atomic at the KV layer (all-or-nothing), so a
SIGKILL mid-commit cannot produce a torn/half-written position — confirmed
empirically by the chaos test's restart path (
loadOrCreateInstanceintests/chaos/child.gotreats anystore.Geterror other than "key notfound" as
CORRUPT_POSITIONand hard-fails; this marker never fired acrossall runs). Invariant 2's "no corrupted position" holds structurally here —
invariant 1/3 (ack-before-durable-persist ordering) does not, per the
finding above.
happy-path boundary between batches.
Cross-pipeline blast radius (per instructions, flagged explicitly):
Persister.flushNow(persister.go:238-271) flushes one shared batch acrossevery connector currently pending a write —
Persistis called per-connectorbut batched into a single
map[string]persistDatakeyed by connector ID,and
triggerFlush/flushNowoperate on that whole map in one transaction. Aslow or blocked write for one connector's state (or the transaction itself)
delays the flush for every other connector's pending position update sharing
that same debounce cycle, process-wide. This chaos test only exercises a
single connector, so it does not itself demonstrate the blast radius — flagging
it here because the fix DeVaris may eventually decide on for the sev-0 above
(if it touches persist timing/ordering) needs to account for this shared-batch
behavior, not just the single-connector case this PR tests.
FIFO-ack test coverage (closes #2672)
pkg/lifecycle-poc/funnel/worker_ack_order_test.go— realconduit-connector-protocolv1 gRPC client (not mocked) over an in-memorybufconnlistener, talking to a fakeSourcePluginServerthat records ackarrival order:
TestWorker_Ack_DeliversPositionsFIFOToV1Plugin: full-batch ack(
worker.go:493) — 5 positions acked together, observed as 5 separate,strictly ordered gRPC messages.
TestWorker_Nack_DeliversPartialAckFIFOToV1Plugin: partial-batch ackfollowing a partial DLQ nack (
worker.go:513) — simulates the DLQdestination successfully writing 3-of-5 nacked records; asserts exactly the
3-position prefix is acked, in order, and the other 2 are not acked
(would violate invariant 1 — acking a record neither delivered nor durably
DLQ'd).
This closes #2672: the FIFO guarantee standalone connectors depend on was
previously upheld only by a comment in
conduit-connector-protocol's v1client, unasserted anywhere in this repo.
Adversarial self-review findings (resolved before this PR)
Source.Ackonly guarantees the ack was handed off to the plugin(
stream.Sendrendezvous), not that the plugin's subsequent commit hadfinished — the child process could
os.Exitbefore the last commit's fsynccompleted, truncating the observed run by one position and producing a
false-positive gap unrelated to the engine code under test. Fixed with
waitForUpstreamCommittedbefore the graceful-exit path; also fixed a bugwhere its timeout case silently fell through to
DONEinstead of failing.spawnChildand thetest's own
sigkill/waitExitcall would have left an orphaned childprocess running. Fixed via an idempotent
reap()(guarded bysync.Once,since
cmd.Wait()may only be called once) plus at.Cleanup-registeredfallback kill+reap.
exec.Command/os.ReadFile/os.MkdirAllcalls are taint-analysis false positives (self-controlledre-exec path and
t.TempDir()-scoped fixture paths, not external input) —addressed with targeted
//nolintcomments explaining why, not blanketsuppression.
child.goline by line: everyos.Exitpath prints adistinguishable marker (
CORRUPT_POSITION,OPEN_GAP_ERROR,FATAL,DONE) so the parent test's assertions are never guessing from a bare exitcode.
chaosPlugin's produce/ack goroutines communicate only via thein-memory stream's channels and the
upstreamStore's own mutex; verifiedclean under
-raceacross repeated runs (go test -race -count=3, noreports).
Gates
make generate && git diff --exit-code: clean (no diffs; new files only).golangci-lint runscoped to changed packages (./tests/chaos/...,./pkg/lifecycle-poc/funnel/...): 0 issues. (A full-repogolangci-lint run ./...hit lock contention from concurrent agent sessionssharing this environment's lint cache and could not be cleanly re-run in
isolation before submission — the scoped, diff-relevant run is clean, which
is what's gated on here;
go vet ./...andgo build ./...were runfull-repo without contention, see below.)
go build ./...: clean.go vet ./...: clean except one pre-existing, unrelated finding(
pkg/lifecycle-poc/funnel/worker.go:473, predates this PR, inside thealready-commented-out multi-connector TODO block).
go test -race ./tests/chaos/... ./pkg/lifecycle-poc/funnel/... ./pkg/connector/...:all green, including 3 repeated full runs of the chaos suite (
-count=3)with no flakes observed.
CLAUDE.md: this is a normal CI test, nota scheduled nightly chaos job (that gate is Phase 2). Coverage-floor and
benchi regression gates are not yet live (Phase 1) and are not claimed here.
Requires DeVaris Tier-1 sign-off (wk4)
Open question carried from the DBZ-1 plan, unresolved by this PR (as
instructed): does fixing
Source.Ack's ack-before-persist ordering becomeits own Tier-1 design doc, or is there context that changes that? This PR
takes no position beyond demonstrating the gap is real and structural — please
confirm the escalation path before or alongside sign-off.
DO NOT MERGE without explicit sign-off.
🤖 Generated with Claude Code
https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD