PR 11 — Advanced failure and concurrency validation - #32
Open
batbrainy wants to merge 1 commit into
Open
Conversation
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 reports both rather than quietly substituting the one that works. 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 in 1,487 — 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. Closes #21 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
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 9's multi-source allocation, shared-IP reconciliation and budget observability; PR 10's
/statusand extended logging; 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.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. Tagging and excluding was rejected because it needs.rspec,ci.ymlandconfig/ci.rball edited and any one drifting silently re-enables it; abin/harness and a rake task were rejected on the groundsscript/probe_304.shalready states about itself.§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 reports both. 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.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.Testing performed
1,487 examples, 0 failures — bind-mounted and again against a freshly built image with no bind mount. RuboCop clean, Brakeman 0 warnings, bundler-audit clean. Green across three seeds; the suite must be run in its own Compose project, since sibling worktrees share
github_push_ingestor_testand a concurrent run deadlocks it.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 18 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.push_eventsread 263 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 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 I had 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