Skip to content

PR 11 — Advanced failure and concurrency validation - #32

Merged
batbrainy merged 1 commit into
mainfrom
pr-11-advanced-failure-concurrency-validation
Jul 31, 2026
Merged

PR 11 — Advanced failure and concurrency validation#32
batbrainy merged 1 commit into
mainfrom
pr-11-advanced-failure-concurrency-validation

Conversation

@batbrainy

@batbrainy batbrainy commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Linked issue

Closes #21IMPLEMENTATION_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/worker containers 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.md both 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.md is 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 + 10 examples green, including PR 9's spec/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 rspec with no path arg and no tag exclusion against a bare postgres:16 service 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 kill is an API stop: 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, all three services stayed down with RestartCount at 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, brought db, web and worker back 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 duration cannot; and key agreement comes from SourceProvisioner, not from any concurrency primitive. A grep example asserts no job declares limits_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 real Fairness, Claim and BudgetLedger — and deliberately not the transport, whose sticky tail would hand every flood row octocat's body and produce 57 identity failures that obscure the sequence.

concurrent_write_spec.rb turns 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. An around hook is not a workaround: its ensure still runs inside the fixture transaction, so cleanup would roll back with everything else.

Review fixes

All four blockers from the first round are addressed.

  1. Stale compose assertion. The test-service expectation is now structural — bash -c, starts with bin/rails db:test:prepare, includes bundle exec rspec, never mentions the development db:prepare — instead of an equality against a frozen command string. PR 9 appending bundle exec rspec spec/stress is exactly the failure mode that motivated it, and the comment says so.
  2. Isolation phase reported a failure as prose. It now stops the worker for the duration of the measurement and 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 moves push_events and solid_queue_jobs whether 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,293 and 1,703 → 1,703.
  3. Redirect scenarios were swallowed with || 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_repositorycomplete, hostile_redirectpermanent_failure.
  4. Trailing whitespace. git diff --check is 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 test command 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.yml pins the project name, so sibling worktrees share github_push_ingestor_test and 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:

  • Removing event_source.reload from IngestionRunner#call fails 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.
  • Swapping 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 abandoned running run 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 new spec/docker_compose_spec.rb examples 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" to bin/rails server must fail there), the test service's isolation, and that no service leaves restart implicit.

Docker verification

script/verify_recovery.sh --confirm against a fixture-mode stack; transcript committed at docs/evidence/2026-07-31-container-kill-recovery.md. All 19 checks pass. push_events read 9,293 before and after every kill, restart and recovery; the pgdata volume is identical at both ends, so those counts are survival rather than recreation; docker compose run --rm test left both development databases byte-identical and did not trigger setup; 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 reaching api.github.com.

The script refuses under CI, without --confirm, against a worker not in fixture mode, with RAILS_ENV set to anything but development, or when container names do not match §15's literals. It never runs docker compose down, never drops a database, never issues psql against a _test database, 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.md names what consumes the redirect scenarios; poll_event_source_job.rb's "Revisit in PR 11" comment now points at the answer.

Known limitations

  • The container-kill result is not machine-verified on every push. A compose change that breaks recovery at runtime without changing the YAML would go unnoticed until the transcript is re-run; the new docker_compose_spec.rb examples exist to shrink that window.
  • The transcript is one host, one OS, one Docker version, one date. unless-stopped is not exercised across a daemon restart or a host reboot, and a SIGKILL to postgres exercises WAL crash recovery, not disk corruption.
  • No kill was timed inside an in-flight push_events insert; that guarantee is not observable from outside the process and is asserted by spec/recovery/crash_window_spec.rb instead.
  • The redirect scenarios are exercised against one seeded row, so the transcript shows the outcome for that repository rather than a property of the selection policy; the policy is asserted in the spec.
  • Counts were read as deltas, not the README's documented absolutes, because this database was not started from an empty volume. The script detects and reports which mode it used.
  • Processing remains at-least-once execution with effectively-once persisted outcomes. No exactly-once claim.

🤖 Generated with Claude Code

@batbrainy batbrainy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread spec/docker_compose_spec.rb Outdated

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" ]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread script/verify_recovery.sh Outdated
[ "$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\`."

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread script/verify_recovery.sh Outdated
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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@batbrainy
batbrainy force-pushed the pr-11-advanced-failure-concurrency-validation branch from d0bb947 to ba113f8 Compare July 31, 2026 12:01
@batbrainy
batbrainy merged commit cb88ffb into main Jul 31, 2026
2 checks passed
@batbrainy
batbrainy deleted the pr-11-advanced-failure-concurrency-validation branch July 31, 2026 12:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PR 11 — Advanced failure and concurrency validation

1 participant