Skip to content

Enrich in Search batches instead of one request per entity - #43

Merged
batbrainy merged 12 commits into
mainfrom
agent/durable-enrichment-backlog
Aug 2, 2026
Merged

Enrich in Search batches instead of one request per entity#43
batbrainy merged 12 commits into
mainfrom
agent/durable-enrichment-backlog

Conversation

@batbrainy

@batbrainy batbrainy commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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

  • Derive every field computable from the stored event with no network call.
  • Normal path: unauthenticated GitHub Search with repeated exact user: / repo:
    qualifiers, up to SEARCH_BATCH_SIZE (≤ 10) entities per request, on a separate
    per-minute Search ledger.
  • Payload-URL detail fetching demoted to a bounded fallback for missing, renamed,
    mismatched, and contract-invalid batch items.
  • Append-only observations and batch envelopes; latest projection on the entity rows.
  • /status reports stages, throughput, batch quality, both quotas, and a tri-state
    catch-up verdict.

Deliberately not in scope

  • Any credential or token.
  • Full user-profile hydration (actor name, company, location, bio, followers). The
    useful-data contract is explicit about what "complete" means.
  • Pausing polling to make room for enrichment.
  • Any form of sampling, dropping, or terminal outcome caused by budget.

Technical decisions

Search batches, not OR-joined queries. Repeated exact qualifiers separated by spaces.
Joining them with OR answers HTTP 422 and is not the supported batching form.

Two ledgers, because they are two rate-limit resources. Search reports
x-ratelimit-resource: search with a limit of 10 per minute; core is 60 per hour. The
core 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/react redirecting to react/react
is exactly why: a name-matched apply would have written one repository's data onto another.

Status and stage are separate columns. enrichment_status remains the business
outcome; enrichment_stage carries pipeline position through seven resting stages.
Stage ownership is disjoint — the batch lane owns retry_scheduled, the detail lane owns
detail_pending — so the two claims can never contend for a row.

Explicit leases. lease_token and leased_until replace the overloaded next_retry_at
lease, 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 test1,959 examples, 0 failures; stress suite 10
examples, 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_results responses, nullable
contract 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, /status per-stage and catch-up metrics, and configuration
validation.

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.

  • One poll persisted 92 events and 176 cold entities with zero enrichment requests;
    owner_login derived locally, 184 event observations committed with the events.
  • One Search request settled 9 of 10 actors; the next settled 10 of 10
    repositories. Both batch rows recorded x-ratelimit-resource: search, limit 10,
    while the core ledger's enrichment counter stayed at 0.
  • A full production cycle settled 74 entities in 42.5 seconds across 8 requests — fill
    ratio 1.00
    — stopping at the configured reserve rather than running the limit to zero.
  • facebook/react reproduced the rename: Search answered 422, the members were admitted
    to the fallback, and the stored payload URL resolved it through GitHub's redirect with
    the stable id validated.
  • With the Search ledger blocked at its reserve, five pending rows were fingerprinted, the
    database container restarted, and the fingerprint recomputed identical — ledger
    state, batch envelopes, and observations all intact.
  • Sustained run at shipped defaults, sampling /status every 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 /status reported keeping_up 8 times and
    not_keeping_up 16 times, and the reason is in the numbers: the 1.6% of items Search
    does 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_ALLOWANCE now
    defaults 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.md Appendix 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, and
the fixtures README follow the same numbers throughout.

Known limitations

  • Catch-up is a measurement, not a guarantee. /status compares measured completion
    and arrival rates over a trailing window and reports keeping_up, not_keeping_up, or
    insufficient_sample. It publishes no drain estimate. A past rate does not bound future
    arrivals.
  • Event capture remains incomplete by construction. The public feed is a sliding
    window with no cursor; a long enough outage loses events permanently. Polling therefore
    keeps first claim on the core budget.
  • Search is an index, not a directory. An entity absent from it is resolved by the
    bounded fallback, not by a larger batch.
  • One IP, one feed, one sample. The evidence file covers the window it measured.

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:

  1. An unparsable payload URL was treated as retryable. github-actions[bot] supplies
    a 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.

  2. 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.

  3. queues: polling,control in config/queue.yml declared one queue literally named
    "polling,control".
    SolidQueue::QueueSelector wraps its input in Array(), so that
    worker 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.

  4. 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_RETRIES was inert on Search because the backoff landed inside the pacing
    window; 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

batbrainy and others added 7 commits August 2, 2026 15:39
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 batbrainy changed the title Make enrichment backlog durable Enrich in Search batches instead of one request per entity Aug 2, 2026
@batbrainy
batbrainy marked this pull request as ready for review August 2, 2026 21:30
@batbrainy
batbrainy merged commit 7da0864 into main Aug 2, 2026
3 checks passed
@batbrainy
batbrainy deleted the agent/durable-enrichment-backlog branch August 2, 2026 21:30
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.

Implement derivation-first staged batch enrichment without authentication

1 participant