Skip to content

Consumer::consume() recreates the notify trigger on every start: table lock, blocked producers, deadlocks #20

Open

Description

@iGrog

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.php L51: $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 and CREATE 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 commits

Lost 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:

  1. 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;
  2. read the state from the catalog: the trigger exists, is enabled (tgenabled O/A) and calls pgmq.notify_queue_listeners(); the throttle row is present;
  3. 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

  1. iGrog commented on Oct 7, 2026

    @iGrog
    Author

    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 -9 and crash recovery. It showed that the first proposal in #22 was not enough:

    1. Concurrent cold starts still raced. "Check, then enable" is not atomic. Consumers starting together on a queue without notifications all saw "missing" and all called enable_notify_insert(), and the losers failed with trigger … 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.
    2. notify_insert_throttle is 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.
    3. A trigger that exists by name was trusted. A disabled trigger or one calling another function counted as enabled.
    4. A different throttle interval recreated the trigger, although 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 counts CREATE TRIGGER).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions