Skip to content

[DX-5066] fix(llo): avoid transmit queue deadlock in Prune - #281

Merged
kalverra merged 4 commits into
masterfrom
fix/llo-transmit-queue-deadlock
Nov 3, 2026
Merged

kalverra merged 4 commits into
masterfrom
fix/llo-transmit-queue-deadlock

Conversation

@kalverra

@kalverra kalverra commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Intent

Postgres reports 40P01 deadlock detected on llo_mercury_transmit_queue when concurrent Delete (locking by transmission_hash) and Prune (locking by seq_nr) lock overlapping rows in opposite orders. Prune now selects candidates with FOR UPDATE SKIP LOCKED in both its batch loop and final truncate, leaving rows locked by Delete for the next prune cycle.

Changes

Prune skips rows locked by Delete instead of blocking on them:

flowchart LR
  D["Delete (by transmission_hash)"] -->|locks row, must block| Q[(llo_mercury_transmit_queue)]
  P["Prune (by seq_nr / OFFSET)"] --> B["Batch & tail candidate select"]
  B --> SL["FOR UPDATE SKIP LOCKED (new)"]:::new
  SL -->|skips locked rows| Q
  classDef new fill:#d4f8d4,stroke:#2da44e
Loading
Prune batch loop skips locked rows
Final truncate locks only excess rows past the OFFSET
  • Window function replaced by an unlocked OFFSET subquery wrapped in FOR UPDATE SKIP LOCKED. FOR UPDATE in the same SELECT as OFFSET would lock all maxSize retained 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

Notes

  • Delete intentionally does not use SKIP LOCKED: its caller already removed those transmissions from the in-memory queue, so skipping would silently lose rows.
  • The reaper in chainlink/core/services/llo/cleanup.go (reapStale, reapOrphaned) has the same access pattern and is fixed in a separate PR. Chainlink needs a go.mod bump to this commit.

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.
@kalverra
kalverra force-pushed the fix/llo-transmit-queue-deadlock branch from b3a0327 to 9cc4f07 Compare October 30, 2026 16:52
@kalverra
kalverra requested a balanced review from Copilot October 30, 2026 16:52
@kalverra kalverra changed the title fix(llo): avoid transmit queue deadlock in Prune [DX-5066] fix(llo): avoid transmit queue deadlock in Prune Oct 30, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 LOCKED to pruning queries and replaces the final window-function query with OFFSET.
  • 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

Comment thread llo/transmitter/dataengine/orm.go Resolved
@kalverra
kalverra enabled auto-merge (squash) October 30, 2026 17:11
@kalverra kalverra added complexity:medium pr-triage: medium complexity risk:medium pr-triage: medium risk labels Oct 30, 2026
@kalverra

Copy link
Copy Markdown
Contributor Author

PR Triage · Risk 🟡 Medium · Complexity 🟡 Medium

Suggests 1 approval, including one from an owner. Suggested reviewers: @brunotm, @pavel-raykov.

Level Why
Risk Medium changes touch 2 subsystems (threshold 2)
Complexity Medium new code size 1,706 (Medium at 1,600)
Visualizations

Score map

flowchart 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;
Loading

Change shape

pie title Change shape
    "Tests" : 1081
    "Source" : 225
    "Trivial" : 5
Loading

— concentrated in llo/transmitter/dataengine/orm_test.go

Files: 4 changed, 1 skipped
File Risk Complexity Why
llo/transmitter/dataengine/orm.go Low Low
llo/transmitter/dataengine/orm_test.go Low Low
mercury/testutils/sql.go Low Low
1 skipped (documentation) .changeset/llo-transmit-queue-deadlock.md
Why these reviewers
Reviewer Share Last commit
@brunotm 87% 2026-08-07
@pavel-raykov 13% 2026-05-04

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
Metric PR value Scale Medium at High at
logic added (cognitive delta) 14 [██████│░░░░░░░░░░░░│] 15 45
syntax change (AST delta) 1,306 [████████████████░░░│] 1,600 —
new code size (Kolmogorov delta) 1,706 [██████████████████│█] 1,600 —
most complex function (max function cognitive complexity) 11 [███░░░░░░░░░░░░░░░░│] 70 70

Risk signals: change spread ○ · subsystems ● · hotspot ○ · blast radius ○ · author familiarity ○ · floor Medium

pr-triage v0.0.0-20261030164835-20c4213aefbb · policy fc234bb


What is this? · How is this calculated?

Comment on lines +253 to +266
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;

@brunotm brunotm Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Suggested change
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;

@kalverra
kalverra merged commit 6f82092 into master Nov 3, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

complexity:medium pr-triage: medium complexity risk:medium pr-triage: medium risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants