Rate pressure throttles the pass instead of shutting it down; Google ceiling 8.0 -> 7.0 - #644
Merged
Merged
Conversation
…ceiling 8.0 -> 7.0 Owner ruling, 2026-07-30: "the limit needs to be on the amount we are fetching from google api 7/s or hardware whichever comes first. and the limit needs to throttle not shut it down." THE CEILING. `harvest_fetch_limiter.GOOGLE_IMAGE.rate_per_sec` drops 8.0 -> 7.0. The "or hardware, whichever comes first" half gets NO second constant, on purpose: `_DestinationLimiter` is a strict minimum-interval pacer, so it can only ever DELAY a request - the achieved rate is already `min(7.0, what this host and network sustain)` by construction, and `current_rate()` reports which term is binding. A configured hardware-derived RATE would need a mean per-fetch latency nobody has measured at catalog scale; inventing one would be exactly the fabricated ceiling PR #589 removed from `stage_e_batch_sizing`. #589's `HostProfile.fetch_overcommitted` remains the honest hardware signal and is untouched here. THROTTLE, NOT SHUTDOWN - the substantive change. Of the four ratified envelope bars, exactly one halted for what is really rate pressure: FETCH_FAILURE_RATE. A Google 429 reached the dispatcher as `fetch_card_image_bytes() -> None`, byte-for-byte indistinguishable from a 404 or a corrupt download (the limiter returned the 429 and the caller's own `raise_for_status()` flattened it), so it landed on the operating envelope's rolling fetch-outcome window - and >1% of a 500-card window HARD-STOPPED a 230,753-card unattended pass at exit 3, demanding a human `resolve_envelope_trip` acknowledgement for a condition whose correct answer is "go slower". Rate pressure now has its own channel end to end: `DestinationThrottledError` -> `_StageCFetchOutcome.throttled` -> `DispatchOutcome.stage_c_fetch_throttled`. The limiter widens its pacing interval, the one card is deferred, the fetch thread carries on, and the pass completes at exit 0. That window never sees it. The other three bars are UNCHANGED and still halt: host load and RSS because slowing fetches does not address either, and GOOGLE_LOCKOUT (403) because by then throttling is not the available remedy - it is an IP-level lockout on an endpoint shared with the live site's own image serving. Throttling 429/503 is what makes reaching a 403 less likely. Also: 503 joins 429 as rate pressure for the Google destination; backoff decays (one signal doubles, 100 consecutive clean responses halve, never past the configured ceiling) instead of being sticky for the life of the process, which on a one-shot multi-hour pass meant one early blip pinned the whole run at half speed. `run_image_evidence_cohort` is BULK mode, which the envelope does not govern at all, so it catches the new exception and reports the same shape a 429 produced there before - bit-for-bit unchanged behaviour for that command. Default-on, no flag required. Wiki-facing `docs/features/stage-e-operations.md` carries the bar classification, the exit-code table correction (3 can no longer be reached by a 429/503; 4 is our own dispatch-slot cap, not a destination rate limit) and the two-throttles distinction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley
added a commit
that referenced
this pull request
Jul 30, 2026
Owner clarification, 2026-07-30: "to be clear: the 7 fetches per second cap is a global cap, it shouldn't be per process or per core." THE GAP. `_DestinationLimiter` paces with a `threading.Lock` and a process-local `_next_allowed`, so PR #644's `rate_per_sec = 7.0` bought 7/s PER PROCESS. The pooled runner (`run_image_evidence_cohort`) is single-process and was therefore correct by accident. The conveyor is not: django-q2 workers are separate OS processes, each building its own limiter in its own address space, so N concurrent dispatches issued N x 7/s -- 14/s at the production STAGE_E_MAX_CONCURRENT_DISPATCHES = 2, scaling with the cap. Same per-process trap as threading.Semaphore(max_concurrency), which #589 removed a false ceiling term over. #644 established that the rate limit is what protects the destination; that makes its globality load-bearing. MECHANISM. One Postgres row per destination (GlobalFetchPace, migration 0102), advanced by a single atomic UPDATE ... RETURNING that lifts the existing Python pacing arithmetic into the one place every process can see. The caller then sleeps to its slot locally, holding no lock and no transaction. Concurrent reservers serialise on the row lock and, under READ COMMITTED, the blocked statement re-evaluates against the committed row -- so reservations form one strictly-increasing sequence regardless of how many processes compete. WHY NOT stage_e_concurrency's ADVISORY LOCKS (evaluated first, as asked). A lock is a binary held/not-held token: it expresses "how many at once", not "how many per unit time", because a rate needs a remembered timestamp and a lock stores no value. And that module's crash-safety objection to a DB row -- a killed worker leaves a claimed slot claimed forever -- is about a CLAIM. This row holds only a timestamp: a killed process leaves next_allowed_at at most one interval ahead, self-healing in ~143ms with zero reconciliation. The property that made a row unsafe for a slot makes it correct for a pace. No Redis exists in docker-compose.prod.yml, so Postgres is the only shared state. Timestamps come from the database's clock_timestamp(), never time.monotonic(), whose epoch is per-process -- persisting one for another process to read would reintroduce the defect while looking coordinated. The backoff multiplier and clean streak move into the same row because a global rate ceiling requires a global backoff term: processes holding different multipliers would write conflicting paces into the same row. #644's 429/503 semantics and decay schedule are unchanged; only their storage moved. max_concurrency stays per-process on purpose -- it is a local resource bound, not the destination-protecting ceiling. COST: 1.5ms mean / 2.3ms p95 per reservation over 300 calls -- 0.78% of a ~300ms image fetch, ~1% of the 143ms interval it schedules. The pooled runner is not measurably slowed. If the row is unreachable, pacing degrades to per-process and the run continues, logged at ERROR -- deliberately the opposite of stage_e_concurrency's fail-closed, since an unavailable rate budget still leaves every process individually paced. Default-on, no flag. Wiki-facing docs/features/stage-e-operations.md states the per-process-vs-global distinction explicitly, with the mechanism, the rejected alternatives and the measured cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley
added a commit
that referenced
this pull request
Jul 30, 2026
Owner clarification, 2026-07-30: "to be clear: the 7 fetches per second cap is a global cap, it shouldn't be per process or per core". `harvest_fetch_limiter._DestinationLimiter` kept its pacing state in one process's memory, so PR #644's `rate_per_sec = 7.0` bought 7/s PER PROCESS. That is a real ceiling for the pooled runner (one process, one thread pool) and no ceiling at all for the conveyor: django-q2's workers are separate OS processes, so N concurrent dispatches fetched at N x 7/s - 14/s at the shipped STAGE_E_MAX_CONCURRENT_DISPATCHES = 2, rising with that cap. New `cardpicker/harvest_rate_coordinator.py` moves the pacer's own `max(now, next_allowed) + interval` arithmetic into a single atomic `INSERT ... ON CONFLICT DO UPDATE` over one cursor row every fetching process shares. One round trip per fetch, Postgres's own clock, no advisory lock, no explicit transaction. No new migration: the cursor lives in the existing `shared_cache` table (0092), under a key Django's own `make_key` cannot produce. The migration graph is contended and a one-row table is not worth a leaf. Losing the row is benign - the next reservation re-inserts at "now". Degradation is divided, not open and not closed: if Postgres is unreachable, each process falls back to its own pacer at rate / (STAGE_E_MAX_CONCURRENT_DISPATCHES + 1), so the aggregate ceiling still holds with every process fetching. Failing open would restore the exact defect; failing closed would halt an unattended 230,753-card pass over a blip. A degraded reservation waits, never raises, never trips. Purely additive to #644: the throttle-not-halt conversion, the 429/503 classification, the backoff decay and the envelope bar classification are untouched. Backoff stays per-process and reaches the shared cursor as a widened interval, which can only slow the aggregate. Proven with real forked OS processes, not one limiter: three processes at a 20/s ceiling deliver 20/s together. Against the pre-fix pacer the same test measures 63/s. Coordination costs 1.1-1.3 ms per reservation (0.8-0.9% of the 143 ms interval a 7/s ceiling already imposes); eight acquisitions at the real 7.0/s ceiling take 1.003s against 1.000s for the ceiling alone, +0.3%. Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
WilfordGrimley
added a commit
that referenced
this pull request
Jul 30, 2026
…ess + census leak, mid-pass envelope re-sampling (#665) FIX 1 - THE MD5 GROUP BEHAVES AS ONE UNIT THROUGH THE MONOLITH Owner: "the md5 dedupe should only fetch each identical image once across sources and then apply votes to the entire group as the fetched card passes through the monolith." The fetch half already existed (`evidence_transfer`, keyed on `Card.md5_checksum`). The vote half existed only on the phash distance-0 key, so the set that got a fetch saved and the set that got a vote propagated were DIFFERENT SETS - byte-identical files always share a phash, but files sharing a phash are not necessarily byte-identical. CHECKED BEFORE BUILDING, as instructed: propagation is NOT redundant. `evidence_transfer` gives every md5 sibling its own ImageEvidence row with byte-identical extractor values, so it is reasonable to ask whether each member already reaches the same conclusion independently. It does not, structurally: a Stage D printing deduction is not a function of the evidence row alone. `_resolve_candidates_for_card` keys the candidate list on `Card.name`, and md5-identical uploads from different sources routinely carry different names. Members also differ on per-card eligibility. `test_the_unfetched_twin_has_no_verdict_of_its_own_without_propagation` falsifies the "N independent deductions already agree" hypothesis on the fixture rather than arguing it. Stage C+ now runs TWO tiers through ONE propagation engine (`_propagate_over_groups`), which takes the grouping as a parameter and knows nothing about how it was keyed: md5 exact identity -> shares a PRINTING vote. New. Runs first. phash d0 unchanged from PR #660. Runs second, filling only what md5 did not. Two defects in the PR #660 propagation are fixed on the way: - SOURCE VOTES WERE READ FROM REPRESENTATIVES ONLY. Stage D has no reason to reach a group's lowest pk first, so whenever it reached any other member, nothing propagated. Source votes are now read across every member, and one source per (group, identity) is chosen deterministically so two vote-holders in one group cannot generate duplicate rows inside a single write batch. - REPRESENTATIVES WERE NEVER PROPAGATION TARGETS. Same root cause, opposite direction. PROPAGATION NEVER OVERRIDES A MEMBER'S OWN INELIGIBILITY (owner constraint). A member already resolved, already confirmed to a `canonical_card`, not a CARD, or carrying a resolved custom-art/non-english tag is skipped. custom-art is the catalogue DECLARING the image is not a faithful depiction of a printing; a checksum must not overturn that. THE PHASH TIER IS LEFT IN PLACE, flagged rather than accreted. Issue #661 holds what phash grouping is FOR - the owner's direction is that it should eventually share an ILLUSTRATION (same artwork, possibly a different printing), not a printing verdict. Removing it now would itself be a behaviour change, and it is currently the only propagation reaching cards with no md5 at all (md5 is NULL for every LOCAL_FILE source by design). The `groups` parameter is the seam that tier plugs into later. FIX 2a - `run_name_frequency_elimination` NEVER LOOKED AT THE IMAGE Owner: "just because a card was printed exactly once doesn't mean that the image in our catalogue is an accurate depiction of that card, it may have a different border or another issue." Owner leaned toward adding the conjunct rather than dropping the tier; the conjunct is what shipped, and the reasoning for keeping the tier is in its own docstring. Everything the 1:1 gate checked was a COUNT. Counting establishes that IF the card depicts one of the name's printings THEN it is the uncovered one; nothing established the antecedent, and the only filters that spoke to it were the DECLARED custom-art/non-english tags - so an untagged altered border sailed through. "It is only a vote" is weaker than it sounds: #593 established a machine vote is what the question feed renders as the suggestion to confirm, and the human's click returns as a full-weight USER vote. The missing conjunct now requires the card's ALREADY-STORED evidence to be consistent with the candidate printing. NOT a new implementation: `_apply_agreement_checks`' border/frame check was lifted to `local_identify_printing_tags.printing_attribute_disagreement` and both callers now share it. That direction is forced - `local_calculate_verdicts` imports `local_identify_printing_tags`, never the reverse. Sharing also inherits PR #656's `artist_ocr` gate for free, which is the half a second copy would most likely have got wrong. NO STORED EVIDENCE MEANS ABSTAIN. This module's "missing data is not evidence" rule protects a match from being VETOED by silence; here silence is being asked to ESTABLISH something, so it points the other way. Counted separately from mismatches so the cost is legible. FIX 2b - THE CENSUS LEAK (a fresh wrong positive, not a stale vote) `_eligible_base_queryset(NAME_FREQUENCY_ANONYMOUS_ID)` was called with no `run_id`, making its "exclude cards already carrying this calculator's vote" LIFETIME. The gate is a COUNT over exactly that population, so the calculator was taking a census over a pool it permanently shrinks itself: run 1 votes on a card, a second upload of that name arrives, and run 2 sees one unresolved card where there are really two - and votes. Nothing about the second card changed; only the size of the population the gate counts. `compute_covered_printing_pks()` stays catalogue-wide and unscoped, deliberately: "covered" is a fact about the world, not about this calculator's progress. `run_pilot`'s `select_candidates` and `count_below_resolution_floor` are LEFT UNSCOPED - neither gates on a count over the returned population, so neither has this defect. Stated in `_eligible_base_queryset`'s docstring so the asymmetry is visible from the function rather than only from its callers. FIX 3 - THE MONOLITH RE-SAMPLES THE ENVELOPE MID-PASS Owner: "host resampling is likely required (for steps that aren't fetch) as the same monolith will run for small datasets and large ones so needs to fit the available compute appropriately." PR #660 checked the envelope ONCE, before Stage C. `_EnvelopeSentry` now re-samples at every stage seam: after Stage C, between each of Stage D's calculators/casters (via a new OPTIONAL `envelope_check` callback on `stage_e_dispatch._run_stage_d`, defaulting to None so the conveyor is byte-identical), and before each Stage C+ tier. Sample counts land on the ledger. HALT SEMANTICS PRESERVED. A breach still persists an EnvelopeTrip, still exits 3, still needs `resolve_envelope_trip` - no self-resume, and NOT converted to a throttle (that is rate pressure's channel, beneath Stage C, PR #644). A mid-pass halt message differs from the preflight's: rows already written STAY written, and it names the `--run-id` to resume with. Interval-gated at 60s so the check cannot become its own load. The number is derived, not tuned: the host-load bar reads the ONE-MINUTE load average, so sampling faster re-reads a number that has not finished moving. RESIDUAL, reported not hidden: the seams are BETWEEN calculators, not inside them. Closing that gap means threading a callback into each of seven calculators' own batch loops - a real refactor of shared code, deliberately not done here. DELIBERATE DUPLICATION, WITH A TRIPWIRE. `_members_eligible_for_a_propagated_vote` expresses four catalogue-level facts `_eligible_base_queryset` also expresses. It does not call that function (which bundles workload rules wrong for a propagation target) and that function could not be refactored to expose them (its own docstring records that tests and `stream_backstop_sweep` assert against its COMPILED SQL). `TestPropagationEligibilityMatchesTheBaseQueryset` fails if the two ever disagree. VERIFICATION - mutation red, restore green (7 mutants, all red; 263 tests green restored): M1 md5 tier returns no groups 3 failed M2 source votes read from representatives only 2 failed M3 propagation ignores member ineligibility 1 failed M4 envelope re-sampling reverted to preflight 2 failed M5 visual conjunct never disagrees 1 failed M6 no-evidence no longer abstains 1 failed M7 propagation eligibility drops the tag excludes 1 failed (the tripwire) Suites: test_run_pipeline, test_local_identify_printing_tags, test_local_calculate_verdicts, test_stage_e_dispatch - 520+ tests, all green. No model changes, so no migration. Docs: living pages only, no dated report - docs/identification-pipeline.md (Stage C+ md5 section), docs/features/printing-tags.md (both name-frequency fixes), docs/features/stage-e-operations.md (mid-pass re-sampling). Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN Co-authored-by: Claude Opus 5 <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.
Owner ruling, 2026-07-30:
1. The ceiling: 7/s, and where "or hardware" actually lives
harvest_fetch_limiter.GOOGLE_IMAGE.rate_per_secdrops 8.0 → 7.0. That contradicts nothing task #165's concurrency probe established — 7.0 sits strictly under the concurrency=6 step's measured 8.116/s, so it only tightens it.max_concurrencyis unchanged at 6 and is deliberately not the subject: the rate limit, not the concurrency limit, is what actually protects the destination, which is why the owner specified it in requests/second."or hardware, whichever comes first" gets no second constant, on purpose.
_DestinationLimiteris a strict minimum-interval pacer — it can only ever delay a request, never issue one, never make a slow host go faster. The achieved rate is thereforemin(7.0, whatever this host and network sustain)by construction, andcurrent_rate()(logged every 50 requests) reports which term is binding. There is a test for exactly this property.A configured hardware-derived rate would need a mean per-fetch latency term nobody has measured at catalog scale.
stage_e_batch_sizing.HostProfile(#589) gives usable cores, a memory budget andaggregate_fetch_threads— counts, not a rate. Deriving a req/s from those would be precisely the fabricated ceiling #589 removed when it deleted the falseGOOGLE_IMAGE.max_concurrencyterm. #589's ownHostProfile.fetch_overcommittedremains the honest hardware-vs-destination signal and is untouched here.2. Throttle, don't shut down — the bar classification
This is the substance. Of the four ratified envelope bars, exactly one halted for what is really rate pressure:
What was wrong. A Google 429 reached the dispatcher as
fetch_card_image_bytes() -> None— byte-for-byte indistinguishable from a 404 or a corrupt download, becauserate_limited_getreturned the 429 response and the caller's ownraise_for_status()flattened it into a generic exception. It was recorded on the operating envelope's rolling fetch-outcome window, and >1% of a 500-card window hard-stopped a 230,753-card unattended pass at exit 3, demanding a humanresolve_envelope_tripacknowledgement for a condition whose correct answer is "go slower".The fix. Rate pressure gets its own channel end to end:
DestinationThrottledError→_StageCFetchOutcome.throttled→DispatchOutcome.stage_c_fetch_throttled. The limiter widens its pacing interval, the one card is deferred, the fetch-ahead thread carries on (unlike a lockout or a crash, which both stop it), and the pass completes at exit 0. The failure window never sees it. This is a narrowing, not a weakening — every bar still halts for everything it ever halted for, except a destination asking us to slow down.3. Extending the existing throttle path rather than duplicating it
stream_full_catalog's pre-existing--max-throttle-retries/ exit-4 budget coversthrottled-concurrency-caponly — our ownSTAGE_E_MAX_CONCURRENT_DISPATCHESslots being full. It is denominated in dispatch attempts and has nothing to do with Google or any request rate. Spending that budget on a destination's request rate would put a hard stop straight back onto the condition the ruling says must never stop the run, so the fetch-rate throttle sits alongside it: no retry budget, no exit code of its own, cannot stop the command. Both are documented as two distinct throttles in the module docstring and the ops doc.4. Also in this change
run_image_evidence_cohortis BULK mode, whichoperating_envelopeexplicitly does not govern at all — no envelope there to spare. It catches the new exception and reports the same shape a 429 produced before: bit-for-bit unchanged behaviour for that command, and the exception can't escape through a Future and kill the fetch pool.Verification
_run_stage_c's throttle branch to fall through (if False:) made the degradation test fail; raisingHOST_LOAD_CEILINGto 9999.0 made the halt test fail. Both restored, both green.test_sustained_rate_pressure_degrades_and_completes_at_exit_zero— 100% of fetches throttled, far past the >1% bar: exit 0, zeroEnvelopeTriprows, all 3 batches dispatched,stage_c_fetch_throttled=6/stage_c_fetch_failures=0, and the envelope window ends at(0, 0).test_a_genuine_envelope_breach_still_hard_stops_at_exit_three— load 9.0: exit 3, oneHOST_LOADtrip, one attempt, no retry.test_non_throttle_fetch_failures_still_reach_the_envelope_window— the narrowing is a narrowing: a-> Nonefetch still feeds the window(4, 4).cardpicker/tests/suite green.pre-commit run --all-filesgreen (ruff, isort, black, mypy, prettier, eslint).docs_lint.py --strictclean.Docs
Per the owner documentation rule, this edits the wiki-facing
docs/features/stage-e-operations.md(published asStage-E-Operations) rather than adding a dated report: a new "Rate pressure is throttled, not halted" section with the bar-classification table, the corrected exit-code table (code 3 can no longer be reached by a 429/503; code 4 relabelled as our own dispatch-slot cap), and the two-throttles distinction in the stop-conditions list.Interaction with #589
#589 is already merged (
128492dc, 2026-07-30T09:07Z) and this branch is cut fromorigin/masterat80bd7b3d, which contains it — so there is no ordering hazard left to reason about. Nothing #589 removed is reintroduced: no hardware term is added tostage_e_batch_sizing,fetch_overcommittedis untouched, and the only file both changes touch isstream_full_catalog.py, in disjoint regions (#589's batch-size plumbing vs. this change's docstring and one counter).🤖 Generated with Claude Code
https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN