PR 8 — Background processing and recovery - #29
Merged
Conversation
Makes the system run by itself, and proves it recovers (IMPLEMENTATION_PLAN.md §13's PR 8). Solid Queue runs in its own `queue` database inside the same PostgreSQL container, a `worker` container runs its supervisor, and two recurring tasks fire every 60 seconds. A tick is not a poll. PollEventSourceJob pre-filters with EventSource.poll_due — mode-scoped, enabled, idle, projection arrived — and Github::IngestionRunner still re-reads each source inside its advisory lock and applies §9's five components. At the 300-second cadence four ticks in five cost one indexed SELECT and open no run row. A busy source is INFO and a completed tick, never a failed execution: §2A's poller attempts once, and the next tick is 60 seconds away. Enrichment is scheduled twice over, deliberately. IngestionRunner#call dispatches once per run that created events, after every row is committed and the source lock is released; ReconcilePendingEnrichmentsJob sweeps the committed entity rows every 60 seconds. The enqueue is a hint and the entity rows are the durable record, so work committed before a crash but never enqueued is rediscovered within a minute with no cleanup step. Both go through Github::Enrichment::Dispatch, which schedules at most one cycle per class — the runner picks the entity by fairness under a lease, so N enqueues carry no more information than one, and queue depth is set by §10's hourly allowance rather than by arrival rate. No retry_on anywhere: PollState and EntityState already own durable, ledger-aware retry ladders, and a second uncoordinated one would spend the allowance twice on the same failure. ApplicationJob adds §11's job fields — job_id, job_class, queue, attempt, duration_ms — so a reviewer's trace is job_id → run_id → every ingestion.* line. Solid Queue's schema lands as a migration in db/queue_migrate *and* the regenerated dump, because db:prepare loads a schema file only for an uninitialized database and PR 2's version-0 placeholder already created schema_migrations in every development queue database (verified against one). Recovery tests under spec/recovery/ cover §12's list: work committed but never enqueued, a job delivered twice, a lease left by a crashed worker, contention between pollers, and advisory-lock release on session death — proven with pg_terminate_backend rather than a cooperative close, because a container kill runs no ensure block. spec/queue/ holds the only examples that touch the queue test database; spec/job_boundary_spec.rb keeps it that way. Processing semantics are unchanged and stated exactly: at-least-once execution plus idempotent writes plus unique constraints gives effectively-once persisted outcomes. Not exactly-once execution. Closes #18 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 #18 —
IMPLEMENTATION_PLAN.md§13's PR 8 — Background processing and recovery.Problem
Nothing fires on a schedule.
docker compose upstartsdb,setup,web, and then waits for someone to runbin/ingestorbin/enrichby hand. Extension B's restart-safety claims — pending work preserved in business tables, work reconciled after a crash, advisory locks released on session death — are stated in the plan and unproven in the repository.Scope
In: Solid Queue in its own
queuedatabase inside the same Postgres container (B4); theworkercontainer; the 60-second recurring poll tick (S1.7);EnrichActorJob/EnrichRepositoryJobwith post-commit enqueue; the entity-scoped reconciler (S3.8, B6); restart safety (S1.8, B5); advisory-lock release on session death, verified (B7's verification half); job IDs in logs (S4.3's job-ID half); recovery tests (D11).Deliberately out: rich
/statusand queue observability (PR 10);job_idon the DEBUGgithub.requestline (PR 10); container-kill e2e, multi-poller stress, crash-window matrix (PR 11); multi-source allocation validation (PR 9); Solid Queue concurrency limits keyed by source id — rejected with a reason in ADR 0008, not deferred silently; an id-addressed enrichment job, which the fairness design rules out.Technical decisions
The enqueue is a hint; the entity rows are the durable record. Solid Queue's separate database means an enqueue cannot join the business transaction.
Github::IngestionRunner#calldispatches once per run that created events, after every row has committed, the run row is finalized, and the source lock is released.ReconcilePendingEnrichmentsJobasks the same question of committed state every 60 seconds, so a process killed between the COMMIT and the enqueue loses the hint and never the work. ADR 0008 records this, with the rejected alternatives.One cycle per class, not one per created event.
Github::EnrichmentRunnerenriches one entity per call and chooses it by §10's fairness policy under aFOR UPDATE SKIP LOCKEDlease — an id-addressed job would have to bypass that ordering to honour its argument, which is how a repository flood starves actors. So N enqueues carry exactly the information one does. Enqueuing per created event would put ~2,400 argument-identical cycles an hour on a queue that can spend 40 requests, each running the age-out sweep to be told no.A tick is not a poll.
EventSource.poll_duepre-filters (mode'ssource_type,enabled,idle, projection arrived) andGithub::PollScheduleunder the source lock stays the authority — the runner re-reads the row before deciding. Thesource_typefilter is load-bearing: a development database routinely holds a fixture source too, and a live worker polling it would raiseFixtureMissonce a minute forever.db/migrate/20260731100000adds the partial indexspec/db/schema_spec.rbexplicitly asked PR 8 for.SourceBusyis INFO, never a failed execution, and noretry_onon any job. Both retry ladders already exist and are ledger-aware (Ingestion::PollState,Enrichment::EntityState); a second uncoordinated ladder would re-poll a source whose backoff was just written. The 60-second tick is the retry.Solid Queue's schema lands as a migration and the dump.
db:prepareloads a schema file only for an uninitialized database, and PR 2's version-0db/queue_schema.rbplaceholder already createdschema_migrationsin every development queue database — verified against one, which returned the single row0. A schema-file-only change would have left those databases empty and crash-looped the worker on a reviewer's seconddocker compose up.shutdown_timeout20s /stop_grace_period30s is derived from §2A's pinned defaults (open 5 + read 15 is the longest an attempt already holding the gate can run), the wayRequestGate::WAIT_SECONDSis. A job still waiting for the gate has reserved nothing and is safe to kill.Testing performed
1,383 examples, 0 failures; RuboCop clean; Brakeman 0 warnings; bundler-audit clean.
New
spec/recovery/covers §12's recovery list:idlerather than pretending.ensureruns in a real crash): idle and free untilClaim#lease_secondselapses, then enriched, with no cleanup code anywhere.pg_terminate_backendon a second session, not a cooperative close, for the source lock and the request gate, plus a full poll that failsSourceBusybefore the kill and completes after it. The residual gap (the dying session cannot be RSpec's own pooled connection) is stated in the file.Plus
spec/jobs/for each job,spec/services/github/enrichment/dispatch_spec.rbfor the enqueue rule, andspec/queue/— the only examples that touchgithub_push_ingestor_queue_test, withspec/job_boundary_spec.rbkeeping it that way (mirroringnetwork_boundary_spec.rb).CI gains
bin/rails solid_queue:checkand a fixture-modetimeout --signal=TERM 20 bin/jobssupervisor smoke — boot, recurring registration, clean shutdown; it does not claim to prove a tick fired.Docker verification
GITHUB_MODE=fixture docker compose up --build—db,setup,web,worker; the scheduler registered all three recurring tasks, and within 60 seconds the log showedjob.completedforPollEventSourceJob(sources_due: 1, onerun_id),ingestion.run_started/run_completed,enrichment.dispatchedwith the per-class summary, andenrichment.completed/enrichment.failedcarrying their job ids.Recovery drill: stopped the worker, set the entities back to
pending,TRUNCATE solid_queue_jobs CASCADE(the crash), started it again at 23:31:17 — the reconciler tick at 23:32:00 rediscovered the work from the entity rows and the cycles ran.docker compose restart workerrestarts cleanly (no stale pidfile).docker compose run --rm test(image, no bind mount) is green.Documentation updates
docs/adr/0008-post-commit-enqueue-and-entity-scoped-reconciliation.md; README gets a Continuous ingestion section, theworkerservice in Quick start, the job log vocabulary, queue-inspection and recovery-drill commands, and a plain statement thatdocker compose upat the defaultGITHUB_MODE=livenow spends real unauthenticated quota continuously. Stale forward references insource_lock.rb,enrichment_runner.rb,bin/enrich,config/database.yml,spec/support/webmock.rband the compose header corrected.IMPLEMENTATION_PLAN.mdunchanged — it is frozen.Known limitations
bin/enrich --limit Nremains the operator's handle.🤖 Generated with Claude Code