Skip to content

Add Stage E streaming dispatch concurrency cap - #450

Merged
WilfordGrimley merged 4 commits into
masterfrom
stage-e-concurrency-cap-v2
Jul 25, 2026
Merged

Add Stage E streaming dispatch concurrency cap#450
WilfordGrimley merged 4 commits into
masterfrom
stage-e-concurrency-cap-v2

Conversation

@WilfordGrimley

Copy link
Copy Markdown

Description

Replaces #449, which was stacked on #448's now-merged (squashed) branch
and could not be cleanly rebased onto master without hand-resolving the
lock-context re-indentation of dispatch_micro_batch against the squashed
already_voted/ignore_conflicts body - rebased and force-push was ruled
out per this repo's own no-force-push rule, so this is a fresh branch/PR
carrying the same content plus one added commit (integration tests against
the real, merged dispatch_micro_batch body). See #449 for the full
original review history/discussion.

Adds a concurrency cap for Stage E's streaming dispatch loop, addressing
the SECOND, separate failure the 2026-07-24 shakedown run hit (the first,
the vote-collision IntegrityError, was #448's own fix, now on master):
eight concurrent dispatch_micro_batch calls, all running CPU-bound
OCR/phash extraction at once, tripped the envelope's host-load bar
(envtrip-20260724T214616-be6e5db9, observed load 11.85 against the
7.0 ceiling) on a host with only 7 usable compute cores
(docs/features/catalog-completion-plan.md L1794/2248/2366's hardware
citation and its own 0.31x-slower-than-sequential CPU-bound-
oversubscription finding).

The envelope's own host-load bar is REACTIVE - check_envelope only trips
after a fresh signal sample crosses 7.0, necessarily after load has
already spiked (the incident's own trip landed 0.43s after the vote had
already landed). This cap is PROACTIVE: dispatch_micro_batch now refuses
to even start once settings.STAGE_E_MAX_CONCURRENT_DISPATCHES (default
2, env-tunable) dispatches are already running concurrently, anywhere
across this box's django-q2 worker processes - the host is never driven
past a bounded concurrency level by Stage E's own dispatches in the first
place. Both mechanisms stay in place; neither supersedes the other.

Mechanism - Postgres session-scoped advisory locks
(cardpicker.stage_e_concurrency, new module, no migration), not a
cache-based counter or a dedicated low-worker django-q queue:

  • A cache-based counter was rejected: this app's cache backend is
    Django's default, per-PROCESS LocMemCache (no CACHES override in
    settings.py) and django-q2's 8 workers are separate OS processes
    (multiprocessing) - a cache-based counter would silently fail to
    coordinate across them.
  • A dedicated low-worker django-q queue was rejected as disproportionate -
    a second Cluster process needs its own supervisor/deployment wiring, a
    much larger blast radius than a primitive enforced inside
    dispatch_micro_batch itself.
  • A DB-row-based atomic counter was considered and rejected specifically
    for CRASH SAFETY: a kill -9'd worker would leave a row-based counter's
    slot permanently "claimed" with no reconciliation mechanism, unlike a
    Postgres session-scoped advisory lock, which Postgres auto-releases the
    instant the holding connection dies - matching this pipeline's own
    "truthful ledger, idempotent re-entry, zero manual cleanup" ethos
    (scripts/ops/crash_drill.sh, TestKillSafetyResumeContract) for free.

New DispatchOutcome status "throttled-concurrency-cap" - writes no
PilotRunLedger row, matching the other halted statuses' own convention.

Checklist

  • I have installed pre-commit and installed the hooks with pre-commit install before creating any commits.
  • I have updated any related tests for code I modified or added new tests where appropriate.
  • I have manually tested my changes as follows:
    • pytest cardpicker/tests/test_stage_e_concurrency.py cardpicker/tests/test_stage_e_dispatch.py cardpicker/tests/test_local_calculate_verdicts.py (149 passed) via the host mpcautofill-pilot venv against the ephemeral testcontainer Postgres (never the live prod stack), run AFTER rebasing this branch onto current master (which now contains Fix Stage E concurrent-dispatch vote-collision IntegrityError #448's squashed content) to confirm the cap still triggers correctly against the real, merged dispatch_micro_batch body - not just the pre-merge module-level unit tests.
    • Includes genuine cross-session race tests (a raw psycopg2 connection standing in for a second django-q worker process, matching production's real "each dispatch = its own connection" shape - a discovered, load-bearing subtlety: Postgres session-level advisory locks are RE-ENTRANT within one session, so simulating "two dispatchers" by calling the acquire function twice on the SAME connection silently re-acquires the same slot instead of testing anything real; every multi-dispatcher test here uses a genuinely independent connection per simulated dispatcher), a real-OS-thread contention test with a shared "max simultaneously held" counter, and (new in this branch) a full-dispatch_micro_batch-level integration test confirming status="throttled-concurrency-cap" with zero ledger/evidence/vote writes when every slot is externally held, and normal completion once released.
    • black --check, ruff check, isort --check, mypy all clean on every touched file; docs_lint.py clean (including the mechanical CLEAN-row tether for the new extractable-primitives.md row).
  • I have updated any relevant documentation or created new documentation where appropriate.
    • docs/features/stage-e-operations.md: new "Concurrency cap" subsection, renumbered dispatch-ordering steps, and the reactive-vs-proactive framing against the envelope.
    • docs/upstreaming/extractable-primitives.md: new CLEAN row for cardpicker.stage_e_concurrency (zero fork-only imports - a genuinely generic Postgres-advisory-lock concurrency primitive).

WilfordGrimley and others added 2 commits July 24, 2026 23:02
Companion to PR #448's vote-collision fix (Tron gate round 1, COMPANION
item), kept as a separate PR per that gate's own instruction. The
shakedown's first live run had eight concurrent dispatch_micro_batch
calls - all running CPU-bound OCR/phash extraction at once - trip the
envelope's host-load bar (11.85 vs 7.0 ceiling) on a host with only 7
usable compute cores. The envelope only trips REACTIVELY, after load
has already spiked; this cap is PROACTIVE, refusing to even start a
dispatch once settings.STAGE_E_MAX_CONCURRENT_DISPATCHES (default 2)
concurrent dispatches are already running.

Mechanism: Postgres session-scoped advisory locks
(cardpicker.stage_e_concurrency), not a cache-based counter (this app's
cache is per-process LocMemCache, useless across django-q2's 8 separate
worker processes) or a dedicated low-worker queue (disproportionate
infra for a conservative cap). Chosen over a DB-row counter specifically
for crash safety: a killed process's session-scoped lock auto-releases,
no reconciliation code needed - no new migration either.

New DispatchOutcome status "throttled-concurrency-cap" (no ledger row,
matching the other halted statuses). Tests include genuine cross-session
races (a raw psycopg2 connection standing in for a second django-q
worker process) and a real-thread contention test with a shared
max-concurrently-held counter, not just sequential calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… body

Verifies dispatch_micro_batch itself (post-#448 merge) returns
throttled-concurrency-cap and does zero work when every slot is held by
a genuinely independent connection, and proceeds normally once released.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@WilfordGrimley
WilfordGrimley merged commit fc43f13 into master Jul 25, 2026
9 checks passed
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.

1 participant