PR 11 — Advanced failure and concurrency validation - #32
Conversation
batbrainy
left a comment
There was a problem hiding this comment.
I reviewed this against the current main after PR #31. CI is green on the older base, but the merged tree still has blockers: one spec is stale against the current Compose test command, and the recovery transcript records failures while the summary says those checks passed. git diff --check also reports trailing whitespace in the evidence file.
|
|
||
| it "prepares its own isolated databases before running the suite" do | ||
| expect(test.fetch("command")).to eq( | ||
| [ "bash", "-c", "bin/rails db:test:prepare && bundle exec rspec" ] |
There was a problem hiding this comment.
Since PR #31 already changed the test service command to bin/rails db:test:prepare && bundle exec rspec && bundle exec rspec spec/stress, this exact assertion will fail as soon as this branch is merged on top of current main. CI is green on the older base, but the post-merge tree is red. Please update this expectation to include the stress invocation, or assert the important pieces without freezing the pre-PR31 command.
| [ "$before_setup" = "$after_setup" ]; then | ||
| echo "Both development databases are untouched and the \`setup\` service did not run." | ||
| else | ||
| echo "MISMATCH: the suite changed development state or ran \`setup\`." |
There was a problem hiding this comment.
This phase treats a failed isolation measurement as transcript text and keeps going. The committed run actually hits this branch: push_events changes from 263 to 353 and queue jobs from 56 to 62, while the summary claims the isolation gate held. For a verification script, this needs to exit nonzero on mismatch and the evidence should be rerun from a passing state.
| echo "### redirecting_repository — a validated redirect is followed" | ||
| echo '```' | ||
| GITHUB_MODE=fixture GITHUB_FIXTURE_SCENARIO=redirecting_repository \ | ||
| docker compose run --rm enrich --limit 1 --class repository 2>&1 | redact || true |
There was a problem hiding this comment.
These scenario commands are swallowed with || true, and the committed transcript shows both redirect runs ending in Fixture corpus error rather than exercising the redirect outcomes. That leaves the “both redirect corpus scenarios ran end to end” claim unsupported. The script should seed or select the intended repository row and fail if either scenario does not reach the expected redirect result.
Adds the validation tier IMPLEMENTATION_PLAN.md §13 assigns to PR 11. No product behavior changes; app/ is touched only to correct one stale forward reference. The work splits on one line: CI proves every claim that is a property of a file or of the application's own logic, and a dated reviewer transcript proves every claim that is a property of the Docker runtime. Nothing straddles, because CI runs `bundle exec rspec` with no tag exclusion against a bare postgres service and has no Compose stack. §15 step 8's own command does not do what the plan assumes, and that is the headline finding. `docker kill` is an API stop, so the daemon records the container as manually stopped and `restart: unless-stopped` is defined to skip exactly that case: measured against Docker 28.3.0, every killed service stayed down with RestartCount at 0. A reviewer following the plan literally would conclude §16's durability gate is unmet. The policy is sound — it is the kill that does not exercise it. A SIGKILL delivered to the container's main process from outside its PID namespace, which the daemon does not attribute to an operator, brought db, web and worker back on their own. script/verify_recovery.sh performs both kills and asserts both outcomes. The script records a verdict per check and exits non-zero if any fails, so a committed transcript and a green exit code cannot disagree. Two measurements needed that discipline to be correct rather than merely reported. Test isolation now stops the worker for the duration: continuous polling is the worker's whole job, so a fixture poll landing mid-suite moves push_events and solid_queue_jobs whether or not the suite touches them, and an earlier revision recorded 263 -> 353 while printing that the gate held. The redirect scenarios now force selection onto the corpus repository first, because §10 picks the newest eligible candidate and on a database that has polled live GitHub that is a real repository the corpus has never heard of — so an earlier revision watched both scenarios die on a corpus gap while `|| true` swallowed the exit code. ADR 0008 asked PR 11 to measure whether Solid Queue concurrency limits keyed by source id close a gap the source lock leaves. They do not, and the verdict is now recorded with four observations: five contended ticks produce no run row, request, debit or schedule movement; a contended tick issues no INSERT, UPDATE or DELETE at all, so a semaphore would spend writes to prevent writes that never happen; a killed session's lock frees in milliseconds where a fixed duration cannot; and key agreement comes from SourceProvisioner rather than from any concurrency primitive. An example asserts no job declares limits_concurrency, so reversing the decision fails a test. Two new examples pin behavior nothing pinned before, verified by breaking the code and watching only them fail. Removing IngestionRunner's in-lock `event_source.reload` fails four multi-poller examples and nothing else — that reload is what stops two pollers who both read a stale cadence from polling back to back, which ADR 0006 rejected an alternative to protect. Swapping PageWriter's actor-before-repository upsert order fails exactly one, asserted on statement order because both upserts share a transaction and the positional observable is identical either way. Fairness is measured over a whole window rather than one choice: against a 60:3 repository flood the three actors all enrich, borrowing begins only after the last actor choice, and forty real denials write nothing global. Crash windows are expressed as the durable state a kill leaves — an abandoned `running` run row that nothing resurrects, a page committed one envelope deep and replayed, and all three crash signatures composed into one restart. spec/recovery/concurrent_write_spec.rb turns transactional fixtures off, because what it tests is what one session can see of another's commit. The two redirect corpus scenarios are consumed at last, through the enrichment claim path: a rename completes and is debited for both hops, a hostile Location reaches permanent_failure with the second hop never sent and the event source still in service. The compose test-service assertion is structural rather than an equality against a frozen command string, so a later step appended to that pipeline — as PR 9 appended `bundle exec rspec spec/stress` — cannot turn a green branch red on merge. Closes #21 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
d0bb947 to
ba113f8
Compare
Linked issue
Closes #21 —
IMPLEMENTATION_PLAN.md§13's PR 11 — Advanced failure and concurrency validation.Problem
PRs 3–8 shipped the required failure-path tests. What the plan still claims and nothing demonstrates is the tier above them: that the guarantees §8 and §9 state survive adversarial timing, and that §16's durability gate — "Docker restart policies recover crashed
db/web/workercontainers automatically (verified by container kills)" — is met by an actual kill rather than by a line of YAML.Three commitments named this PR by name and were still open: PR 8 (#29) deferred "container-kill e2e, multi-poller stress, crash-window matrix"; ADR 0008 rejected Solid Queue concurrency limits keyed by source id and wrote "revisit when PR 11's multi-poller tests can measure a gap"; and the README and
fixtures/github/README.mdboth promised the redirect corpus scenarios here, which nothing had ever consumed.Scope
In: crash-window tests (B9's test half); multi-poller concurrency tests (§9) including ADR 0008's measurement; class-isolation and fairness stress including starved-class enrichment (S3.9's stress part); fixture-mode fail-closed and container-kill verification (D12); the two orphaned redirect scenarios.
Deliberately out: PR 12's README completion and clean-checkout verification.
IMPLEMENTATION_PLAN.mdis unedited — it is frozen, and this PR honours §15 rather than amending it.app/carries one comment-only change, correcting a forward reference this PR resolves.Rebased onto current
main(PR 9 + PR 10).1,684 + 10examples green, including PR 9'sspec/stress.Technical decisions
The split. CI proves every claim that is a property of a file or of the application's own logic; a dated reviewer transcript proves every claim that is a property of the Docker runtime. Nothing straddles — CI runs
bundle exec rspecwith no path arg and no tag exclusion against a barepostgres:16service and has no Compose stack, so a Docker-dependent example would break it.§15 step 8's command does not exercise the restart policy.
docker killis an API stop: the daemon records the container as manually stopped, andrestart: unless-stoppedis defined to skip exactly that case. Measured against Docker 28.3.0, all three services stayed down withRestartCountat 0 — a reviewer following the plan literally would conclude the gate is unmet. The policy is sound; it is the kill that does not test it. A SIGKILL to the container's main process from outside its PID namespace, which the daemon does not attribute to an operator, broughtdb,webandworkerback unaided. The script performs both kills and asserts both outcomes, so a future Docker version that changes either behaviour fails the run. An in-container kill is not an alternative — the kernel refuses SIGKILL to a PID namespace's own init from inside it — and the helper needs--user 0, because the app image's non-root user cannot signal postgres.The script has a verdict, not a narration. Each check records PASS/FAIL, the transcript carries the list, and the process exits non-zero if any failed — so a committed transcript and a green exit code cannot disagree. Two measurements needed that discipline to be correct rather than merely reported, and both were wrong in the first revision (see "Review fixes" below).
ADR 0008's measurement, with a recorded verdict. No gap. Five contended ticks produce no run row, request, debit or schedule movement; a contended tick issues no INSERT, UPDATE or DELETE at all, so a semaphore would spend writes to prevent writes that never happen; a killed session's lock frees in milliseconds where a fixed
durationcannot; and key agreement comes fromSourceProvisioner, not from any concurrency primitive. A grep example asserts no job declareslimits_concurrency, so reversing the decision fails a test. Bounded honestly: this covers correctness and local cost, not semaphore behaviour under N real worker containers.Fairness measured over a window, not a choice. Every existing fairness spec asks for one choice against counters set by
update_all. None spends a window, so none can express §10's actual claim. The drain runs the realFairness,ClaimandBudgetLedger— and deliberately not the transport, whose sticky tail would hand every flood rowoctocat's body and produce 57 identity failures that obscure the sequence.concurrent_write_spec.rbturns transactional fixtures off, the second file in the suite to do so, because what it tests is what one session can see of another's commit. Anaroundhook is not a workaround: itsensurestill runs inside the fixture transaction, so cleanup would roll back with everything else.Review fixes
All four blockers from the first round are addressed.
test-service expectation is now structural —bash -c, starts withbin/rails db:test:prepare, includesbundle exec rspec, never mentions the developmentdb:prepare— instead of an equality against a frozen command string. PR 9 appendingbundle exec rspec spec/stressis exactly the failure mode that motivated it, and the comment says so.checks each observable. The original numbers were not a leak — continuous polling is the worker's whole job, so a fixture poll landing mid-suite movespush_eventsandsolid_queue_jobswhether or not the suite touches them. With the only other writer stopped, a difference can mean nothing except the suite. Re-measured:9,293 → 9,293and1,703 → 1,703.|| true. They now force selection onto the corpus repository before running — §10 picks the newest eligible candidate, and on a database that has polled live GitHub that is a real repository the corpus has never heard of, which is why both previously died on a corpus gap. Each asserts its terminal status and fails the run otherwise:redirecting_repository→complete,hostile_redirect→permanent_failure.git diff --checkis clean across the branch.The transcript was regenerated from a passing state after all three fixes: 19 checks, all passing, exit 0.
Testing performed
1,684 examples + 10 stress examples, 0 failures — bind-mounted and again against a freshly built image with no bind mount, running the compose
testcommand as-is. RuboCop clean across 230 files, Brakeman 0 warnings, bundler-audit clean. The suite must be run in its own Compose project:docker-compose.ymlpins the project name, so sibling worktrees sharegithub_push_ingestor_testand a concurrent run deadlocks it into a scattered, non-reproducible failure set.Two examples were verified by breaking the code and confirming only they fail:
event_source.reloadfromIngestionRunner#callfails 4 multi-poller examples and nothing else. That reload is what stops two pollers who both read a stale cadence from polling back to back — ADR 0006 rejected an alternative specifically to protect it, and nothing tested it.PageWriter's actor-before-repository upsert order fails exactly 1. Asserted on statement order, because both upserts share one per-envelope transaction and the positional observable is identical either way — the first attempt at this example passed under both orders and was rewritten.New coverage, by file:
spec/recovery/multi_poller_spec.rb(the stale-cadence race, the gate across both request paths including the enrichment lease being restored exactly, ADR 0008's four observations);spec/recovery/crash_window_spec.rb(an abandonedrunningrun row nothing resurrects, a page committed one envelope deep then replayed,finish's write order, and all three crash signatures composed into one restart);spec/recovery/concurrent_write_spec.rb;spec/services/github/enrichment/fairness_stress_spec.rb;spec/services/github/fixture_mode_fail_closed_spec.rb(fail-closed proved through the real chain with a throwaway corpus, rather than by stubbing the runner to raise);spec/services/github/enrichment/redirect_boundary_spec.rb; and newspec/docker_compose_spec.rbexamples covering the declarations the recovery claim rests on — the named volume, the healthcheck's-h 127.0.0.1,web's puma command (a future "simplification" tobin/rails servermust fail there), thetestservice's isolation, and that no service leavesrestartimplicit.Docker verification
script/verify_recovery.sh --confirmagainst a fixture-mode stack; transcript committed atdocs/evidence/2026-07-31-container-kill-recovery.md. All 19 checks pass.push_eventsread 9,293 before and after every kill, restart and recovery; thepgdatavolume is identical at both ends, so those counts are survival rather than recreation;docker compose run --rm testleft both development databases byte-identical and did not triggersetup; and fixture mode failed closed in the running stack — this database holds entities from earlier live polls, and the baseline cycle refused with a corpus gap and exit 2 rather than reachingapi.github.com.The script refuses under
CI, without--confirm, against a worker not in fixture mode, withRAILS_ENVset to anything butdevelopment, or when container names do not match §15's literals. It never runsdocker compose down, never drops a database, never issues psql against a_testdatabase, and redacts logins, repository names and avatar URLs — a development database that has polled live GitHub holds real third-party identifiers no finding needs. The fixture-mode guard fired for real during development, refusing a run started against a live worker.Documentation updates
README gains Crash recovery, verified and the nine-row fixture scenario matrix, replacing the disclaimer that container-kill verification "lands with PR 11; nothing above claims it" and the two now-delivered rows of the Planned-contents table. ADR 0008 records the measurement and its verdict;
fixtures/github/README.mdnames what consumes the redirect scenarios;poll_event_source_job.rb's "Revisit in PR 11" comment now points at the answer.Known limitations
docker_compose_spec.rbexamples exist to shrink that window.unless-stoppedis not exercised across a daemon restart or a host reboot, and a SIGKILL to postgres exercises WAL crash recovery, not disk corruption.push_eventsinsert; that guarantee is not observable from outside the process and is asserted byspec/recovery/crash_window_spec.rbinstead.🤖 Generated with Claude Code