PR 7 — Enrichment budget and fairness - #28
Merged
Conversation
Fills the actor and repository stubs in, under an hourly allowance split fairly between the two classes (IMPLEMENTATION_PLAN.md §13's PR 7). Fairness is enforced inside BudgetLedger#reserve!, under the same row lock as the debit, as a fifth denial reason :share_exhausted — ADR 0004 makes the ledger the only writer of github_api_budget. The one fact it cannot establish is whether the other class has a currently eligible candidate, so the caller asserts it via a defaulted borrow flag on Github::Request, which survives a redirect hop and a retry because both reserve again. The entity state machine gets its write matrix in Enrichment::EntityState (PollState's twin), newest-first selection with a total eligibility predicate that keeps the backlog bounded, a claim lease on next_retry_at so two workers cannot enrich one row, the freshness cache and its refresh TTLs, and §7 merge rule 3's reactivation behind the RETURNING gate PR 5 already built. Delivered with bin/enrich and an `enrich` compose service mirroring bin/ingest, so the capability is runnable now; PR 8 wraps the same runner in jobs. Closes #17 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
batbrainy
commented
Jul 30, 2026
batbrainy
left a comment
Owner
Author
There was a problem hiding this comment.
Left one inline comment.
| global = [ budget&.global_blocked_until, budget&.enrichment_class_blocked_until(now: now) ].compact.max | ||
| return global if global&.>(now) | ||
|
|
||
| EntityType.all.filter_map { |type| selector.earliest_retry_at(type, now: now) }.min |
Owner
Author
There was a problem hiding this comment.
This summary only looks at pending/retryable rows via earliest_retry_at, but complete rows are the refresh pool. That means the operator output can say due now when the backlog is fully complete and fresh, or while a failed refresh is deferred with next_retry_at, even though the next legal refresh is still in the future (fetched_at + TTL or that retry instant). Can we include refresh-pool timing here before treating nil as due now?
Github::Enrichment::Summary derived "is anything claimable now?" from "is any
pending row deferred?", which are different questions. Three wrong outputs
followed, and the deterministic fixture run reaches all three:
* a fully enriched backlog printed "due now" in the same report as
"Nothing to enrich" — the next legal action was a refresh at
fetched_at + TTL, and nothing looked at the refresh pool;
* a complete row deferred by a failed refresh was invisible, because a
retryable failure deliberately keeps the row complete;
* one candidate due beside one deferred printed the deferred instant while
work was in fact claimable.
CandidateSelector gains the claimable? predicate and per-pool timing, and
earliest_retry_at becomes earliest_pending_at alongside earliest_refresh_at.
A complete row's next legal fetch is the later of its TTL expiry and any retry
instant a failed refresh left behind, so the minimum is taken over
GREATEST(fetched_at + ttl, next_retry_at) rather than over fetched_at alone —
the oldest document may be the one carrying the longest backoff.
Fairness now asks refresh_available? rather than spelling the same scope again.
Github::EnrichmentSchedule is unaffected: §9's three components are per entity
and about retry, while the TTL is a selection concern (§10's ladder), so only
the report's pool-level projection was wrong.
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 #17 —
IMPLEMENTATION_PLAN.md§13, PR 7 — Enrichment budget and fairness.Problem
Every actor and repository has been persisted as a stub marked
pendingsince PR 3, and nothing ever transitioned one. The enum, the five statuses, the partial index,actor_share_used/repository_share_usedandRequest::ENRICHMENT_CLASSESall existed saying "PR 7 does this."The allocation problem is what makes it more than a fetch loop. §10's demand arithmetic: one observed live page of
/eventsheld ~92–95 push events referencing ~89 distinct actors against ~92 distinct repositories — 181 cold entity requests per page, ~2,172 an hour, against 40 available. Repository candidates alone exceed the whole hourly allowance, so a queue ordered purely by recency starves actor enrichment to zero indefinitely, which fails Story 3 outright.Scope
In: the entity state machine and its write matrix; per-class fairness guarantees with floor/remainder rounding and eligibility-aware borrowing; newest-first eligibility;
skipped_budgetwith the distinct-event reactivation rule; the freshness cache and its refresh TTLs; error-context classification (entity vs source);effective_enrichment_time; a bounded backlog (B8);bin/enrichand anenrichcompose service.Deliberately out, per the plan's own assignments: Solid Queue, the
workercontainer,EnrichActorJob/EnrichRepositoryJob, post-commit enqueue and the entity-scoped reconciler (PR 8); rich/statuswith coverage percentages andENRICHMENT_COVERAGE_WINDOW_SECONDS(PR 10) —.env.examplesays why that one variable is absent even though §10 prints it in the same block as the three that landed; starved-class stress and theredirecting_repository/hostile_redirectfixture scenarios (PR 11).Technical decisions
Fairness lives inside
BudgetLedger#reserve!, under the sameSELECT … FOR UPDATEas the debit, as a fifth denial reason:share_exhausted. ADR 0004 rejected a second writer ofgithub_api_budgetbefore this PR existed, and a check outside the row lock would be advisory rather than enforcement. The share cap is computed once and handed to bothdenial_reasonand theDEBIT_SQLguard, so the file's existing claim — "the WHERE guard repeats the checkdenial_reasonalready made" — stays literally true for the newest predicate.The borrow is a parameter, not a query. Whether the other class has a currently eligible candidate is a fact about
github_actorsandgithub_repositories, and reaching for two more tables from inside the most contended row lock in the application would invert the locking order.Enrichment::Fairnessestablishes it; the ledger enforces the arithmetic. It rides onGithub::Requestrather than on aRequestExecutorargument because#redirected_touseswithand a retry reuses the request unchanged — so the authorization survives both, where an executor argument would deny mid-chain after the first hop was already spent.A borrowing reservation is capped at
enrichment_allowance, notguarantee + the other's unused capacity. The two authorize the identical set:actor_share_used + repository_share_used == enrichment_usedis an invariant of the debit statements and the class guard binds first, so a borrower is already limited toallowance − other_share_used, which is exactly what §10's phrasing computes. ADR 0007 carries the proof, because a reader checking the code against the plan's wording will otherwise think they diverge.A share exhaustion is a denial, not a deferral. It writes no global block (
RateLimitPolicy#reserve_breachglobalizes only:reserve_reached, so this needed no production change there) and it is absent fromEnrichmentSchedule. Admitting it would make borrowing unreachable — the schedule would answer "not due" before the runner ever computed one — and there is no honest instant to name, since a share is relieved either by the window rolling or by the other class running dry.The claim is a lease on
next_retry_at, not a new column and not a new advisory lock. The fetch happens outside every transaction, so the claim cannot be a held lock. One column with one meaning — "may not be attempted before T" — is what lets a single predicate exclude leases, backoffs and secondary-limit deferrals from both candidate pools and the age-out sweep; a separateleased_untilcolumn would need four queries to stay in sync, and the first that forgot would either skip an entity mid-flight or hand it to a second worker. The lease duration is derived from the gate wait, both HTTP timeouts, the retries and the redirects rather than chosen, and a release restores the exact prior instant, so a deferred cycle leaves the row byte-for-byte unchanged.TTL staleness is a derived predicate over
completerows, never a stored transition. §7 line 572 and §10 line 816 are only compatible under this reading: "never-enriched pending candidates always precede TTL-stale refreshes" requires the two pools to stay distinguishable, and collapsing stale rows intopendingerases the distinction the priority rule is stated over. It also avoids a background writer that mutates rows purely because time passed — the shapePollStateandBudgetLedger#log_block_clearedboth refuse.Boundedness (B8) is structural. Eligibility and aging both key on
COALESCE(last_seen_at, created_at)while ordering keys strictly onlast_seen_at. The asymmetry is load-bearing:created_atin the bound can only shorten a row's life and is what makes the predicate total (a stub created on a duplicate-event page has a NULLlast_seen_at, andNULL > flooris NULL — such a row would be neither eligible nor ageable and would sit pending forever);created_atin the order would let a replay-created stub outrank a genuinely hot entity, and §10 pins the ordering key by name. Every due candidate is therefore either claimable or ageable, and every not-due one self-clears within 3600s.Two calls I want flagged as deviations from the obvious:
ACTOR_ENRICHMENT_SHAREis parsed withRational, notFloat. Verified in Ruby:(100 * 0.29).floor == 28where the decimal says 29, because 0.29 has no exact binary representation, and the value is multiplied by an integer allowance and floored.Rational("0.50") == 0.5, so specs and logs read unchanged. TheFloatfailure is precision rather than soundness — the sum invariant survives because the repository guarantee is a subtraction — but it silently loses an attempt off the number an operator typed.CASEfolded intotouch_activity!. One statement was tidier, but §11 lists "reactivated" among the INFO events, and a set-basedUPDATEthat also touched non-skipped rows could not report how many it actually reactivated. The separate statement's row count is that number. It matches at most one row, only on a genuinely new event, and almost always zero.A retryable outcome never downgrades a
completerecord. The plan is silent here. A transient 500 on a refresh would otherwise flip a perfectly enriched entity toretryable_failure, dropping coverage for a network blip and jumping the row into the high-priority pending pool ahead of never-enriched candidates — while conveying nothingnext_retry_at,last_errorand the attempt count do not already carry. Terminal outcomes do overwritecomplete, because they invalidate the stored document's premise.An IP-wide condition is not the entity's fault. A primary rate limit counts no attempt and writes nothing —
PollStatemakes exactly this call for the source, and inflating an innocent entity's backoff would, repeated, push it toward the hour-long cap. A secondary limit still writesnext_retry_at, because §10 names that explicitly andROLL_WINDOW_SQLclears the global block at the window boundary while the entity's own component survives it.Testing performed
1232 examples, 0 failures, up from 918 onmain. Rubocop clean across 176 files; Brakeman 0 warnings.Fourteen new spec files plus extensions to seven existing ones. §12's named cases are covered: stub →
complete; reuse of fresh records and TTL-driven staleness; failed, repeated, skipped, reactivated and replay-non-reactivated; terminal skip; 404 →permanent_failurewith the source staying enabled; a URL-policy violation →permanent_failurewith no budget debit; concurrent claim prevention; class fairness and borrowing in both directions; class-blocking isolation both ways; and a fixture-mode end-to-end running the whole poll → persist → stub → enrich chain withexpect(WebMock).not_to have_requested(:any, //).Two structural guarantees are asserted rather than commented: the runner never calls
SourceLock.acquire(§8 step 1), and it opens no transaction across the request — the latter proved byBudgetLedger#assert_committable!, which raises if one is, on a path every attempt takes.The end-to-end spec needed no new fixture authoring. Page 1 persists four push events → three actors and three repositories; four resolve 200 and two resolve 404, which is what
fixtures/github/README.mddocuments corpus event58000000008for.Docker verification
Also verified by hand: a second
enrichreportsNothing to enrich — no eligible candidateand spends nothing (the freshness cache); and a forced replay past GitHub'sX-Poll-Intervalfloor absorbs 4 duplicates while leaving askipped_budgetactor exactly as it was — reviewer verification step 7, and §7 merge rule 4.Both one-shots are now CI smoke steps, ordered so
enrichruns against the window and stubsingestjust created — exercising the whole chain across two real processes offline.Documentation updates
docs/adr/0007-enrichment-fairness-shares-and-borrowing.md— new. Records the seven decisions above with their rejected alternatives, the borrow-cap equivalence proof, and three costs stated plainly: the borrow fact is stale by construction (bounded to one request, self-correcting, can never exceed the class cap); a caller passingborrow: trueunconditionally would defeat fairness and the ledger cannot detect it; and one state where the schedule says "due" while the reservation is still refused (:window_uninitialized).README.md— Status rewritten; an "Enrichment, offline" verification section with the exact counts above; the four new environment variables and the split formula; theenrichment.*log events; the request-path diagram and component list extended; ADR 0007 linked; the| Enrichment flow | PR 7 |placeholder removed..env.example— replaces the "arrive with PR 7" placeholder with the real variables and the reasoning, including whyENRICHMENT_COVERAGE_WINDOW_SECONDSis still absent.add_enrichment_refresh_indexes: a partial index on(fetched_at, next_retry_at) WHERE enrichment_status = 'complete'for both entity tables.index_*_on_enrichment_candidatesexcludescompleteby its own predicate, so the refresh pool had no usable index and would seq-scan a table whose population is ~2,000 candidates an hour against at most 40 completions. The migration comment states thealgorithm: :concurrentlytradeoff rather than pretending it is not there.Known limitations
skipped_budgetis a normal documented outcome andbin/enrichprints the per-class usage so the sampling rate is visible.effective_enrichment_timeis real and enforced, but only the one-shot invokes it until PR 8's worker and recurring task.lease_lostresult at WARN, rather than by padding the constant and hoping.🤖 Generated with Claude Code