Enrich in Search batches instead of one request per entity - #43
Merged
Conversation
SolidQueue::QueueSelector wraps its configured value in Array(), so `queues: polling,control` declared one queue literally named "polling,control" — a name no job is ever enqueued into. That worker registered, heartbeated, and polled for the life of the process while claiming nothing, so the always-on container never polled a source or ran a reconcile tick; only the single-name enrichment worker functioned. Found by watching a live run make no progress: recurring jobs accumulated in solid_queue_ready_executions with zero claimed executions. Both queue specs asserted the author's intent rather than the runtime's reading of it — one split the configured string itself, the other compared against the same joined string — so neither could fail on this. They now assert through SolidQueue::QueueSelector and Array(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Issue #45. The durable backlog from #44 survives quota exhaustion but cannot drain it: one core request per entity gives 40 completions an hour against roughly 2,280 cold arrivals, and authentication is out of scope. Derivation-first staged enrichment replaces the per-entity normal path: - Every field computable from the stored event is derived on ingest, with no network call. Repository owner login comes from the event's own qualified name; both event-native identity fragments are preserved as append-only observations committed with the push event. - The normal path is an unauthenticated GitHub Search request carrying up to SEARCH_BATCH_SIZE repeated exact qualifiers (user: / repo:, never OR-joined, which answers 422). It spends a separate per-minute Search ledger — ceiling 10, reserve 2, paced — so Search pressure can neither consume nor block the polling allocation. - Returned items are applied only when the immutable GitHub id matches the claimed row. Missing, renamed, mismatched, and contract-invalid items go to the payload-URL detail fallback, which spends an explicit core allowance (CORE_DETAIL_FALLBACK_ALLOWANCE, 4/hour) and never the poll allocation. The core formula becomes 12 + 4 + 8 <= 60. - enrichment_status stays the business outcome; enrichment_stage carries the pipeline position through seven resting stages. Claims are explicit leases (lease_token, leased_until) rather than an overloaded next_retry_at, so a retry instant and a live claim can no longer be confused. Every projection is guarded by the lease, so a reclaimed row cannot be double-applied. - Quota, pacing, and reserve denials defer. The only terminal outcome is an entity-specific permanent fact, and it retains the event data, observations, reason, and timestamps. - Raw evidence is append-only: batch envelopes record the request, its counts, and its rate-limit headers; observations record every item. A refresh appends and repoints the projection rather than overwriting the only retained response. One EnrichmentCycleJob replaces the two per-class jobs: it loops until a ledger denies, waiting out pacing, because 6-second pacing is far finer than the 60-second tick and the queue has one thread. The retired per-entity path (EnrichmentRunner, Fairness, CandidateSelector, Claim, EntityState) is deleted rather than left as a second writer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The staged path needs deterministic offline coverage for the cases live traffic produces: a partial batch, a rename, an unrequested extra item, incomplete_results, a malformed envelope, a rejected query, and both Search rate-limit shapes. Manifest keys are the canonical form the fixture transport actually computes, derived by running the claim and SearchQuery rather than by hand: per_page sorts before q, and encode_www_form's percent-encoding is reproduced exactly. A changed batch membership therefore fails closed as a FixtureMiss instead of silently matching. Search responses override the manifest's default headers so the corpus carries x-ratelimit-resource: search with its own limit and 60-second reset — the search ledger's header path is exercised offline. The two repository bodies gain "archived", the one contract field they lacked. The CI enrichment smoke step sets SEARCH_PACING_SECONDS=0 so a single one-shot can run both lanes back to back. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the coverage issue #45 requires and reworks what the retired per-entity path owned. Notable shapes: - Batch matrix: apply-by-stable-id with shuffled results, missing, renamed, identity-mismatched, unrequested, incomplete_results, malformed envelope, 422, and both rate-limit responses — each asserting where the members end up, not only what the call returned. - Dual-window deferral: one integration example drains 50 entities FIFO across rolled Search minute windows and a core hourly window, through the real executor and transport with WebMock echoing the requested identifiers back. - Durability: leases, SKIP LOCKED disjointness, expired-lease reclaim with its orphaned batch marked, and a late writer from a stolen lease matching zero rows. - A boundary spec asserts no quota-flavored terminal state exists in the vocabulary or the behavior: a denial on either ledger leaves every row bit-identical while the batch row remains as evidence. - /status: the nine-block contract, per-stage counts, batch quality, and all three catch-up states — including insufficient_sample on a fresh database and not_keeping_up on a flat nonzero backlog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Appendix G records the decision, what it supersedes from Appendix F, the refresh composition rule, and the capacity hypothesis kept explicitly labeled as a hypothesis until measured. Sections 5, 7, 10, 11, 12, and 16 are amended in place, including the two-origin SSRF wording: Search URLs are application-origin constants built from stored identifiers and still validated in-chain, while detail URLs remain payload-origin under the full boundary — no identifier is ever turned into a constructed detail URL after a Search miss. Section 16 gains a forbidden claim to match the honesty rule this change introduces: no guaranteed catch-up, only a dated measured comparison. ADR 0013 records the decision and its rejected alternatives; ADRs 0004, 0007, 0008, and 0010 carry amendment lines in the established format. README, .env.example, CLAUDE.md, the design brief, and the submission checklist follow the same numbers throughout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven findings from an independent review of the branch, plus the default the live measurement corrected. Correctness: - A secondary rate limit is IP-scoped, so it binds both ledgers (§10). Search reservations now refuse while the core ledger holds a global block, and a secondary limit provoked by a Search response writes that block so polling stops too. A *primary* Search exhaustion stays local — blocking polling on it would let the search lane starve event capture. - MAX_HTTP_RETRIES was inert on Search: the ~1s backoff landed inside the 6s pacing window, so every retry was refused as :search_pacing and the transport failure it existed to repeat was replaced by a budget denial. A Search retry now waits at least the pacing interval. - The borrow flag stated which slot was taken; the ledger reads it as "the other class has no eligible candidate". They differ exactly when a backlog is one-sided, which stranded it at half the allowance with the rest idle. It is now computed from the other lane's real claimability — and in bin/enrich from the unfiltered pool, so --class cannot claim a lane is empty when the operator merely excluded it. - Only one exact English 422 message reached the fallback. Every deterministic client error now does: a rejected query is a fact about this batch's identifiers and URL, and the ladder would resend both unchanged, hourly, forever. Matching on GitHub's wording would also break when they reword it. - A deferred batch restored every row to batch_pending, including rows claimed from retry_scheduled — a status/stage pair Enrichable does not list as legal. release! now restores the stage it claimed. Operability: - Compose forwards the staged-enrichment configuration. Without it the variables .env.example documents and /status publishes set nothing inside a container. - CORE_DETAIL_FALLBACK_ALLOWANCE defaults to 40 rather than 4. The live run measured a 1.6-1.9% Search miss rate against ~2,000 arrivals an hour, so the fallback lane needs on the order of 40 requests an hour and had four; the core budget leaves exactly 40 after polling and the reserve. The evidence file is explicit that this is arithmetic from a measured miss rate, not itself a measured outcome. - The cycle budget bounds when a request may start, not how long one already in flight may run. Nothing can preempt a spent reservation, so the overrun is documented and bounded rather than papered over. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second review round on the branch. The global block was shared too broadly. github_api_budget.global_blocked_until carries core primary exhaustion and reserve breaches as well as secondary limits, so reading it from the Search ledger made a spent core hour stop the Search resource for the rest of that hour — the opposite of why the two ledgers exist. Only a secondary limit is IP-scoped. Github::RateLimitPolicy is now the single decider for both resources, which is what it already claimed to be: it takes the resource that answered, returns no global verdict for a primary exhaustion of Search, and on a secondary limit writes both ledgers. Search limits therefore reach the same escalation ladder they always should have — the streak counter, the one-minute floor, the one-hour cap, and the clearing on the next good response — instead of the bespoke block_globally! call that bypassed all four. SearchBudgetLedger#block_from! now records only this resource's own exhaustion. Also: - Dispatch enqueues nothing while a cycle is unfinished. A cycle can outlive the 60-second tick, and the work it would find is still claimable, so the reconciler would otherwise add a wake-up every minute for as long as the overrun lasted. Serializing them was never the same as bounding them. - The 40-request detail-fallback default reached the runtime and Compose but not the prose: .env.example, README, CLAUDE.md, the plan, the design brief and three ADRs still said 4, and Compose now forwards the variable — so uncommenting the documented line would have restored the old cap. The derived arithmetic is corrected with it: the three core lanes fill the limit exactly, and a deeper poll clamps the fallback lane rather than leaving headroom. - The evidence document overstated two things. A 26-minute run cannot hold 53 samples at a 60-second cadence — two sampling loops were running — and the Search lane requested 872 items rather than settling them: 858 were applied directly, 14 went to the fallback, and the pipeline produced 870 exits. One test-only fix: advisory_lock_holders queried pg_locks without a database filter. pg_locks is instance-wide and this project runs the development stack against the same container as the test databases, so a worker mid-poll holding the request gate in the development database failed the multi-poller example here. It failed exactly that way during this session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
batbrainy
marked this pull request as ready for review
August 2, 2026 21:30
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 #45 (and #44, whose durable-backlog work this builds on).
Problem
#44 made the enrichment backlog durable: quota exhaustion defers work instead of
discarding it. It did not make the backlog drain. One core request per entity gives 40
completions an hour against roughly 2,280 cold actor and repository arrivals an hour, and
GitHub authentication is out of scope. Durability without throughput means the backlog and
its oldest pending age grow forever.
Scope
Replaces the one-request-per-entity normal path with derivation-first staged batch
enrichment, and makes catch-up a measured, published property rather than a claim.
In scope
user:/repo:qualifiers, up to
SEARCH_BATCH_SIZE(≤ 10) entities per request, on a separateper-minute Search ledger.
mismatched, and contract-invalid batch items.
/statusreports stages, throughput, batch quality, both quotas, and a tri-statecatch-up verdict.
Deliberately not in scope
useful-data contract is explicit about what "complete" means.
Technical decisions
Search batches, not OR-joined queries. Repeated exact qualifiers separated by spaces.
Joining them with
ORanswers HTTP 422 and is not the supported batching form.Two ledgers, because they are two rate-limit resources. Search reports
x-ratelimit-resource: searchwith a limit of 10 per minute;coreis 60 per hour. Thecore ledger refuses to reconcile a foreign resource, so a single ledger would have imported
a 60-second reset as the hourly window boundary. The core formula becomes 12 poll + 40
detail fallback + 8 reserve = 60. Search pressure
can neither consume nor block the polling allocation.
Stable-ID validation, never name matching. A returned item is applied only when its
immutable GitHub id equals the claimed row's.
facebook/reactredirecting toreact/reactis exactly why: a name-matched apply would have written one repository's data onto another.
Status and stage are separate columns.
enrichment_statusremains the businessoutcome;
enrichment_stagecarries pipeline position through seven resting stages.Stage ownership is disjoint — the batch lane owns
retry_scheduled, the detail lane ownsdetail_pending— so the two claims can never contend for a row.Explicit leases.
lease_tokenandleased_untilreplace the overloadednext_retry_atlease, so a retry instant and a live claim are no longer the same field. Every projection
is guarded by the token, so a reclaimed row cannot be double-applied by a resurrected
worker.
One looping cycle job, not a job per request. Pacing is 6 seconds and the recurring
tick is 60; a job-per-request design could not exceed ~1,200 entities an hour whatever the
budget allowed. The cycle waits out pacing on the dedicated single-thread enrichment
worker, never inside a gate hold or a ledger lock.
Rejected alternatives. Authentication (out of scope by decision). Keeping per-entity
enrichment and accepting unbounded backlog growth (#44's own finding). Suspending polling
to fund enrichment — it trades event capture, which is unrecoverable, for enrichment,
which is not.
Testing performed
docker compose run --rm --build test— 1,959 examples, 0 failures; stress suite 10examples, 0 failures; RuboCop 259 files, no offenses; Brakeman 0 warnings, 0 errors;
docker compose config --quiet;git diff --check.Coverage added for every case issue #45 lists, including: event-native completion with no
network call, duplicate-demand coalescing, both batch lanes, stable-ID validation, partial
/ empty / malformed / renamed / unrequested /
incomplete_resultsresponses, nullablecontract fields, fallback admission and its bounded core consumption, reserve enforcement
on both ledgers, lane fairness and borrowing, quota deferral across Search minute windows
and core hourly windows in one scenario, retry / stale lease / restart / concurrent
worker behavior, append-only observations surviving a refresh, the absence of any
quota-based terminal state,
/statusper-stage and catch-up metrics, and configurationvalidation.
Docker verification
Live, unauthenticated, no credentials present, from isolated Compose projects with the
development stack stopped. Full transcript:
docs/evidence/2026-08-02-live-staged-batch-enrichment.md.
owner_loginderived locally, 184 event observations committed with the events.repositories. Both batch rows recorded
x-ratelimit-resource: search, limit 10,while the core ledger's enrichment counter stayed at 0.
ratio 1.00 — stopping at the configured reserve rather than running the limit to zero.
facebook/reactreproduced the rename: Search answered 422, the members were admittedto the fallback, and the stored payload URL resolved it through GitHub's redirect with
the stable id validated.
database container restarted, and the fingerprint recomputed identical — ledger
state, batch envelopes, and observations all intact.
/statusevery 60 seconds:26 minutes, 53 samples, 872 arrivals. The Search lane
requested all 872 across 92 requests at a fill ratio of 0.981–0.986, applying 858
directly and sending 14 to the fallback, and drained each five-minute arrival burst in
roughly two minutes; 870 of 872 exited the pipeline (99.8%). Across the
24 samples past the minimum sample window
/statusreportedkeeping_up8 times andnot_keeping_up16 times, and the reason is in the numbers: the 1.6% of items Searchdoes not return wait on the detail lane, which that run had capped at 4 requests an
hour. The service did not claim to keep up when it was not — which is the behaviour
the issue asks for. That measurement is why
CORE_DETAIL_FALLBACK_ALLOWANCEnowdefaults to 40 (the whole core remainder after polling and the reserve); the evidence
file is explicit that the new value is arithmetic from a measured miss rate rather than
a second measured outcome.
Documentation updates
IMPLEMENTATION_PLAN.mdAppendix G plus in-place amendments to §5, §7, §10, §11, §12,and §16 (including the new forbidden claim: no guaranteed catch-up, only a dated measured
comparison). New ADR 0013; amendment lines on ADRs 0004, 0007, 0008, 0010. README,
.env.example,CLAUDE.md,docs/DESIGN_BRIEF.md,docs/SUBMISSION_CHECKLIST.md, andthe fixtures README follow the same numbers throughout.
Known limitations
/statuscompares measured completionand arrival rates over a trailing window and reports
keeping_up,not_keeping_up, orinsufficient_sample. It publishes no drain estimate. A past rate does not bound futurearrivals.
window with no cursor; a long enough outage loses events permanently. Polling therefore
keeps first claim on the core budget.
bounded fallback, not by a larger batch.
Three defects found only by running it live
Recorded because each is a case where the code and its tests agreed with each other and
disagreed with the world:
An unparsable payload URL was treated as retryable.
github-actions[bot]suppliesa URL containing brackets; the SSRF policy correctly refuses it, but the retry ladder
would have spent three of the four hourly core detail requests re-refusing the same
stored string. Detail fallback now terminates on permanent classifications, per §10.
Search answers 422, not an empty result, when every requested identifier is
unsearchable. A batch of ten silently omits its unsearchable members, which is why the
exploratory probe never saw this. Read as a generic client error, a renamed repository
retried forever on the one lane that cannot resolve it.
queues: polling,controlinconfig/queue.ymldeclared one queue literally named"polling,control".SolidQueue::QueueSelectorwraps its input inArray(), so thatworker claimed nothing for the life of the process — the always-on container never
polled or reconciled. This predates this issue; the suite missed it because the spec
split the configured string itself and asserted intent rather than runtime behavior.
Independent review of the branch raised seven further findings, all addressed in
453fea6: secondary rate limits now bind both ledgers (they are IP-scoped);MAX_HTTP_RETRIESwas inert on Search because the backoff landed inside the pacingwindow; the borrow flag stated which slot was taken rather than what the ledger reads
it to mean; only one exact English 422 message reached the fallback; a deferred batch
restored rows to a status/stage pair the model does not list as legal; and Compose
forwarded none of the new configuration.
🤖 Generated with Claude Code