test/e2e: replay the control-plane outage as a chaos scenario, and repair the harness three ways - #931
Merged
Merged
Conversation
5 tasks
neochaotic
force-pushed
the
test/chaos-drill-scenario-d
branch
from
September 4, 2026 17:30
e10c302 to
880040b
Compare
The production incident was an ordering failure, and nothing reproduced it. Scenario C proves a durable outcome record is recovered with the control plane up; the failure needed the control plane DOWN across the pods' termination, so the restarted leader wakes to task instances that are running with stale heartbeats and pods that are terminal, and a reaper can judge them before the reconciler has read their records. Scenario D injects C's fault on three parallel tasks and SIGKILLs the scheduler before they finish. It asserts the recovery lands inside the leader-settling grace rather than merely eventually, that the gate was actually engaged (a leader asked to reap nothing proves nothing), that the liveness valve never opened, and that no reaper fired at all. Every step is fail-closed: a pod that exits 0 means the fault-injection seam did not fire, and an attempt that settles during the outage means the outage never covered the report window. Scenarios are now selectable with LEOFLOW_CHAOS_ONLY so D can be iterated on without paying for the others' sleeps.
Three faults, all found by running the new scenario rather than reading it. The setup ignored its own errors, so a control plane that died at boot sailed past the fixed sleep and surfaced minutes later as an unrelated-looking "no run id" from whichever scenario ran first. The api is now waited for, and a failure prints both role logs and names the likely cause; the token mint and every push fail loudly too. A DAG version is immutable and keyed by the repository's describe output, and the cluster is recreated per run while the control-plane database is not. So editing a scenario's DAG body and re-running was rejected with 409, and would otherwise have risked exercising the previous body under the same version. Each setup now deregisters its DAG first. Scenario D's tasks must not return a value: the agent pushes a return value BEFORE writing the durable outcome record, and that push has no deadline of its own, so with the control plane down an agent with a return value blocks in the gRPC reconnect and its pod stays Running through the entire outage. That is correct behaviour, and the opposite of what the scenario needs, which is a pod that terminates inside the outage leaving a record and no report.
…r's task env The durable-outcome scenario has been unable to inject its fault since the dispatch layer started stripping every LEOFLOW_-prefixed key an author declares (#828/#829) — a DAG must not be able to redirect the agent's credential exchange, and the fault seam was riding on exactly that channel. The agent reads the seam with os.Getenv at startup, so a stripped key means the hook is never installed and the task takes the ordinary report path. The scenario is fail-closed and would have caught it, but its message accused a stale agent image, which is the wrong cause and the wrong remedy. Nothing noticed because the harness is not gated in CI. Both scenarios now carry the seam as an ENV in their own DAG image, which nothing in the pod spec overrides, and both messages name the real alternative.
Four defects found by review, three of them in the assertions themselves. The grace comparison could not fail. The success wait was 150s while the grace it is compared against is 180s, so a measured success was always below it and the past-the-grace branch was dead code: a recovery that truly took 200s was reported as "did not reach success", never as "past the grace". The wait now outlasts the grace it judges, and every curl in the file carries a timeout, without which no deadline in the script means anything. The unsettled-during-the-outage precondition passed vacuously when its own read failed: an api hiccup printed nothing, "no success in the output" was then trivially true, and the precondition went unverified inside a green run. It now requires all three attempts, each in a state that is not yet settled. The Running check counted lines rather than distinct tasks, so three pods of one task would have satisfied it. The claim about the settling gate was stronger than the assertions support. The skip is recorded once per maintenance tick before any candidate is listed, and the first tick lands one interval after boot — by which time that same cycle's reconcile has already settled all three from their records. So the reap pass was held while the gate was shut, but the gate had nothing left to hold: what this scenario demonstrates is the cycle ordering, reconcile before reap, not the gate refusing a real candidate. Proving that needs a scenario that costs the reconciler a cycle. The header, the assertion text and the scope note now say so, along with the fact that the run ends before the grace opens. Scenario C's push was the one push still failing silently, which made the "every push fails loudly" claim untrue. The four counters now come from one metrics snapshot, and a scrape that does not contain the metric fails loudly instead of letting every counter assertion pass on a zero.
neochaotic
force-pushed
the
test/chaos-drill-scenario-d
branch
from
September 7, 2026 12:39
880040b to
e9edc0f
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.
The production incident was an ordering failure and nothing in the repository reproduced it. Scenario C proves a durable outcome record is recovered with the control plane up; the failure needed it down across the pods' termination, so that the restarted leader wakes to task instances that are
runningwith stale heartbeats and pods that are terminal, and a reaper can judge them before the reconciler has read their records.Scenario D injects C's fault on three parallel tasks and SIGKILLs the scheduler before they finish. Every step is fail-closed: a pod that exits 0 means the fault seam did not fire, and an attempt that settles during the outage means the outage never covered the report window. Scenarios are selectable with
LEOFLOW_CHAOS_ONLYso D can be iterated without paying for the others' sleeps.Measured on k3d against this branch, rebased onto
mainatd5cef2d(with #925, #926, #927 and #932 all in):What this proves, stated precisely. The maintenance cycle's ordering: reconcile runs before reap inside one cycle, so by the time the reap pass is consulted the same cycle has already settled all three attempts from their records. That ordering is what broke in production.
What it does not prove, so the assertions are not oversold. It does not prove the settling gate refusing a real candidate: the skip is recorded once per tick before any candidate is listed, and the first tick lands one interval after boot, by which point the reconcile has already settled everything — build the same tree with
SettlingGraceat 0 and every behavioural assertion here still passes, only that instrument goes red. Proving the gate itself needs a scenario that costs the reconciler a cycle so a candidate still exists at reap time. It also ends before the grace opens, so "no reaper fired" is scoped to that window and says nothing about the window in which the reapers are finally allowed to act. And it does not prove #915's presence rule orexecution_timeout(#930).Writing it surfaced three defects in the harness, none in production code, and review found four more in the assertions. All of them were the same failure: a test that had quietly stopped testing anything.
The fault seam had been undeliverable since 2026-08-31. Dispatch strips every
LEOFLOW_-prefixed key an author declares, so a DAG cannot redirect the agent's credential exchange (#828, fixed in #829) — and the seam rode exactly that channel, so the hook was never installed and the task took the ordinary report path. Scenario C is fail-closed and would have caught it, but its message accused a stale agent image, the wrong cause and the wrong remedy. Both scenarios now carry the seam as anENVin their own DAG image, which nothing in the pod spec overrides. Scenario C passes again, which is how the diagnosis was confirmed.The setup ignored its own errors. A control plane that died at boot sailed past a fixed
sleep 5and surfaced minutes later as an unrelated-looking "no run id". The api is now waited for, and a failure prints both role logs and names the likely cause; the token mint and every push fail loudly, C's push included.The harness was not re-runnable. A DAG version is immutable and keyed by the repository's describe output, and the cluster is recreated per run while the control-plane database is not — so editing a scenario's DAG body and re-running was rejected with 409, and would otherwise have risked exercising the previous body. Each setup now deregisters its DAG first.
The assertions themselves had three defects. The grace comparison could not fail: the success wait was 150s against a grace of 180, so a measured success was always below it and the past-the-grace branch was dead code. The unsettled precondition passed vacuously when its own read failed, verifying nothing inside a green run. And the Running check counted lines rather than distinct tasks. Every
curlnow carries a timeout, without which no deadline in the script means anything, and the four counters come from one snapshot with a loud failure when the metric is absent.One behavioural note the scenario had to encode: its tasks must not return a value. The agent pushes a return value before writing the durable outcome record, and that push has no deadline of its own, so with the control plane down an agent with a return value blocks in the gRPC reconnect and its pod stays
Runningthrough the entire outage. That is correct behaviour and the opposite of what this scenario needs. It also means that after #897 a task finishing during an outage keeps its pod alive and retries, so killing the control plane alone no longer reproduces the field failure — the seam is what forces the pod to end inside the outage.Test-only change, hence
skip-changelog. Not gated in CI (destructive, about 12 minutes; D alone about 5).