Repository navigation
[DX-5066] fix(llo): avoid transmit queue deadlock in Prune - #281
Conversation
Prune deletes the oldest transmissions by seq_nr while Delete removes them by transmission_hash. Run concurrently, the two paths can hold locks on rows the other wants and Postgres reports 40P01. Select Prune's batch and final-truncate candidates with FOR UPDATE SKIP LOCKED. Prune is best-effort, so skipping a row that is being deleted right now is fine; it is reaped on the next cycle. Delete keeps blocking because its caller has already removed those transmissions from the in-memory queue. The final truncate no longer uses a window function, because FOR UPDATE cannot be combined with one. The partition there was constant (don_id and server_url are fixed), so OFFSET is equivalent.
b3a0327 to
9cc4f07
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The trimming query locks retained rows consumed by OFFSET, potentially locking 300,000 rows unnecessarily and delaying concurrent Delete calls; locking should apply only to excess candidates.
1 open issue
What changed in this PR
This PR addresses deadlocks between LLO transmit-queue pruning and deletion by making Prune skip locked rows without changing exported APIs.
Changes:
- Adds
FOR UPDATE SKIP LOCKEDto pruning queries and replaces the final window-function query withOFFSET. - Adds lock-contention regression tests and shared-database test helpers.
- Records the fix in a patch changeset.
| File | Description |
|---|---|
mercury/testutils/sql.go |
Adds shared-database handles and single-connection test sessions. |
llo/transmitter/dataengine/orm.go |
Updates batch pruning and final trimming to skip locked rows. |
llo/transmitter/dataengine/orm_test.go |
Adds locked-row and concurrent Delete/Prune regression tests. |
.changeset/llo-transmit-queue-deadlock.md |
Documents the deadlock fix for release. |
Review effort: Balanced
PR Triage · Risk 🟡 Medium · Complexity 🟡 MediumSuggests 1 approval, including one from an owner. Suggested reviewers: @brunotm, @pavel-raykov.
VisualizationsScore mapflowchart LR
R["Risk: Medium"]
rs["1 signal → floor Medium"]
s0["subsystems 2"] --> rs
rs --> R
C["Complexity: Medium"]
m0["new code size 1,706 ≥ 1,600"] --> C
RT["Suggests 1 approval · one from an owner"]
R --> RT
classDef low fill:#dafbe1,stroke:#1a7f37,color:#1f2328;
classDef medium fill:#fff8c5,stroke:#9a6700,color:#1f2328;
classDef high fill:#ffebe9,stroke:#cf222e,color:#1f2328;
class R medium;
class C medium;
Change shapepie title Change shape
"Tests" : 1081
"Source" : 225
"Trivial" : 5
— concentrated in Files: 4 changed, 1 skipped
Why these reviewers
Ranked by commits to the changed files. Commits in the 360 days before the base tip count, newer ones more. Requested everyone with at least 20% of the score, up to 4. Added 1 reviewer below the share bar to reach the approval floor. Excluded the PR author, @kalverra. Considered only current organization members. Metrics and policy
Risk signals: pr-triage v0.0.0-20261030164835-20c4213aefbb · policy |
| DELETE FROM llo_mercury_transmit_queue AS q | ||
| USING ( | ||
| SELECT transmission_hash | ||
| FROM llo_mercury_transmit_queue | ||
| WHERE transmission_hash IN ( | ||
| SELECT transmission_hash | ||
| FROM llo_mercury_transmit_queue | ||
| WHERE don_id = $1 AND server_url = $2 | ||
| ) sub | ||
| WHERE row_num > $3 | ||
| ) | ||
| DELETE FROM llo_mercury_transmit_queue | ||
| WHERE ctid IN (SELECT ctid FROM to_delete); | ||
| WHERE don_id = $1 AND server_url = $2 | ||
| ORDER BY seq_nr DESC, inserted_at DESC | ||
| OFFSET $3 | ||
| ) | ||
| FOR UPDATE SKIP LOCKED | ||
| ) AS to_delete | ||
| WHERE q.transmission_hash = to_delete.transmission_hash; |
There was a problem hiding this comment.
we need the partitioning predicate also on the middle query taking the lock. this will allow the qeury planner to estimate the scan instead of a sequential or hash join over the whole table,
| DELETE FROM llo_mercury_transmit_queue AS q | |
| USING ( | |
| SELECT transmission_hash | |
| FROM llo_mercury_transmit_queue | |
| WHERE transmission_hash IN ( | |
| SELECT transmission_hash | |
| FROM llo_mercury_transmit_queue | |
| WHERE don_id = $1 AND server_url = $2 | |
| ) sub | |
| WHERE row_num > $3 | |
| ) | |
| DELETE FROM llo_mercury_transmit_queue | |
| WHERE ctid IN (SELECT ctid FROM to_delete); | |
| WHERE don_id = $1 AND server_url = $2 | |
| ORDER BY seq_nr DESC, inserted_at DESC | |
| OFFSET $3 | |
| ) | |
| FOR UPDATE SKIP LOCKED | |
| ) AS to_delete | |
| WHERE q.transmission_hash = to_delete.transmission_hash; | |
| DELETE FROM llo_mercury_transmit_queue AS q | |
| USING ( | |
| SELECT transmission_hash | |
| FROM llo_mercury_transmit_queue | |
| WHERE don_id = $1 AND server_url = $2 | |
| AND transmission_hash IN ( | |
| SELECT transmission_hash | |
| FROM llo_mercury_transmit_queue | |
| WHERE don_id = $1 AND server_url = $2 | |
| ORDER BY seq_nr DESC, inserted_at DESC | |
| OFFSET $3 | |
| ) | |
| FOR UPDATE SKIP LOCKED | |
| ) AS to_delete | |
| WHERE q.transmission_hash = to_delete.transmission_hash; |
…treams into fix/llo-transmit-queue-deadlock

Intent
Postgres reports
40P01 deadlock detectedonllo_mercury_transmit_queuewhen concurrentDelete(locking bytransmission_hash) andPrune(locking byseq_nr) lock overlapping rows in opposite orders.Prunenow selects candidates withFOR UPDATE SKIP LOCKEDin both its batch loop and final truncate, leaving rows locked byDeletefor the next prune cycle.Changes
Pruneskips rows locked byDeleteinstead of blocking on them:Prunebatch loop skips locked rowsFOR UPDATE SKIP LOCKED; skipped rows are picked up by the next prune cycle.Final truncate locks only excess rows past the OFFSET
OFFSETsubquery wrapped inFOR UPDATE SKIP LOCKED.FOR UPDATEin the sameSELECTasOFFSETwould lock allmaxSizeretained rows (up to 300k at the default cap) and shift the offset window when a retained row is locked, over-deleting a kept row.Test helpers for multi-session lock contention
NewIndependentSqlxSessionsandNewIndependentSqlxDBsshare a private helper that opens pinned connections to one fresh database.TestORM_PruneSkipsRowsLockedByDeletelocks both a pruned and a retained row and assertsPruneskips them without shifting theOFFSETwindow;TestORM_ConcurrentDeleteAndPrunehammers both paths and fails on any error.Notes
Deleteintentionally does not useSKIP LOCKED: its caller already removed those transmissions from the in-memory queue, so skipping would silently lose rows.chainlink/core/services/llo/cleanup.go(reapStale,reapOrphaned) has the same access pattern and is fixed in a separate PR. Chainlink needs ago.modbump to this commit.