fix(chaos): bound property2 kill points by construction, not by a race - #2840
Merged
Conversation
Fixes #2836. tests/chaos/property2_test.go carried the same unsound pattern #2835 fixed in recovery_test.go: all six subtests (3 kill windows x 2 upstream classes) watched a FREE-RUNNING child's stdout and then SIGKILLed it. The child kept producing while the parent was descheduled, so an observed read count said nothing about where the child was when the kill landed. Failure-mode analysis (what this fixes, reproduced on demand): with a 2.5s stall injected between the kill gate and the SIGKILL, against the pre-fix test, all six subtests fail or hang - mid-snapshot (both classes): HANG ~42.6s ("timed out waiting for child to exit") - the starvation its own comment describes; mid-handoff (both classes): FAIL "got 164" - resumeEmpty asserted, but a flush landed and a non-zero position was persisted; mid-position-write (both classes): FAIL "got 192" - the stale guard (resumePos < killAfterReads) blown through by the free-runner. The fix gives every kill point one observed LOWER bound and one STRUCTURAL upper bound, so the verdict no longer depends on when the kill lands: - resumeEmpty cases: 600s persister debounce (the #2534 pattern) makes "nothing durably persisted at kill time" structural - no flush can ever land before the kill, however long the parent stalls; holdAt caps production below total so the child can never finish and exit before the kill (its read loop blocks forever). - resumeValidNonZero: the kill is gated on the child's DURABLE upstream commit watermark (waitForUpstreamCommittedAtLeast), not a read count; holdAt caps what the child can ever commit, and the stale guard is restated structurally as resumePos <= holdAt. - New regression test TestChild_HoldAt_CapsProductionBelowTotal pins the ceiling itself: wait for the HELD marker, stall 400ms adversarially, assert nothing past the ceiling was produced or committed. - parseChildEnv rejects invalid combos at child startup (holdAt >= total exits exitBadArgs: a cap at/above total bounds nothing). Verified: - Stall-injected (2.5s, the injection that fails all six pre-fix subtests): all six subtests + the regression test PASS. - Perturbation proof: cap disabled in produceLoop - regression test FAILS (HELD marker never appears) and mid-position-write FAILS (resume 129 > ceiling 100). The bound is load-bearing, not decorative. - go test -race -v -count=3 -shuffle=on ./tests/chaos/... (the chaos.yml invocation): PASS, 368s. - golangci-lint (v2.12.2, per tools/go.mod): clean. go vet: clean. Rollback: revert this commit; tests only, no engine code touched. A regression surfaces as a chaos-suite failure, which is a required CI context.
Comment-only honesty fixes from PR #2840's independent review; no code semantics changed (one vacuous assertion removed): - The mid-position-write case no longer claims a stale (behind the kill point) checkpoint with in-flight records: the watermark-gated kill lands on a flushed state where produced == acked == committed, and the in-flight window is empty by construction. The comment now states the flush-boundary semantics and records that losing the in-flight window was the deliberate cost of bounding the kill on the watermark, with DBZ-1's sigkill mid-stream case as the intended home of that scenario. - The 'cap removal fails immediately' claim was not load-robust: under the very descheduling this PR targets, a flush can cover as few as just over 70 records, so an uncapped run can pass the case-level resumePos <= holdAt assertion. Comments now state the airtight bracket argument (watermark is always in [killAfterCommitted, holdAt]) and cite the regression test's HELD-marker wait as the deterministic cap-removal detector, with the case-level assertion labeled a soft detector. - Dropped the regression test's committed <= holdAt assertion, which was vacuous at its timing (default 1s debounce, ~480ms elapsed: committed is 0 by construction), documenting where the committed side is pinned.
devarismeroxa
added a commit
that referenced
this pull request
Aug 29, 2026
…2841) The mid-stream SIGKILL case estimated its crash window with arithmetic (killAfterReads=95 at paceMS=15: one ~1s flush landed, a second pending) then SIGKILLed. Under parent descheduling the child keeps producing through the observation-to-signal gap, so the crash lands far past the window the case claims to test - and since every landing point yields the same gap-free verdict, the case passes vacuously and never flakes (the identical defect #2836 diagnosed for property2). Apply the #2835/#2840 bracket pattern exactly: - Lower bound (observed): gate the kill on the child's durable upstream commit watermark reaching killAfterCommitted=70 (waitForUpstreamCommittedAtLeast) instead of parent-observed READ progress - the watermark only moves when a flush lands, so the gate cannot fire before a checkpoint is durably persisted. - Upper bound (structural): holdAt=100 caps the first child's production via #2840's CONDUIT_CHAOS_HOLD_AT seam (merged on main after this branch started - reused, not duplicated). Nothing past it is ever produced, acked, or committed, and the capped child never exits on its own, so the kill can never miss a dead process. - Every value in [70, 100] yields the identical verdict. - Preconditions asserted, not trusted: a kill landing outside the bracket, or a resume position past the ceiling, fails the test. The deterministic cap regression test is #2840's TestChild_HoldAt_CapsProductionBelowTotal (property2_test.go) - this PR adds no duplicate. Proven: (a) pre-fix + 2.5s stall passes vacuously with the kill point blown through (watermark 192/193, produced 254/255 vs the claimed ~95-133 window; a temporary bracket assertion fails both prune classes deterministically - the silent window-skip); (b) post-fix + same stall passes on both prune classes; (c) cap removed fails deterministically on both (HELD-marker timeout, and precondition watermark 257 > holdAt 100). Verification: go test -race -count=3 -shuffle=on ./tests/chaos/... green (362.5s), golangci-lint v2.12.2 clean, go vet clean.
devarismeroxa
added a commit
that referenced
this pull request
Aug 29, 2026
…2841) (#2842) The mid-stream SIGKILL case estimated its crash window with arithmetic (killAfterReads=95 at paceMS=15: one ~1s flush landed, a second pending) then SIGKILLed. Under parent descheduling the child keeps producing through the observation-to-signal gap, so the crash lands far past the window the case claims to test - and since every landing point yields the same gap-free verdict, the case passes vacuously and never flakes (the identical defect #2836 diagnosed for property2). Apply the #2835/#2840 bracket pattern exactly: - Lower bound (observed): gate the kill on the child's durable upstream commit watermark reaching killAfterCommitted=70 (waitForUpstreamCommittedAtLeast) instead of parent-observed READ progress - the watermark only moves when a flush lands, so the gate cannot fire before a checkpoint is durably persisted. - Upper bound (structural): holdAt=100 caps the first child's production via #2840's CONDUIT_CHAOS_HOLD_AT seam (merged on main after this branch started - reused, not duplicated). Nothing past it is ever produced, acked, or committed, and the capped child never exits on its own, so the kill can never miss a dead process. - Every value in [70, 100] yields the identical verdict. - Preconditions asserted, not trusted: a kill landing outside the bracket, or a resume position past the ceiling, fails the test. The deterministic cap regression test is #2840's TestChild_HoldAt_CapsProductionBelowTotal (property2_test.go) - this PR adds no duplicate. Proven: (a) pre-fix + 2.5s stall passes vacuously with the kill point blown through (watermark 192/193, produced 254/255 vs the claimed ~95-133 window; a temporary bracket assertion fails both prune classes deterministically - the silent window-skip); (b) post-fix + same stall passes on both prune classes; (c) cap removed fails deterministically on both (HELD-marker timeout, and precondition watermark 257 > holdAt 100). Verification: go test -race -count=3 -shuffle=on ./tests/chaos/... green (362.5s), golangci-lint v2.12.2 clean, go vet clean.
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.
Fixes #2836. Tier 1 (data-path chaos tests).
What was wrong
tests/chaos/property2_test.gocarried the same unsound pattern #2835 fixed inrecovery_test.go: all six subtests (3 kill windows x 2 upstream classes) watched a free-running child's stdout and then SIGKILLed it. The child kept producing while the parent was descheduled, so "I last saw N READs" said nothing about where the child was when the kill landed.Failure-mode analysis — reproduced on demand, pre-fix
2.5s stall injected between the kill gate and the SIGKILL, against the pre-fix test (the issue's injection, same mechanism):
resumePos < killAfterReads) blown through by the free-runnerA stall is the failure mode here: the parent being descheduled is exactly what makes a lagging observation unsound, so "make it bigger and it passes" is the bug, not the fix.
The fix — every kill point now has one observed LOWER bound and one STRUCTURAL upper bound
persistDelayMS=600s(the Flaky test suite: multiple order/timing-dependent failures make CI green unreliable #2534 pattern — no flush can ever land before the kill, however long the parent stalls) andholdAt=80(the fix(chaos): bound the recovery kill point by construction, not by a race #2835 pattern — the child can never reach total, so it can never finish and exit before the kill; its read loop blocks forever, so the kill can never miss an exited process).waitForUpstreamCommittedAtLeast) — a flush has provably landed, so the resume position is provably non-zero. The watermark only moves when a flush lands, so the kill lands on a flushed state where produced == acked == committed: the checkpoint equals the watermark, which is exactly the no-gap equality the shared assertions check. The in-flight window is empty by construction — losing it is the deliberate cost of bounding the kill on the watermark; crash-with-records-in-flight remains the job of DBZ-1's mid-stream case (sigkill_test.go). Upper bound:holdAt=100— nothing past the ceiling is ever produced, so nothing past it can ever be acked or committed, and the resume position can never catch up past it however long the parent stalls. The oldresumePos < killAfterReadsguard is restated structurally asresumePos <= holdAt.TestChild_HoldAt_CapsProductionBelowTotalpins the ceiling itself (fix(chaos): bound the recovery kill point by construction, not by a race #2835's pattern): wait for theHELDmarker, stall 400ms adversarially, assert nothing past the ceiling was produced.parseChildEnvrejects invalid combos at child startup:holdAt >= totalexitsexitBadArgs(a cap at/above total bounds nothing — the child would stop at total on its own).Every value in the bracketed interval yields the identical verdict. 70/100 is a bracket, not a tuning: the gate cannot fire before the watermark reaches 70, and the ceiling means the watermark can never exceed 100 — the kill always lands with the watermark in [70, 100]. On an unloaded machine the gate fires at the second flush (a 15ms-paced flush covers ~66 records, so 66 < 70 <= ~132), but under the very descheduling this PR targets, production slows and the gate can fire at a later flush with coverage as low as just over 70 — so the case-level
resumePos <= holdAtassertion is only a soft cap-removal detector; the deterministic detector is the regression test's HELD-marker wait (an uncapped child never prints it, on any machine).Verified
produceLoop): the regression test FAILS deterministically (HELD marker never appears — timeout); on this machine mid-position-write also FAILS (resume 129 > ceiling 100, the expected ~132 uncapped flush-2 coverage). The bound is load-bearing, not decorative.go test -race -v -count=3 -shuffle=on ./tests/chaos/...(the chaos.yml invocation): PASS, 368s; re-run after review-comment fixes: PASS (see below).go vet: clean.Review round 2 (independent review, no blockers)
persistDelayMS=600_000not vacuous (persister's only mid-run triggers are the 1s timer and a 10k-bundle threshold — capped production can't hit 10k, SIGKILL bypasses the stopped-flush, resumed child runs the real debounce; same profile Flaky test suite: multiple order/timing-dependent failures make CI green unreliable #2534 shipped); regression-test mechanism verified (cap check is after send, before pace sleep; HELD never prints without the cap).committed <= holdAtassertion in the regression test (at ~480ms elapsed the default 1s debounce has not fired, so committed is 0 by construction), documenting why the committed side is pinned elsewhere.go test -race -v -count=3 -shuffle=on ./tests/chaos/...green.Failure-mode analysis of the change itself
What could this break: nothing in the data path — this touches only the chaos harness and test-scenario knobs;
pkg/connectorandpkg/pipelineare untouched. The env seam (CONDUIT_CHAOS_HOLD_AT) is consumed only by the child under test; every scenario that does not set it gets 0 (inert, identical behavior). A regression would surface as a chaos-suite failure — the suite runs on every PR touchingtests/chaos/and is a required CI context. Rollback: revert the commit.Honest limits
With a watermark-gated kill, the SIGKILL lands at a flush boundary: the mid-position-write checkpoint equals the watermark (the no-gap equality asserted), and the in-flight window is empty by construction — bounded by the ack plumbing at the flush instant, not by how long the parent was descheduled. "Mid-production with deep in-flight" is arithmetically impossible at 15ms pace and is covered by DBZ-1's mid-stream case instead. The verdict — valid non-zero resume in [70, 100], no gap, at-least-once through total — is unchanged and structural. The pre-fix "got 164"/"got 192"/hang reproductions were run with the pre-fix test; the pre-fix and post-fix numbers are not directly comparable because the test's expectations changed by design.
🤖 Generated with Claude Code