fix(engine): persist position before plugin ack — sev-0 Source.Ack ordering (Approach A)#2680
Conversation
…s/chaos) Adds the repo's first chaos-testing harness (tests/chaos) and a standing engine-side ack-ordering test, per v0.19 Workstream 7 / DBZ-1. - tests/chaos: a minimal SIGKILL harness (re-exec-self subprocess pattern) that drives pkg/connector.Source + Persister against a real on-disk badger DB, killed with SIGKILL mid-snapshot (fast burst, no prior flush) and mid-stream (steady pacing, past one flush cycle), then restarted. A synthetic upstreamStore models the plugin's own commit durability with a `prune` toggle: pruning (Postgres-replication-slot-like) vs durable (Kafka-like). Result: the ack-before-persist window in pkg/connector/source.go:207-238 produces a benign duplicate against a durable upstream, but a structural, loudly-surfaced GAP against a pruning upstream, in both the mid-snapshot and mid-stream variants. Per the workstream's explicit instruction, Source.Ack's ordering is NOT changed in this PR - the finding is escalated, not fixed inline. - pkg/lifecycle-poc/funnel/worker_ack_order_test.go: closes #2672. Exercises the real conduit-connector-protocol v1 gRPC client (over an in-memory bufconn listener) end to end through funnel.Worker.Ack (worker.go:493, full batch) and funnel.Worker.Nack's partial-ack path (worker.go:513), asserting a v1-protocol plugin observes every acked position as a separate, strictly ordered message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD
…dering (Approach A) Source.Ack sent the ack to the plugin (stream.Send) before the resulting position reached durable storage (persister.Persist only enqueues into an in-memory batch, flushed asynchronously on a ~1s debounce). For a source backed by a pruning upstream (e.g. a Postgres replication slot), a crash in that window loses the durable position after the plugin was already told to commit — a structural, unrecoverable gap. Confirmed by the DBZ-1 chaos test (PR #2677). Implements Approach A from docs/design-documents/ 20260723-source-ack-persist-ordering-fix.md (DeVaris-approved): defer the plugin ack to a post-flush callback (onPersistFlushed), gated by a per-connector FIFO high-water-mark (pendingAcks/nextAckSeq/durableAckSeq) so acks are only released to the plugin once the persister confirms they are durable, in the exact order they were originally queued — safe even when flush confirmations complete out of order. Persister.flushNow's callbacks are now tracked by a new callbackWg so WaitPendingWrites (used by lifecycle.Service.StopAndWait) waits for the deferred ack to actually be sent, not just for the write to land. Source.Teardown forces a final flush and waits for it before canceling the stream, so a graceful shutdown cannot drop the final ack either (invariant 7) — the flush-and-wait must happen before stopStream, since canceling the shared stream context first would make the deferred send race ctx.Done() and very likely lose the message. Supersedes PR #2677: folds in its chaos harness and flips TestSIGKILL_PruningUpstream_ProducesGap into TestSIGKILL_PruningUpstream_NoGap (and adds a TestSIGKILL_DurableUpstream_NoGap control), asserting no gap for both a prune-class and a retention-class synthetic upstream. Kill-timing now keys off read progress instead of ack progress, since ack visibility is deliberately deferred behind the debounce under this fix. Also fixes a pre-existing harness data race on the child process's stderr buffer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD
Approach A made Source.Teardown wait on the persister flush, which it never did before — a bare WaitGroup.Wait() could hang graceful shutdown unboundedly on a stuck/slow flush (disk stall, badger compaction), with the caller's ctx powerless. Add WaitPendingWritesContext(ctx, timeout): Teardown now waits with DefaultTeardownFlushTimeout (10s) and honors ctx cancellation, then proceeds with teardown on timeout. Safe by the same reasoning the SIGKILL chaos suite proves for a hard crash — at worst a benign duplicate on restart, never a gap. Invariant 7: graceful shutdown no longer hangs on a stuck flush. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD
…th shipped mechanism Two remaining items from the sev-0 Source.Ack persist-ordering fix: - TestSource_Teardown_BoundedWaitOnStuckFlush: proves Teardown returns within the bounded teardownFlushTimeout instead of hanging when the persister flush is stuck (blockingDB gates NewTransaction on a channel the test controls). Verified this fails (deadlocks past a 5s test timeout) when the bounded WaitPendingWritesContext call is temporarily reverted to the old unbounded WaitPendingWrites, then restored - the fails-without-fix check CLAUDE.md requires. - TestSource_Teardown_FastFlushCompletesWithinBoundedTimeout: same short timeout override, but a healthy (unblocked) flush - proves the bound never fires as a false positive against a normal flush, and the deferred ack still gets delivered before Teardown returns. docs/design-documents/20260723-source-ack-persist-ordering-fix.md (brought in from #2679, together with its companion postmortem) is corrected to match what actually shipped in #2680, not what was originally proposed: - Connector-level FIFO seq, not per-source-partition high-water-mark tracking - unnecessary because SourceState.Position is always the connector's full cumulative position (source.go:394 overwrites unconditionally), so a later flush always subsumes every earlier-queued seq. - Head-of-line blocking restated honestly as process-wide, not merely cross-connector: Persister is a single instance shared across every pipeline in the process (persister.go's WaitPendingWrites doc, ~184-186), so a slow flush delays deferred acks process-wide. - Bounded-Teardown behavior added to the failure-mode analysis: graceful-shutdown-with-stuck-flush -> bounded wait -> forced teardown -> benign duplicate, never a gap. Both simplifications (FIFO seq vs. per-partition HWM, and the bounded wait itself) are flagged pending DeVaris re-affirmation at Tier-1 sign-off - not assumed to be silently covered by the original approach approval. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD
|
Pushed the two remaining items (commit 1. Bounded-Teardown regression test (was the missing piece). Added 2. Doc alignment. Brought
Both simplifications (FIFO Gates run this session (
Still Tier 1 — not merging. Needs DeVaris sign-off on (a) the approach as originally proposed and |
Summary
Implements Approach A (ack-follows-durable-flush) for the sev-0
Source.Ackpersist-orderingbug, per DeVaris's sign-off on the approach in #2679's design doc
(
docs/design-documents/20260723-source-ack-persist-ordering-fix.md) and postmortem(
docs/postmortems/20260723-source-ack-persist-ordering.md).Source.Ack(pkg/connector/source.go) sent the ack to the plugin (stream.Send) before theresulting position reached durable storage (
persister.Persistonly enqueues into an in-memorybatch, flushed asynchronously on a ~1s debounce). For a source backed by a pruning upstream (e.g. a
Postgres replication slot, whose ack advances
confirmed_flush_lsnand frees WAL for recycling), acrash in that window loses the durable position after the plugin was already told to commit — a
structural, unrecoverable gap. This was confirmed, not hypothetical: DBZ-1's chaos test (#2677)
drove this exact window and reproduced the gap on demand.
Supersedes #2677, folding in its chaos harness and flipping its central assertion from
"demonstrates the gap" to "asserts no gap."
The per-connector FIFO high-water-mark mechanism
Source.Ackno longer sends the plugin ack inline. It:Instance.Stateand appends(seq, positions)to a newpendingAcksFIFO queue(
seqis a purely engine-internal, monotonically increasing counter — not derived from theopaque
opencdc.Positionbytes, which the engine cannot generically parse or compare; aPosition's structure, e.g. per-partition offsets, is entirely connector-defined).
persister.Persist(..., func(err) { s.onPersistFlushed(seq, err) }).onPersistFlushedonly sends the plugin ack once the persister confirms durability:durableAckSeqtomax(durableAckSeq, seq)and drainspendingAcksfrom the front for every entry whose
seq <= durableAckSeq, sending each in the exact orderAckoriginally queued them (invariant 4), then removes them from the queue.transaction can finish before an earlier one still in flight, since
Persister.flushNow'scallback goroutines aren't ordered relative to each other):
durableAckSeqonly ever advances,and draining is idempotent — a stale, late callback finds nothing left to send.
s.errs, same as before.Graceful shutdown (invariant 7):
Source.Teardownnow forces a finalpersister.Flushandwaits for it (
Persister.WaitPendingWrites, extended with a newcallbackWgso it waits for thecallback — and therefore the deferred send — not just the store write) before canceling the
stream (
stopStream). This ordering is load-bearing, not incidental: the in-memory stream (and areal gRPC stream) shares one context between both directions, so canceling it first would make the
deferred ack's
stream.Sendrace an already-closedctx.Done()and very likely lose the messageinstead of delivering it — I hit this as a real bug during implementation (see Adversarial
self-review below) before reordering fixed it.
Fails-without-fix verification
Per
CLAUDE.md's "every bug fix ships with the test that would have caught it," I temporarilyreverted just the ordering change (reintroducing a synchronous
stream.Sendbeforepersister.PersistinSource.Ack, then restored it) and reran the new chaos assertion:Red on both scenarios with the bug reintroduced; restored the fix and confirmed green
(
diffagainst the pre-revert file showed byte-identical restoration).Failure-mode analysis
Walking
Source.Ack's sequence under Approach A (today's behavior in brackets):resumes correctly. Plugin may or may not have received that specific message — benign duplicate
if not. [today: this exact window is the sev-0, since the ack already went out before
persistence.]
restart resumes from the last durably-flushed position and reprocesses the gap as a duplicate,
not a data-loss gap. [today: this is the exact scenario that produces the structural gap.]
flushNow's transaction commit. Unchanged, already verified atomic (badger'scommit; chaos harness never observed
CORRUPT_POSITION).state is correct, ack is merely delayed to the next opportunity.
completion. Covered by the new unit test
TestSource_OnPersistFlushed_OutOfOrderCompletionStillDeliversInOrder— simulates thehighest-seq flush completing first and asserts all three positions are still delivered in
order, with the two late/lower-seq callbacks arriving as safe no-ops.
kill -9shutdown. Covered byTestSource_Teardown_SendsPendingDeferredAckBeforeReturning:Teardownmust not return until apending ack has actually been sent. This is where I found and fixed the stream-context-ordering
bug described above — without the fix, this specific test hung indefinitely (a real deadlock,
not a flaky timing issue), because the deferred send raced an already-canceled stream context.
Persister.flushNowstill flushesone shared batch across every connector pending in a debounce cycle; under this fix, a slow
flush for one connector now also delays other connectors' deferred acks in the same cycle, not
just their own write. Named in the design doc as inherited context, not a blocker.
What could this break / how would we know / rollback: the risk surface is entirely within
pkg/connector/{source,persister}.go; a regression would show up as either (a) the chaos suiteregressing to gap-producing (caught by
TestSIGKILL_PruningUpstream_NoGap), (b) acks silentlynever arriving at the plugin (caught by the FIFO-ack tests and would manifest as stalled upstream
commits / growing WAL in a real Postgres-slot deployment — no metric exists for this yet, tracked
as a follow-up per the design doc's Observability section), or (c) a deadlock on shutdown (caught
by
TestSource_Teardown_SendsPendingDeferredAckBeforeReturning, which is exactly how I found thestream-ordering bug in my own first draft). Rollback is a plain revert: no format/protocol change,
no migration.
MySQL/Mongo harness result
Per the design doc's explicit scope: this PR proves the fix gap-free against both a prune-class
synthetic upstream (
TestSIGKILL_PruningUpstream_NoGap, modeling a Postgres replication slot) and aretention-class synthetic upstream (
TestSIGKILL_DurableUpstream_NoGap, modeling a durable,replayable log such as Kafka) — both now pass with the identical assertion, proving the fix removes
the upstream-class-dependence entirely at the engine level. This does not verify the real MySQL
binlog or MongoDB oplog connectors (those don't exist yet per the Debezium-compete roadmap, DBZ-7)
— tracked as a required follow-up before claiming "fixed for all CDC," exactly as the design doc and
postmortem flag it. Not claiming more than this proves.
benchi / performance
Committed, not run:
benchi/ack-hot-path/(config, pipeline, two tool variants fororigin/mainvs this branch, README). The sandbox this was authored in hasbenchiinstallablebut no reachable Docker daemon, so the actual orchestrated run could not be executed — stated
plainly, not glossed over.
What I did run:
pkg/connector/source_bench_test.go'sBenchmarkSource_Ack, a same-sandboxGo microbenchmark of
Source.Ackin isolation, at-benchtime=20000x -count=3, both on thisbranch and on the same temporary pre-fix revert used for the fails-without-fix check:
The fix is faster per call in this microbenchmark, not slower — pre-fix pays a synchronous
stream
Send(channel rendezvous with the plugin reader) on every call; the fix only enqueuesand reuses the already-existing debounced persister. This is a single-function microbenchmark on
one machine, not an end-to-end throughput/latency number — the benchi run above is what would
actually gate a >10% regression claim per
CLAUDE.md, and it has not been run. Seebenchi/ack-hot-path/README.mdfor full numbers and exact reproduction steps.Adversarial self-review
around ack-vs-durability) and found a real one during implementation, not after: my first
Teardowndraft canceled the stream (stopStream) before forcing the final flush, whichdeadlocked (or, worse, silently dropped the ack) because the deferred send's
stream.Sendracedan already-canceled context. Caught by
TestSource_Teardown_SendsPendingDeferredAckBeforeReturninghanging, not by inspection — fixed by reordering flush-then-cancel, documented at length in
Teardown's doc comment so it isn't re-broken.Also required a Persister change (
WaitPendingWrites), and I re-checked that this newcallbackWgdoesn't affectDestination's unrelated use of the samePersister.Persist— itscallback is a trivial error-forward, so the extra wait is harmless there.
onPersistFlushedruns from a goroutine spawned byPersister.flushNowand needspreparePluginCall'sInstance.RLock—Teardownnever holdsInstance's exclusive lock across the flush-and-wait for exactly this reason (documented inline).pendingAcks/nextAckSeq/durableAckSeqare guarded by a dedicatedackMu, deliberatelyseparate from
Instance's lock, to avoid exactly this class of deadlock.pendingAcksun-drained (nothing acked, matchinginvariant 1) and propagates via
s.errs, unchanged from before this fix. A deferred send failure(e.g. a genuinely closed stream, not just a canceled context) is intentionally not escalated
via
s.errs— logged instead — because escalating it risks blocking that goroutine forever on anunbuffered channel nobody is guaranteed to still be draining mid-teardown; I hit exactly this as a
second real deadlock while developing the chaos test (the chaos harness's
runChilddoesn'tdrain
errs), fixed the same way.reader has finished draining before that.
Source.Teardown's double-teardown defense (re-checkings.plugin == nilafter each lock re-acquisition) is new relative to before, added because thereordering now releases
Instance's lock mid-function;funnel.Worker's ownteardownMualreadyserializes this in practice, but
Teardownis a public method and shouldn't rely on that alone.blast radius (failure mode 8 above) now extends from "delays other connectors' writes" to "delays
other connectors' acks too" — inherited from the existing
Persisterdesign, not introduced here,and already named in the design doc, but worth an explicit go/no-go rather than assuming it's
fine by inheritance alone.
Gates
make generate && git diff --exit-code: clean (no diff beyond this PR's own changes).golangci-lint run ./pkg/connector/... ./tests/chaos/...: 0 issues. (Repo-widegolangci-lint run ./...surfaces 36 pre-existing issues, all inpkg/plugin/processor/builtin/internal/diff/— a vendored Go-stdlib diff package — andpkg/foundation/cerrors/conduiterr— none in files this PR touches.)go build ./...: clean.go test -race -count=1 ./...(whole repo): all packages green.go test -race -run TestSIGKILL -count=3 ./tests/chaos/...: green, 3x for flake-safety(~88s/run).
-race:TestSource_Ack_DeferredUntilDurablyFlushed,TestSource_OnPersistFlushed_OutOfOrderCompletionStillDeliversInOrder,TestSource_Teardown_SendsPendingDeferredAckBeforeReturning.TestWorker_Ack_DeliversPositionsFIFOToV1Plugin,TestWorker_Nack_DeliversPartialAckFIFOToV1Plugin) — now take ~1s each (waiting on the realdebounce) instead of near-instant, since acks are genuinely deferred now.
TestSource_Ack_Deadlock(pre-existing, concurrent-Ack stress test) still passes under-race.TestAllCodesCompleteunaffected (ran green as part of the full suite).Risk tier
Tier 1 (data path:
pkg/connector/source.go,pkg/connector/persister.go, plus the chaosregression suite). Requires DeVaris Tier-1 sign-off before merge — per the standing rule, no
data-path change merges on automated review alone, and this session does not review or merge its
own PR.
Related
test(chaos): DBZ-1 SIGKILL crash-safety + engine FIFO-ack) — folds in itschaos harness and FIFO-ack test; this PR's
TestSIGKILL_PruningUpstream_NoGap/TestSIGKILL_DurableUpstream_NoGapreplace its..._ProducesGap/..._ProducesDuplicateNotGaptests.docs(design): Source.Ack persist-ordering fix + sev-0 postmortem(design doc + blamelesspostmortem this PR implements; approach sign-off required there first).
(
docs/design-documents/20260722-debezium-compete-roadmap.md) and closes Engine FIFO/in-order ack delivery to standalone connectors is unasserted (only a v1-client comment upholds it) #2672: the chaos suitenow proves the sev-0 fixed (for the two upstream classes the harness models) rather than merely
documenting it.
🤖 Generated with Claude Code
https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD