perf: push card_ids into every Stage D dependency subquery (#533) - #579
Conversation
|
CI:
Same shape on a sibling branch that touches nothing related ( Locally, with a throwaway merge migration to make a test database buildable at all (not committed):
Needs a rebase once the migration-graph fix lands; expected green then. |
The last surviving instance of the defect class PR #541 was written to eliminate - and the one that mattered most, because #541 fixed five calculators OUTSIDE the dispatch loop and explicitly did not touch these, so it survived in three of the four calculators already in Stage E's hot path. `_fallback_eligible_cards_queryset` forwarded `card_ids` to `_eligible_cards_queryset` (the outer `Card` query and its own-exclusion) but built its two join-key no-hit DEPENDENCY SUBQUERIES unscoped. `_slow_path_eligible_cards_queryset` built four unscoped. And `local_illustration.run_illustration_calculator` built the same join-key pair unscoped at its call site, then handed it to `_eligible_illustration_cards_queryset`, which cannot scope what it receives as an argument. Django compiles `.filter(pk__in=<values_list qs>)` as an UNCORRELATED `IN (SELECT ...)`. The outer `.filter(pk__in=card_ids)` therefore bounds the ROWS RETURNED, not the WORK DONE: every micro-batch paid a full pass over `CardPrintingTag` (167,229 rows live) or `CardScanLog` (2,617,333 rows live, append-only, still growing) regardless of batch size. Keeping the subqueries LAZY - which `local_illustration` already did - does not help; laziness only decides whether the rows land in Python memory, not whether the database scans the table. `_join_key_no_hit_subqueries` is now the single place that pair is built, for all three calculators, so a fourth cannot repeat the omission. The slow-path calculator's own two exclusions (already-routed `CardScanLog`, fallback-voted `CardPrintingTag`) are scoped inline alongside them. Measured against the live catalogue, eligibility query alone, no calculator work, median of 12 reps, read-only: batch 25 batch 250 stage-d-fallback-v1 1113.3 -> 2.6 ms 1212.5 -> 8.1 ms stage-d-slow-path-v1 959.5 -> 2.2 ms 991.0 -> 7.5 ms stage-d-illustration-v2 1117.2 -> 2.9 ms 2101.8 -> 10.7 ms ~3.2 s of fixed per-invocation cost removed at the production batch size of 25 - which at that batch size outweighed all nine calculators' actual compute, and is why Stage D cost 174-196 ms/card at batch 25 against 28-32 ms/card at batch 250. BULK mode (`card_ids=None`, every management-command caller) is byte-identical: the compiled SQL of all three eligibility queries was diffed against `origin/master`'s under a fixed `PYTHONHASHSEED` and is unchanged to the byte. Tests assert on the COMPILED SQL, not the result set, per #541's established rationale: the outer filter produces the same rows whether or not the push-down happened, so a result-set test is green either way and proves nothing. The new SQL assertions were mutation-proved - reverting the push-down turns exactly those three red while every result-set test in the same class stays green, which is the vacuous-green defect demonstrated rather than argued. Refs #533, #541, #469, #526, #458, #460. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
Semantic (not textual) conflict surfaced by rebasing onto master after PR #567 landed. #567 renamed `SLOW_PATH_TO_REVIEW_REASON` -> `SLOW_PATH_TO_REVIEW_SKIP_REASON` in `cardpicker/local_calculate_verdicts.py`; this branch had concurrently ADDED a new test (`test_slow_path_scoped_and_unscoped_eligible_sets_agree`) referencing the OLD name. Git merged both cleanly - #567 touched only the declaration, this branch only added lines - so the breakage was invisible in the diff and would have shown up as a `NameError: SLOW_PATH_TO_REVIEW_REASON` at test run time (the import block at the top of the file already imports the NEW name, so collection succeeds and only this one test explodes). No behaviour change: the value behind both names is the same string "to-review". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
2991c23 to
c62770e
Compare
* Autoscale the Stage E micro-batch size from measured hardware (#533 follow-on) `settings.STAGE_E_MICRO_BATCH_SIZE = 25` was an explicit placeholder - the stage-e-streaming brief's §10(c) said the real number ships as a MEASURED output and nobody had measured it. Measured now, against the live catalog, and the answer is not a constant. New `cardpicker/stage_e_batch_sizing.py` decides the size from three terms, smallest wins, all three measured or ratified: * SATURATION - 250 cards. One dispatch costs `F + (m + c) * N`; only `F/N` improves with batch size, and `c` (Stage C's fetch-overlapped per-card floor, 561 ms measured) is set by `harvest_fetch_limiter.GOOGLE_IMAGE`, not by the box. 250 is the smallest tested size whose residual per-card fixed cost is under 1 ms. Deliberately NOT hardware-scaled: a bigger host buys more concurrent dispatch streams, not a bigger batch. * MEMORY - `(min(768, available/streams) - 320) / 0.002` cards, against the ratified per-worker RSS bar. Measured marginal is 1.0-1.9 KB/card, so this is worth ~224,000 cards on the production host and only ever bites on a host too small to hold the working set at all. * DURATION - `300s / (561ms * max(1, 7.0/usable_cores))`. The batch is the envelope's sampling interval AND the kill-loss bound; 300 s is `DEFAULT_PROGRESS_EVERY_SECONDS`. The inflation comes from HOST_LOAD_CEILING (flat 7.0 on every host) divided by discovered cores - the worst contention a dispatch may legally start under - never from a live getloadavg sample, which would make the size differ per dispatch. Measured effect on the production host: 25 -> 250 cards, per-card batch-fixed cost 8.70 -> 2.39 ms, peak RSS flat at ~298 MB (39% of the 768 MB bar) across N=10..2000. Two modes. MODE_BULK (`stream_full_catalog`, the whole-catalogue pass) gets the autoscaled size. MODE_INCREMENTAL (the event echo via `dispatch_for_card`, and `stream_backstop_sweep`, whose footprint is `--max-batches` x batch size with a default of 1000) stays on 25 - both want a small bounded unit of work, and both are behaviourally unchanged. Precedence is unchanged where it matters: `--batch-size N` wins outright, a non-None `settings.STAGE_E_MICRO_BATCH_SIZE` wins next, the rule last. That setting now defaults to None so "an operator chose 25" is distinguishable from "nobody chose anything". `--batch-size auto` names the rule explicitly, and every run prints the chosen size, its source and the binding term before batch 0, so `stream_full_catalog`'s "every tunable is a flag, changing one never costs a redeploy" property survives intact. Refs #533, #579, #458, #472. * Correct the false per-process fetch ceiling in Stage E batch sizing `discover_host` sized dispatch concurrency as min(STAGE_E_MAX_CONCURRENT_DISPATCHES, GOOGLE_IMAGE.max_concurrency, usable_cores) and two of those three terms are wrong for the question being asked. `GOOGLE_IMAGE.max_concurrency` was justified as "a stream past the limiter's sixth just blocks on its semaphore". It does not. That ceiling is `threading.Semaphore(config.max_concurrency)`, constructed once per PROCESS in `harvest_fetch_limiter._DestinationLimiter.__init__`, and concurrent dispatches are separate OS processes (`stage_e_concurrency`: django-q2 workers are "separate OS PROCESSES (multiprocessing, not threads)"). Each builds its own full-strength Semaphore(6) that cannot see the others. This is the same defect `run_image_evidence_cohort` documents having needed a per-worker "descaling hack" for, back when fetching lived inside N compute processes; the conveyor is still the multi-process case, so no cross-process mechanism enforces that 6. `usable_cores` bounds who is SCHEDULED, not who is RESIDENT. A descheduled dispatch process still holds its whole RSS, and this number is the divisor in `_memory_limit`. Both trims therefore UNDER-counted the resident process set, in the unsafe direction: with a cap of 12 the rule computed 6 and handed each process twice the memory budget it may really take. The only cross-process cap on dispatch count is `stage_e_concurrency`'s Postgres advisory-lock slots, i.e. the setting itself, so the setting is the whole answer. - `HostProfile.dispatch_streams` -> `concurrent_dispatches`, a PROCESS count, equal to `STAGE_E_MAX_CONCURRENT_DISPATCHES` with no `min()`. - New `FETCH_THREADS_PER_DISPATCH = 1` (the single `_stage_c_fetch_ahead_worker` thread), and `HostProfile.aggregate_fetch_threads` = the honest cross-process product, explicitly NOT clamped to 6, with the `threading.Semaphore` line and its multi-process consequence named at the term. - New `HostProfile.fetch_overcommitted` + a shouted `FETCH-OVERCOMMIT:` prefix on `describe()`. Sizing cannot fix an overcommit (it is a function of process count), so the rule reports it instead of hiding it. Production default of 2 is well inside the budget of 6, so this is a guard, not a live alarm. - Module docstring's "aggregate throughput across dispatches is capped by GOOGLE_IMAGE.max_concurrency = 6" removed as false. SATURATION_BATCH_SIZE=250 is unchanged and unaffected: it rests on the measured per-card `c` floor within ONE dispatch, a serial property of that single fetch thread, which needs no cross-process guarantee. Tests: three new assertions in `TestHostDiscovery`/`TestDecisionReporting` fail against the old expression with `assert 6 == 12` and `assert 6 == (12 * 1)`, plus a memory-guard test pinning the sizing consequence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…e name-only face reading `_slow_path_eligible_cards_queryset` excluded `already_routed` and `fallback_voted` and had NOTHING for the illustration calculator. `management/commands/local_calculate_verdicts.py`'s own sequencing comment admitted it - "the slow-path queryset would need an additional exclusion for this identity's votes". PR #604 did not close it. Failure direction is WRONG HUMAN WORK, not a silent no-op: Stage D sequences join-key -> fallback -> illustration -> slow-path, so a card the illustration calculator resolves is routed to a reviewer moments later in the SAME invocation, asking a human to identify a card the pipeline just identified. Bounded so far only because `stage-d-illustration-v2` has never run; the read-only replay in docs/pipeline-fidelity-gate.md projects ~3,233 printing votes, so it fires on the first `-v2` run. This is a pre-fire fix. One `.exclude(pk__in=illustration_voted_card_ids)`, built exactly like the fallback one: `is_no_match=False` qualified (an illustration `is_no_match` vote is the calculator CONCLUDING it cannot identify the card - precisely a card a reviewer should see), `card_ids`-pushed-down per PR #579, and deliberately NOT run-scoped, because "illustration has a confident vote for this card" is a statement about the catalogue rather than about a run. The identity is a duplicated literal per this module's established "no hard import-time dependency between sibling engines" convention - with a test asserting it equals `local_illustration.ILLUSTRATION_ANONYMOUS_ID`, so a future `-v3` bump fails there instead of silently reopening the defect. ALSO, test-only: `CanonicalPrintingMetadata.face_illustrations` is about to be populated in production. 1,594 of 113,224 printings get a non-empty list, and 60 of those are NAME-ONLY (`{name: ..., illustration_id: None}` throughout) - non-empty lists carrying no usable illustration, which satisfy the partial index `cpm_face_illustrations_present`. Any consumer reading list truthiness as "has back-face art" is wrong for exactly those 60 the moment the importer runs. Audited every consumer. BOTH are already correct: `IllustrationIndex._build` tests `illustration_id is None`, and `printings_for_illustration`'s JSONB containment asks for a real uuid a `None` cannot satisfy. The version stamp's `.exclude(face_illustrations=[])` is a CHANGE DETECTOR, where counting a name-only row is the correct behaviour. Nothing to fix - so the readings are pinned with a name-only fixture instead, proven by mutation to fail against the truthiness reading in both consumers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
…e name-only face reading `_slow_path_eligible_cards_queryset` excluded `already_routed` and `fallback_voted` and had NOTHING for the illustration calculator. `management/commands/local_calculate_verdicts.py`'s own sequencing comment admitted it - "the slow-path queryset would need an additional exclusion for this identity's votes". PR #604 did not close it. Failure direction is WRONG HUMAN WORK, not a silent no-op: Stage D sequences join-key -> fallback -> illustration -> slow-path, so a card the illustration calculator resolves is routed to a reviewer moments later in the SAME invocation, asking a human to identify a card the pipeline just identified. Bounded so far only because `stage-d-illustration-v2` has never run; the read-only replay in docs/pipeline-fidelity-gate.md projects ~3,233 printing votes, so it fires on the first `-v2` run. This is a pre-fire fix. One `.exclude(pk__in=illustration_voted_card_ids)`, built exactly like the fallback one: `is_no_match=False` qualified (an illustration `is_no_match` vote is the calculator CONCLUDING it cannot identify the card - precisely a card a reviewer should see), `card_ids`-pushed-down per PR #579, and deliberately NOT run-scoped, because "illustration has a confident vote for this card" is a statement about the catalogue rather than about a run. The identity is a duplicated literal per this module's established "no hard import-time dependency between sibling engines" convention - with a test asserting it equals `local_illustration.ILLUSTRATION_ANONYMOUS_ID`, so a future `-v3` bump fails there instead of silently reopening the defect. ALSO, test-only: `CanonicalPrintingMetadata.face_illustrations` is about to be populated in production. 1,594 of 113,224 printings get a non-empty list, and 60 of those are NAME-ONLY (`{name: ..., illustration_id: None}` throughout) - non-empty lists carrying no usable illustration, which satisfy the partial index `cpm_face_illustrations_present`. Any consumer reading list truthiness as "has back-face art" is wrong for exactly those 60 the moment the importer runs. Audited every consumer. BOTH are already correct: `IllustrationIndex._build` tests `illustration_id is None`, and `printings_for_illustration`'s JSONB containment asks for a real uuid a `None` cannot satisfy. The version stamp's `.exclude(face_illustrations=[])` is a CHANGE DETECTOR, where counting a name-only row is the correct behaviour. Nothing to fix - so the readings are pinned with a name-only fixture instead, proven by mutation to fail against the truthiness reading in both consumers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
…e name-only face reading (#655) `_slow_path_eligible_cards_queryset` excluded `already_routed` and `fallback_voted` and had NOTHING for the illustration calculator. `management/commands/local_calculate_verdicts.py`'s own sequencing comment admitted it - "the slow-path queryset would need an additional exclusion for this identity's votes". PR #604 did not close it. Failure direction is WRONG HUMAN WORK, not a silent no-op: Stage D sequences join-key -> fallback -> illustration -> slow-path, so a card the illustration calculator resolves is routed to a reviewer moments later in the SAME invocation, asking a human to identify a card the pipeline just identified. Bounded so far only because `stage-d-illustration-v2` has never run; the read-only replay in docs/pipeline-fidelity-gate.md projects ~3,233 printing votes, so it fires on the first `-v2` run. This is a pre-fire fix. One `.exclude(pk__in=illustration_voted_card_ids)`, built exactly like the fallback one: `is_no_match=False` qualified (an illustration `is_no_match` vote is the calculator CONCLUDING it cannot identify the card - precisely a card a reviewer should see), `card_ids`-pushed-down per PR #579, and deliberately NOT run-scoped, because "illustration has a confident vote for this card" is a statement about the catalogue rather than about a run. The identity is a duplicated literal per this module's established "no hard import-time dependency between sibling engines" convention - with a test asserting it equals `local_illustration.ILLUSTRATION_ANONYMOUS_ID`, so a future `-v3` bump fails there instead of silently reopening the defect. ALSO, test-only: `CanonicalPrintingMetadata.face_illustrations` is about to be populated in production. 1,594 of 113,224 printings get a non-empty list, and 60 of those are NAME-ONLY (`{name: ..., illustration_id: None}` throughout) - non-empty lists carrying no usable illustration, which satisfy the partial index `cpm_face_illustrations_present`. Any consumer reading list truthiness as "has back-face art" is wrong for exactly those 60 the moment the importer runs. Audited every consumer. BOTH are already correct: `IllustrationIndex._build` tests `illustration_id is None`, and `printings_for_illustration`'s JSONB containment asks for a real uuid a `None` cannot satisfy. The version stamp's `.exclude(face_illustrations=[])` is a CHANGE DETECTOR, where counting a name-only row is the correct behaviour. Nothing to fix - so the readings are pinned with a name-only fixture instead, proven by mutation to fail against the truthiness reading in both consumers. Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The last surviving instance of the defect class PR #541 was written to eliminate — and the one that mattered most. #541 fixed five calculators outside the dispatch loop and explicitly did not touch these, so the defect survived in three of the four calculators already in Stage E's hot path.
The defect
.filter(pk__in=<values_list qs>)compiles to an uncorrelatedIN (SELECT ...). The outer.filter(pk__in=card_ids)therefore bounds the rows returned, not the work done: every micro-batch paid a full pass overCardPrintingTag(167,229 rows live) orCardScanLog(2,617,333 rows live, append-only, still growing) regardless of batch size._fallback_eligible_cards_queryset_slow_path_eligible_cards_querysetlocal_illustration.run_illustration_calculator→_eligible_illustration_cards_querysetThe illustration case is the instructive one: that pair was already lazy, and a comment said so. Laziness only decides whether the rows land in Python memory — it says nothing about whether the database scans the table.
_eligible_illustration_cards_querysetreceives the pair as arguments and cannot scope what it is handed, so the fix has to be at the call site. Its docstring now carries that as an explicit caller contract._join_key_no_hit_subqueriesis now the single place the join-key no-hit pair is built, for all three calculators, so a fourth cannot repeat the omission. The slow-path calculator's own two exclusions (already-routedCardScanLog, fallback-votedCardPrintingTag) are scoped inline alongside them.Measurements
Live catalogue, eligibility query alone (no calculator work), median of 12 reps, read-only session:
stage-d-fallback-v1stage-d-slow-path-v1stage-d-illustration-v2~3.2 s of fixed per-invocation cost removed at the production batch size of 25 — which at that batch size outweighed all nine calculators' actual compute, and is why Stage D cost 174–196 ms/card at batch 25 against 28–32 ms/card at batch 250.
Result sets are identical before and after:
stage-d-illustration-v2returned the same 25 / 250 rows either way.stage-d-fallback-v1andstage-d-slow-path-v1returned 0 rows for every batch — their production backlogs are genuinely drained right now (0 eligible cards across a 20,000-card sample of each branch of the join-key no-hit population), so 0 is the true live answer, not a scoping artefact. Set equivalence is pinned by tests instead.card_ids=Noneis byte-identicalThe compiled SQL of all three eligibility queries was diffed against
origin/master's under a fixedPYTHONHASHSEED(theskip_reason IN (...)literal order isfrozensetiteration order and varies per process, so the seed has to be pinned to compare at all). Identical to the byte — 7,548 bytes, md50fcecfb5eab427e051b33e384d9c61fdon both revisions.Tests assert on the compiled SQL
Per #541's established rationale, and it is load-bearing here: the outer filter produces the same rows whether or not the push-down happened, so a result-set test is green either way and proves nothing.
_dependency_subqueriesslices every(SELECT U0."card_id" FROM ...)out of the compiled statement by balanced parentheses — necessary because the outer"cardpicker_card"."id" IN (<pks>)term carries the same pk literals in both shapes, and an assertion against the whole statement would be satisfied by it.Each calculator gets: every dependency subquery scoped (with an exact expected count, so an added-but-unscoped subquery fails rather than slips through); the pre-fix shape reconstructed literally and shown to leave exactly the two join-key subqueries unscoped; BULK mode taking no scoping branch at all; and the result-set equivalence as the second half of the contract.
Mutation-proved. Reverting both push-downs turns exactly three tests red —
test_fallback_scopes_every_dependency_subquery,test_slow_path_scopes_every_dependency_subquery,test_the_join_key_populations_are_scoped_to_card_ids_in_the_compiled_sql— while every result-set test in the same class stays green. That is the vacuous-green defect demonstrated rather than argued.Verification
cardpicker/tests/: 3120 passed, 11 skipped, 0 failed.black/isort/ruff/mypyclean (pre-commit).mastercurrently has two0096_*leaves (0096_card_scan_log_anon_skip_idx,0096_freeze_deductive_backfill_zero_weight_cohort), somigratefails with multiple leaf nodes and no test database can be built. CI will hit the same wall until that lands.Refs #533, #541, #469, #526, #458, #460.
🤖 Generated with Claude Code
https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN