NAME

Punk::Queue::Backend::Pg - the PostgreSQL backend

SYNOPSIS

my $q = Punk::Queue->new(dsn => 'dbi:Pg:dbname=myapp');

DESCRIPTION

Selected automatically for a dbi:Pg: dsn. Requires PostgreSQL 9.5 for FOR UPDATE SKIP LOCKED, which the claim depends on entirely.

This is the backend for a queue shared across machines. For a single host, Punk::Queue::Backend::SQLite needs no server.

How a job is claimed

One statement, on a connection with AutoCommit on, so the claim is its own transaction:

UPDATE pq_jobs SET state = 'active', started = ?, worker = ?
 WHERE id = (SELECT id FROM pq_jobs
              WHERE state = 'inactive' AND queue IN (...)
                AND delayed <= ? AND parents_left = 0
                AND (expires IS NULL OR expires > ?)
              ORDER BY priority DESC, id
              LIMIT 1 FOR UPDATE SKIP LOCKED)
   AND state = 'inactive'
RETURNING ...

SKIP LOCKED lets contending workers step over each other's in-flight candidate rows rather than queueing behind them. That is what makes the claim scale past one worker; without it every worker in the fleet serialises on the same highest-priority row.

The redundant state = 'inactive' on the outer update closes the re-evaluation race between the subquery and the write. Minion omits it. A silently duplicated job is the worst failure a queue has, and the guard turns that possibility into a harmless miss for no measurable cost.

RETURNING means the claim reads back its own row without a second round trip.

Queue and task lists

The IN lists are expanded to placeholders in C rather than passed as an array with = ANY(?). Two reasons: the array literal needs PostgreSQL quoting that has to be right for names containing commas or braces, and it does not port to SQLite. A worker's queue and task sets are fixed at boot, so the statement text is stable for the life of the process and prepare_cached sees exactly one statement.

Times

Columns are DOUBLE PRECISION epoch seconds, not TIMESTAMPTZ, so binds and comparisons are identical to SQLite's and no driver timestamp parsing happens anywhere. The cost is readability at a psql prompt, and the apology for that is the pq_jobs_human view, which the schema creates:

SELECT * FROM pq_jobs_human WHERE state = 'failed';

JSON columns

args, notes and result are TEXT, not JSONB. JSONB would normalise key order, whitespace and numbers differently from SQLite's TEXT, and a job payload that round-trips differently depending on the backend would defeat the conformance suite. The queue never queries inside a payload, so JSONB's indexing would buy nothing.

METHODS

Everything in Punk::Queue::Backend, plus:

dequeue

my $row = $backend->dequeue($worker_id, \@queues, \@tasks);

notify

$backend->notify($queue, $id);

Emit a wakeup on the queue's channel, through pg_notify rather than a NOTIFY statement - it takes the channel as a text parameter, so no identifier quoting is involved at all. The listening side arrives in phase 5. On SQLite the same method is a no-op, so callers never have to ask which backend they are on.

begin_immediate / commit / rollback

Transaction control. Named for symmetry with the SQLite backend; here begin_immediate starts a transaction and takes a transaction-scoped advisory lock, which is what serialises concurrent migrations. Being transaction-scoped, it is released even if the process dies mid-migration.

SEE ALSO

Punk::Queue, Punk::Queue::Backend, Punk::Queue::Backend::SQLite.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)