fix(cron): stop metrics partition drops from deadlocking with message inserts - #1265
fix(cron): stop metrics partition drops from deadlocking with message inserts#1265odesenfans wants to merge 1 commit into
Conversation
… inserts The metrics tables carry a foreign key to `messages`, and every monthly partition inherits it. Dropping a partition therefore locks `messages` twice within one cron transaction: DETACH creates standalone RI triggers on it (SHARE ROW EXCLUSIVE) and DROP TABLE removes them (ACCESS EXCLUSIVE). The message processor escalates on `messages` in the same way (ACCESS SHARE for the existence check, then ROW EXCLUSIVE for the upsert). When both are in flight, each holds the lock the other needs and PostgreSQL aborts one side with `DeadlockDetected`. Retention drops only fire on the first cron tick of a new month, which is why this surfaced on nodes on 2026-09-01 and not before. The lock_timeout added in #1247 does not help: the deadlock detector fires after deadlock_timeout (1s by default), long before the 5s cap. Fix: run partition creation and each partition drop in their own transaction, and have the drop transaction take ACCESS EXCLUSIVE on `messages` as its first statement. The cron then never escalates a lock it already holds, so an in-flight processor transaction can still upgrade past the queued request and commit, after which the cron proceeds. The wait stays bounded by PARTITION_LOCK_TIMEOUT and a timeout still defers the table to the next tick. The regression test reproduces the production interleaving through the real job (open read on `messages`, cron queued behind it on DROP, then the insert) and asserts both sides complete.
foxpatch-aleph
left a comment
There was a problem hiding this comment.
Correct and well-reasoned fix for a genuine mutual lock-upgrade deadlock. Taking ACCESS EXCLUSIVE on messages as the drop transaction's first statement ensures the cron never escalates a lock it already holds, letting an in-flight processor transaction upgrade past the queued request and commit — the standard remedy for this class of deadlock. I verified by inspection that the scoring-insert path (insert_node_metrics from the post handler) takes its messages locks before touching crn_metrics, so the new lock ordering introduces no cycle there; that _partitions_past_cutoff preserves the old filter semantics; and that lock_timeout still bounds every wait, with the monkeypatch-based deferral test unaffected. I could not run the tests locally (no hatch/Postgres in this sandbox), so the new interleaving test is verified by reading only. Three non-blocking observations below; none affect correctness.
src/aleph/jobs/cron/metrics_partition_job.py (line 114): Non-blocking: to_drop is now listed in one transaction and consumed by later ones. If a partition disappeared in between, DETACH would raise UndefinedTable — a ProgrammingError, which slips past except OperationalError and escapes run() (caught and logged one level up in CronJob.__run_job, then retried next tick). With one cron process per node DB this race is essentially impossible, so it is fine as-is; if you want it airtight, either re-check existence inside _drop_partition or catch ProgrammingError alongside OperationalError and treat it as a deferral.
src/aleph/jobs/cron/metrics_partition_job.py (line 200): Worth noting in the PR text: this ACCESS EXCLUSIVE on messages is held while DETACH waits for the parent's ACCESS EXCLUSIVE, so a concurrent metrics read/write can block all message traffic for up to PARTITION_LOCK_TIMEOUT (5s), not just the milliseconds described. The old code had comparable exposure once DROP's AEL request was queued, and the window is once-a-month per table, so this is acceptable — just not strictly 'milliseconds' under contention.
tests/jobs/test_metrics_partition_job.py (line 413): The processor thread must win the race to acquire ACCESS SHARE on messages before the cron's drop transaction reaches LOCK TABLE. If the cron wins, no not-granted AEL ever appears in pg_locks and the test fails on "cron never reached DROP". In practice the thread's SELECT runs well before the cron finishes its ensure queries, but setting a threading.Event after the SELECT and waiting on it before run_in_executor would make the test deterministic on loaded CI.
Problem
Nodes started logging this on 2026-09-01:
Relation 16454 is
messages. Process 8798 is themetrics_partitioncron job.The metrics tables carry a foreign key to
messages(migration 0059), and every monthly partition inherits it. Dropping a partition therefore locksmessagestwice inside one cron transaction:ALTER TABLE ... DETACH PARTITIONcreates standalone RI triggers onmessages: SHARE ROW EXCLUSIVE.DROP TABLEremoves those triggers: ACCESS EXCLUSIVE.The message processor escalates on
messagesthe same way in one transaction: the existence check inMessageHandler.processtakes ACCESS SHARE, then the upsert asks for ROW EXCLUSIVE. ROW EXCLUSIVE conflicts with the cron's SHARE ROW EXCLUSIVE, and ACCESS EXCLUSIVE conflicts with the processor's ACCESS SHARE. Each side holds what the other needs and PostgreSQL aborts one of them. Which one dies is arbitrary.Retention drops only fire on the first cron tick of a new month (a partition's upper bound has to cross the 12 month cutoff), which is why this surfaced on September 1 and not during the month. The
lock_timeoutfrom #1247 does not cover it: the deadlock detector fires afterdeadlock_timeout(1s by default), long before the 5s cap.Impact is limited: the message is retried, and when the cron is the victim it already catches the
OperationalErrorand retries next tick. But it fires on every node twice a month (once per metrics table) whenever a drop coincides with an insert, and it looks alarming in the logs.Fix
LOCK TABLE messages IN ACCESS EXCLUSIVE MODEas its first statement, before DETACH and DROP. The cron never escalates a lock it already holds onmessages, so PostgreSQL lets an in-flight processor transaction upgrade past the queued request and commit, after which the cron proceeds. No cycle can form.PARTITION_LOCK_TIMEOUT(5s). A timeout still defers the table to the next tick, as before.The cost is one brief ACCESS EXCLUSIVE on
messagesper dropped partition, once a month per metrics table, held for the duration of DETACH + DROP (milliseconds). That lock was already being taken by DROP TABLE before this change; it is just taken first now. Removing it entirely would mean dropping the FK from the metrics tables, which is out of scope here.Test
test_partition_drop_does_not_deadlock_with_message_insertreproduces the production interleaving through the real job: a thread holds an open read onmessages, waits (viapg_locks) until the cron is queued for ACCESS EXCLUSIVE onmessages, then inserts a message and commits. Without the fix it fails with the exactDeadlockDetectederror from the report; with the fix both the insert and the partition drop succeed.The existing
test_cron_defers_table_when_parent_lock_is_contendedstill passes, so the lock_timeout deferral from #1247 is preserved.Verification
black, isort and ruff are clean on both files. mypy reports no errors in the changed module (the remaining output is the pre-existing missing-stubs noise from other modules).