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)