PR 6 — Poll budget and scheduling - #27
Merged
Merged
Conversation
Makes a poll a scheduled, bounded, conditional act rather than an unconditional one (IMPLEMENTATION_PLAN.md §9, §10, §13). - Github::PollSchedule: effective_poll_time as the max of five independent components, nil meaning due now. --force drops exactly one of them. - Github::LinkHeader + Github::Ingestion::PageLoop: Link-driven pagination with budget-bounded stops (cap / allowance / no-next-link / empty page, plus link-loop and page-error), and deliberately no stop-on-known-event. - Page-one ETag and its conditional request; a 304 stays debited. - Github::RateLimitPolicy decides a global block, BudgetLedger#block_globally! records it; class blocking stays derived from the counters. - Github::Ingestion::PollState persists the poll state, moving the scheduling components only when an attempt actually reached GitHub. - event_sources.status vocabulary (idle | failed) with a CHECK constraint. - script/probe_304.sh and the docs/evidence transcript for §10's required validation gate; ADR 0006. Two hardening fixes in BudgetLedger that PR 6's new writer makes reachable: apply_observation and the enrichment guard now key on reset_at and window_initialized_at rather than on the window_status label, which a third value would otherwise have turned into an unrecoverable row. Closes #16 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.
Reviewed the poll scheduling and budget paths. I left two inline comments on behavior that looks worth fixing before merge.
…mpts Two review findings, both cases where PR 6 wrote state that nothing acted on. A source marked `failed` by a permanent 4xx kept being polled, and PollState#success wrote `status: "idle"` back on any later 200 — so §10's out-of-service state was a label that lied, recovery was automatic rather than an operator's decision, and the source spent quota on a request that could not succeed. IngestionRunner now refuses a failed source before the schedule and before `force` is read, and success no longer writes status at all. An exception raised after the executor had already debited and fetched a page was recorded as an unattempted run, leaving cadence, backoff and last_polled_at untouched — so the next invocation was immediately due and spent another request into the same crash. PageLoop now raises WalkInterrupted carrying an outcome with the last fetch's classification and the tally so far; the runner records it and re-raises the original, so the crash still escapes and only the bookkeeping changes.
Ran script/probe_304.sh against api.github.com. The finding reproduces: across two conditional requests answered 304, x-ratelimit-used went 4 -> 5 -> 6 and remaining 56 -> 55 -> 54, with x-ratelimit-resource core throughout. That confirms the observation IMPLEMENTATION_PLAN.md §10 records from the review-supplied probes, so no plan amendment is required. Two corrections the real data forced: * The script used an empty bash array under `set -u`, which is an unbound-variable error on bash 3.2 — the shell macOS ships, and so the one most likely to run this. Extra headers now ride in on "$@", which is special-cased when empty. * The transcript template claimed GitHub's Date header was the authoritative timestamp. All three responses carry the same Date: the 304s echo the cached representation's metadata rather than stamping a fresh instant, so Date fixes the hour but cannot order the requests. Documented as observed, along with the fact that used already stood at 3 before the probe began — which makes the shared-IP caveat concrete rather than hypothetical.
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.
Pull Request
Linked issue
Closes #16 —
IMPLEMENTATION_PLAN.md§13, "PR 6 — Poll budget and scheduling".Problem
A poll was an unconditional act.
IngestionRunnerfetched exactly one page, never sentIf-None-Match, never read aLinkheader, and wrote nothing to anyevent_sourcescolumn. The five scheduling columns,
event_sources.etagandgithub_api_budget.global_blocked_untilall existed and were all dead data — PR 4'sBudgetLedgerread the global block but its own comment said it would never set it,never derive class blocking for scheduling, and never handle a secondary limit.
Scope
Included — the issue's checklist, in full:
effective_poll_timeas the max of five independent components(
cadence_due_at,poll_floor_until,retry_not_before_at,global_blocked_until,derived
poll_class_blocked_until), inGithub::PollSchedule.Link-header pagination with budget-bounded stops — cap / allowance / no-next-link /empty page — and explicitly no stop-on-known-event, in
Github::Ingestion::PageLoopover a new
Github::LinkHeader.304performs no processing and itsreservation stays debited.
global_blocked_untilfor §10's three truly-global conditions only, decided by a newGithub::RateLimitPolicyand recorded byBudgetLedger#block_globally!. Class blockingstays derived from the counters.
Retry-After, clamped to 60 s–1 h, withjittered backoff when the header is absent, zero, or an HTTP-date.
Github::Ingestion::PollState.event_sources.statusvocabulary (idle | failed) with a CHECK constraint.script/probe_304.shplusdocs/evidence/2026-07-30-unauthenticated-304-quota-probe.md..env.example, fixture-corpus docs, and every stale "PR 6 will…"comment left behind by PRs 4 and 5.
Deliberately not included —
effective_enrichment_time, fairness shares and enrichmentjobs (PR 7, #17); the recurring task and worker container (PR 8, #18); the rich
/statusendpoint (PR 10). Nothing fires the cadence yet — the one-shot is still the only thing
that polls.
IMPLEMENTATION_PLAN.mdis untouched. It is frozen and forward-referential by design,and §14 pins the execution summary to PR 12. The probe re-run confirmed the observation §10
records, so no amendment was required — had it contradicted it, §10's accounting decision
and ADR 0004 would both have needed revision with the tradeoff stated. That is the case
CLAUDE.md's rule is for.
Technical decisions
Five components, never one collapsed timestamp. ADR 0006 records this. A single
next_poll_atcannot answer the two questions §9 asks of it:--forcecould not tell whichpart it may bypass, and a routine
X-RateLimit-Reseton a healthy200would defer everypoll to the top of the hour.
next_poll_atis written as a cache of the answer and isnever read back to make a decision.
--forceremoves exactly one term.PollSchedule::FORCEABLEis that claim in oneplace.
forceis never passed to the page loop, the executor, the ledger, the policy or thepoll-state writer, so the other four stay binding by construction;
SourceLockis acquiredbefore it is read; and
reserve!knows nothing about it, which is a second, independentguarantee that a forced run cannot overspend.
The cadence gate runs inside the source lock, and re-reads the row. The caller's
EventSourceis loaded before mutual exclusion exists, so two processes would otherwiseboth read a stale
cadence_due_at, serialize on the lock, and poll back to back — defeatingthe guarantee the lock exists to provide.
A run row exists iff the process tried to reach GitHub. A poll the schedule turns away
writes no
ingestion_runsrow and no source state. Under PR 8's recurring task thealternative is dozens of zero-count rows an hour diluting every counter in the table. The
cost, stated in ADR 0006: skipped cycles are countable from the logs only.
A truncated walk is
completed, notfailedordeferred. If page 3 is denied afterpages 1–2 committed, "the attempt happened and did not produce usable events" is false, and
deferredwould drop a run holding real events out ofIngestionRun::SUCCESSFUL_STATUSES.The truncation is visible in
pages_fetchedandstop_reason.Two hardening fixes in
BudgetLedger, both for bugs this PR's new writer makesreachable rather than pre-existing failures.
apply_observationdispatched on thewindow_statusstring; once a global block could write a third value, a block landing onan uninitialized window sent the next good response into
snapshot.reset_at < nil→ArgumentError, on a row neither rollover predicate could ever fix. Both it and theenrichment eligibility guard now key on the fact (
reset_at,window_initialized_at)rather than the label.
No new columns. §7 lists every column both tables need and all of them already exist.
Rejected alternatives, with reasons, in ADR 0006: a stored
poll_class_blocked_until; asecond class writing
github_api_budget; asecondary_limit_countcounter for exponentialescalation — see Known limitations.
One migration, and one only. The
event_sources.statusCHECK, because PR 6 is the PRthat defines the vocabulary and PR 3's migration comment said so.
IngestionRunseparatelyinvites a CHECK on
ingestion_runs.status"whenever migrations are next touched" — that isan invitation, not a requirement, and taking it here would put a schema change in this diff
that no behaviour in it depends on. Parked; considered and declined, not missed.
Testing performed
910 examples, 0 failures(from 764 onmain), rubocop clean across 144 files, brakeman0 warnings, bundler-audit clean.
New:
link_header_spec,poll_schedule_spec(§12'seffective_poll_timeunit test, withno database — including one example per constraint
--forcemust still obey),poll_backoff_spec,rate_limit_policy_spec,page_loop_spec(D5 and D6 in full),poll_state_spec(the write matrix row by row).Extended: the runner spec gains the cadence gate,
--forceend to end, a full multi-pagepoll, and persisted poll state per terminal outcome;
budget_ledger_specgains#block_globally!and both hardening fixes; the model and schema specs gain the vocabularyand the derived class blocking.
Changed, and worth a reviewer's attention:
"sent no conditional header, so this 304 came from the corpus script and not from us"inverts into
"sends the ETag the first poll stored". PR 5 wrote that exampleprecisely to be inverted here.
"holds no application transaction across the fetch"observed only the last fetch. Itnow collects over a three-page walk — the page most likely to be wrapped in a transaction
is the second one, after the writer has run.
pre-flight as a §9 scheduling component, which is strictly better — no lock work, no
run row, no log line per tick. The ledger's denial path stays live and separately covered
for what the schedule cannot see coming.
Docker verification
Two findings came out of running these rather than assuming them, and both changed the
code or the docs:
--forcecorrectly refuses to bypass the server floor. The corpus sendsx-poll-interval: 60exactly as GitHub does, so an immediate forced replay defers withpoll_floor_until. The README now documents thesleep 60and explains that the waitis the demonstration.
nothing rather than one that never happened. Suppressed.
POLL_INTERVAL_SECONDS,MAX_PAGES_PER_POLL,ENABLED_LIVE_SOURCE_COUNTandRATE_LIMIT_RESERVEwere not in the compose env anchor, so setting them in a shell hadno effect inside the container and the pagination walk was not reproducible as documented.
Added, with a spec asserting the anchor's defaults match
Configuration::DEFAULTS.Documentation updates
README (status,
--force, pagination, "Why a304costs a request here", expected latency,fixture rate-limit recovery, logging, request-path diagram, env table); ADR 0006; a
one-sentence evidence pointer appended to ADR 0004;
.env.example; the fixture corpusREADME;
docs/evidence/.Known limitations
The probe transcript is a template.Done. The probe was run againstapi.github.comand the finding reproduced:x-ratelimit-used4 → 5 → 6 across two304s,resource: corethroughout. §10's validation gate is satisfied and no planamendment was required. Two corrections came out of running it: the script used an empty
bash array under
set -u, which aborts on the bash 3.2 macOS ships; and the transcript'sclaim that GitHub's
Dateis authoritative for ordering is false — all three responsesecho the cached representation's
Date, so only the local clock orders them. Both aredocumented as observed.
backoff when the header is absent"; there is no persisted counter to be exponential in,
and §7's
github_api_budgetcolumn list is closed. In practice this is a 60-second floorwith jitter, escalating only via
GREATEST(existing, new)— which a block still in forcemakes rare, because the block itself prevents the request that would escalate it. Adding a
column was the alternative; it would put a column §7 does not list into the schema.
budget.global_block_clearedrecords the write, not the moment the block stoppedbiting — blocking is derived from the timestamp, so an expired block is already inert
without any write.
MAX_PAGES_PER_POLLis 1 by default,documented latency is 30 s–6 h against a 5-minute cadence, and the
/eventswindow holds~300 events. Pagination deepens one poll within the budget; it does not backfill.
coordinates this application only.
🤖 Generated with Claude Code