Repository navigation
Consumer::consume() recreates the notify trigger on every start: table lock, blocked producers, deadlocks #20
Description
Updated after further testing — see the comment below. The first version of this issue (and of #22) missed two cases: concurrent starts on a queue without notifications still race, and notifications are silently lost after crash recovery.
Summary
With listenForInserts: true (the default), every Consumer::consume() call runs enableNotifyInsert(). pgmq.enable_notify_insert() drops and recreates the trigger on the queue table (DROP TRIGGER …; CREATE CONSTRAINT TRIGGER …). This is DDL that takes a table-level lock on pgmq.q_<queue>:
- a (re)starting consumer waits for every open transaction that has inserted into the queue to finish;
- while it waits, new inserts into that queue queue up behind it;
- consumers of the same queue starting at the same time race into the same DDL. They wait for each other or deadlock, and the loser fails with
trigger "trigger_notify_queue_insert_listeners" … already exists.
Consumers restart routinely (deploys, worker recycling after N messages or a memory limit), so this is not limited to the first start.
A second problem: the throttle row is UNLOGGED
The insert trigger notifies only when it finds the queue's row in pgmq.notify_insert_throttle, and that table is UNLOGGED. After crash recovery, and on a promoted replica, the table is empty while the trigger survives. From then on the trigger silently never notifies, and consumers fall back to polling.
Today this heals by accident, because every consumer start recreates everything. Any fix that stops recreating the trigger on start has to keep this case working.
Where
src/Consumer.phpL51:$channelName = $queue->enableNotifyInsert();pgmq.enable_notify_insert()(pgmq extension):pgmq.disable_notify_insert()(DROP TRIGGER IF EXISTS …, delete the throttle row), then insert the row andCREATE CONSTRAINT TRIGGER …, every time.
Reproduction
$queue = createQueue($pg, 'q');
$queue->enableNotifyInsert(); // already set up
$producer = $pg->beginTransaction(); // a producer inside its transaction
send($producer, 'q', new SendMessage('{}'));
async(static fn() => createConsumer($pg)->consume(static function (): void {}, new ConsumeConfig('q')))
->await(new TimeoutCancellation(1)); // CancelledException: blocked until the producer commitsLost notifications: DELETE FROM pgmq.notify_insert_throttle WHERE queue_name = 'q' (what crash recovery does to the whole table), then insert into the queue: no notification arrives until the throttle row is recreated.
Proposed fix (#22)
ensureNotifyInsert(queue, ?throttleInterval) makes notifications work while touching the trigger only when needed. One transaction:
pgmq.acquire_queue_lock(queue): the same lock pgmq takes to create and drop the queue. Consumers starting together pass one by one instead of racing into the DDL;- read the state from the catalog: the trigger exists, is enabled (
tgenabledO/A) and callspgmq.notify_queue_listeners(); the throttle row is present; - decide:
- all in place with the same interval → nothing (catalog reads only, no lock on the queue table);
- in place with another interval →
pgmq.update_notify_insert()(no DDL); - otherwise (missing, disabled, foreign trigger, or the throttle row is gone) →
pgmq.enable_notify_insert().
Consumer::consume() uses it. isNotifyInsertEnabled() exposes the same check. enableNotifyInsert() keeps its "(re)create" semantics.
How it was verified
A stress harness on real Postgres, against pgmq 1.11.1 (the image from this repo's compose) and 1.13.0. Crashes are real: kill -9 of a backend makes the postmaster run crash recovery. Workers and producers are separate processes.
| scenario | 0.1.x + the first version of #22 |
#22 now |
|---|---|---|
| 8 consumers start at once on a queue without notifications, also with open producer transactions | consumers fail: trigger … already exists |
trigger created once, no failures |
| consumer restarts while a producer keeps an insert open | ✅ not blocked | ✅ not blocked |
| 13 rolling restarts under load, 1500 messages | ✅ | ✅ no loss, no duplicates, no DDL |
| throttle row lost / trigger dropped | ✅ | ✅ restored |
| trigger disabled / trigger calls another function | ❌ not restored | ✅ restored |
| different throttle interval | trigger recreated | ✅ update_notify_insert, no DDL |
| queue setup running concurrently with consumer starts and producers | consumers fail: already exists |
✅ |
| real crash under load, 3 queues × 2 workers restarting at once | not measured | ✅ notifications back on every queue, one DDL per queue, no messages lost, no deadlocks |
| two crashes in a row | not measured | ✅ |
Activity
iGrog commentedon Oct 7, 2026
Update after further testing. I built a stress harness for this: real Postgres with pgmq 1.11.1 and 1.13.0, workers and producers as separate processes, real crashes via
kill -9and crash recovery. It showed that the first proposal in #22 was not enough:enable_notify_insert(), and the losers failed withtrigger … already exists. This is not a rare first start: it is exactly what happens after a crash, when every worker reconnects at the same moment.notify_insert_throttleis UNLOGGED. Crash recovery empties it while the trigger survives, and notifications stop silently. The old behaviour healed this by recreating everything on every start, so the fix must keep this case working.update_notify_insert()changes it without DDL.The description now covers all of this. #22 is reworked: one transaction under
pgmq.acquire_queue_lock()(the lock pgmq takes to create and drop queues), a catalog check of trigger validity and of the throttle row, then nothing /update_notify_insert()/enable_notify_insert(). It has deterministic tests for each case, including the concurrent start (event trigger countsCREATE TRIGGER).