Skip to content

PgmqTransport: every consumer start recreates the notify trigger (table lock, blocked producers, deadlocks) #3

Open

Description

@iGrog

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 commit

Proposed fix

  • createQueue() and startConsumer() both call thesis/pgmq's ensureNotifyInsert() (Do not recreate the notify trigger on consumer start; ensure notifications under the queue lock pgmq#22). In one transaction under pgmq.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

  1. iGrog commented on Oct 7, 2026

    @iGrog
    ContributorAuthor

    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_throttle is 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() in thesis/pgmq (thesis-php/pgmq#22): checked under the queue lock, changing only what is missing. #7 calls it from createQueue() and startConsumer(). The description has a table measured on 0.5.x, on the first version and on the current one, including deadlocks on 0.5.x during rolling restarts under load.

    #7 is a draft until a thesis/pgmq release contains #22.

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