Fix Stage E concurrency cap: hold advisory lock on a dedicated connection - #453
Merged
Merged
Conversation
…on, not django.db.connection
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.
Description
Production-verified fix for the Stage E dispatch concurrency cap (PR #450,
MPCAutofill/cardpicker/stage_e_concurrency.py), which did not bind in its first live shakedown.Incident (2026-07-25T00:25Z shakedown):
envtrip-20260725T002504-73e1eb6d,{'ceiling': 7.0, 'load_avg': 11.4013671875}. Zerothrottled-concurrency-capoutcomes despite 8 concurrent django-q workers, plus 8 occurrences of the module's ownpg_advisory_unlock reported slot N was not held by this connectionwarning.Root cause, confirmed by reading the installed
django_qpackage: the original module held its Postgres advisory lock ondjango.db.connection(Django's shared per-thread connection), on the strength of a claim (checked against the wrong code path —django_q.worker's connection recycling, which only happens between tasks) that a singledispatch_micro_batchcall always runs as one uninterrupted segment on one connection. The real trigger:cardpicker.stage_e_signals'spost_savereceivers fire during Stage C'spersist_evidence, inside the locked region, and calldjango_q.tasks.async_task(...), which synchronously calls the installed ORM broker'senqueue.django_q.brokers.orm.ORM.get_connection()callsdjango.db.close_old_connections()unconditionally whenever not inside an atomic block — and this project'sDATABASES["default"]has noCONN_MAX_AGEoverride, so Django's own default (0) applies, meaning the connection is treated as already-expired the first time anything asks. A closed connection auto-releases every advisory lock its session held, so every worker then found every slot "free".Fix:
stage_e_concurrency.pynow opens a dedicatedpsycopg2connection (autocommit=True) it alone owns for the lifetime of onetry_acquire_dispatch_slot()call — neverdjango.db.connection. Explicitlypg_advisory_unlocks ANDclose()s in afinally, so the lock is released even if the explicit unlock itself somehow fails. The "not held by this connection" warning guard is kept unchanged — it's what caught this bug, and should now never fire again. Connection-creation failure fails CLOSED (dispatch treated as throttled) rather than proceeding uncapped, since an uncapped dispatch is exactly the failure this incident was.Observability (Tron gate anomaly 4): throttled dispatches previously wrote no ledger row and emitted only a log line, so the runbook's "tune the cap against the observed throttle rate" instruction had nothing queryable to check. Added
StageEThrottleCounter— a singleton, always-one-row, atomically-incremented counter (migration0081_stageethrottlecounter), visible in Django admin. Deliberately not a per-event row (would write-amplify under exactly the failure shape this module guards against).Zero
cardpicker.*imports preserved instage_e_concurrency.py(extractable-primitives ledger updated).Checklist
pre-commitand installed the hooks withpre-commit installbefore creating any commits.pytest cardpicker/tests/test_stage_e_concurrency.py cardpicker/tests/test_stage_e_dispatch.py -q→ 39 passed (real testcontainers Postgres).TestRegressionDedicatedConnectionSurvivesFollowOnEnqueue) catch the bug: temporarily restored the pre-fix module (django.db.connection-based lock) and confirmed both new tests fail (assert False is True— a genuinely separate session found the slot free when it shouldn't be) against it, then restored the fix and confirmed all 39 pass again.pre-commit run mypy --files <changed .py files>→ passed.pre-commit run --files <all changed files>(ruff/isort/black/mypy/prettier) → passed..github/scripts/docs_lint.py→ clean.docs/features/stage-e-operations.md(Concurrency cap section: dedicated-connection fix, fail-closed choice, throttle-observability counter)docs/troubleshooting.md(new symptom-first entry with the literal warning string)docs/lessons.md(terse entry: a static "no connection pool" review doesn't prove connection stability across a code region's own side effects)docs/upstreaming/extractable-primitives.md(updated the existing CLEAN row for the connection-lifecycle change)