Repository navigation
PgmqTransport: every consumer start recreates the notify trigger (table lock, blocked producers, deadlocks) #3
Description
Updated after further testing — see the comment below. The first proposal (set notifications up only in
createQueue()) loses them for good after crash recovery; the current proposal ensures them under the queue lock.
Summary
PgmqTransport::startConsumer() calls $queue->enableNotifyInsert(), and pgmq.enable_notify_insert() drops and recreates the trigger on pgmq.q_<queue>. That DDL takes a table-level lock:
- a (re)starting worker waits for every open transaction that has inserted into the queue, and in the meantime blocks new inserts into it;
- workers of the same queue starting together race into the same DDL and can deadlock.
Workers restart often: deploys, and recycling after N messages or a memory limit, which happens every few seconds under load. Each restart repeats the DDL.
Where
src/MessageBus/Pgmq/PgmqTransport.php L192: $this->postgres->listen($queue->enableNotifyInsert()),
Observed
Two workers of one queue were recycled in the same tick after reaching their message budget:
Consumer 'queue_a#1' exited (code=0) after 253s
Consumer 'queue_a#2' exited (code=0) after 253s
Starting consumer: queue_a#1
Starting consumer: queue_a#2
Consumer 'queue_a#1' crashed: Amp\Postgres\PostgresQueryError: ERROR: deadlock detected
What a fix must keep: notifications lost after crash recovery
The insert trigger notifies only when it finds the queue's row in pgmq.notify_insert_throttle, and that table is UNLOGGED. Crash recovery, or a promoted replica, leaves it empty while the trigger survives, and notifications stop silently. The current code heals this by accident: every worker start recreates everything.
We saw it ourselves: after a crash restart of Postgres, all queues of an application quietly switched to 5-second polling.
Reproduction
$transport = new PgmqTransport($postgres);
$transport->createQueue('q');
$producer = $postgres->beginTransaction(); // a producer inside its transaction
$transport->dispatchInTransaction($producer, [$envelopeToQ]);
async(static fn() => $transport->startConsumer('q', $handler))
->await(new TimeoutCancellation(1)); // CancelledException: waits for the producer's commitProposed fix
createQueue()andstartConsumer()both callthesis/pgmq'sensureNotifyInsert()(Do not recreate the notify trigger on consumer start; ensure notifications under the queue lock pgmq#22). In one transaction underpgmq.acquire_queue_lock()it reads the catalog and changes something only when needed:- nothing, when the trigger is valid and the throttle row is in place (the usual restart: no DDL, no lock on the queue table);
update_notify_insert()for another interval;enable_notify_insert()when anything is missing, disabled or foreign. That covers crash recovery and concurrent cold starts, which pass one by one.
- The pgmq-specific knowledge (trigger, throttle table, locks) stays in
thesis/pgmq; the transport only calls it.
This needs a thesis/pgmq release with thesis-php/pgmq#22. PR: #7 (draft until then). Integration tests: #8.
Verified with a stress harness (real Postgres, pgmq 1.11.1 and 1.13.0, real crashes via kill -9)
| scenario | 0.5.x |
first version of #7 | #7 now |
|---|---|---|---|
| restart while a producer keeps an insert open | ❌ start blocked 1.02 s, 1 DDL | ✅ | ✅ not blocked, no DDL |
| 8 workers start at once on a queue without notifications | ❌ 4 DDL; workers fail with trigger … already exists, deadlock detected |
❌ notifications never set up | ✅ one DDL, no failures |
| 13 rolling restarts under load (4 workers, 1500 messages) | ❌ 11 DDL, 5 deadlocks | ✅ no DDL | ✅ no DDL, no loss, no duplicates |
| throttle row lost / trigger disabled | ✅ recreated on every start | ❌ not restored | ✅ restored |
| real crash under load, 3 queues × 2 workers | ✅ restored, 5 DDL per queue | ❌ notifications lost on every queue | ✅ restored, one DDL per queue, no loss, no deadlocks |
Activity
iGrog commentedon Oct 7, 2026
Update after further testing. The first proposal here (and the first version of #7) was "set notifications up in
createQueue(), only listen on start". A stress harness with real crashes showed that this loses notifications for good after crash recovery.pgmq.notify_insert_throttleis UNLOGGED, so recovery empties it while the trigger survives. The current code heals this by recreating everything on every start, and my first proposal removed that.The proposal is now
ensureNotifyInsert()inthesis/pgmq(thesis-php/pgmq#22): checked under the queue lock, changing only what is missing. #7 calls it fromcreateQueue()andstartConsumer(). The description has a table measured on0.5.x, on the first version and on the current one, including deadlocks on0.5.xduring rolling restarts under load.#7 is a draft until a
thesis/pgmqrelease contains #22.