Pgque Postgres Queue
Expert skill for PgQue – a zero-bloat, snapshot-based Postgres queue using pure PL/pgSQL with no C extensions required.
- Skill ID
- reason-machines/trending-skills/pgque-postgres-queue
- Publisher
- reason-machines
- Repository
- trending-skills
- Installs
- 0
- Files
- 1
- Synced
- Sep 23, 2026
Open any RiverX project, open the Skills panel in the chat, and search for this identifier. The files are fetched from the source repository at install time.
reason-machines/trending-skills/pgque-postgres-queueInstalls these files- SKILL.md
What this skill tells the agent
PgQue – Zero-Bloat Postgres Queue
Skill by ara.so — Daily 2026 Skills collection.
PgQue is a pure PL/pgSQL reimplementation of the battle-tested PgQ (Skype/Postgres) architecture. It uses snapshot-based batching and TRUNCATE-based table rotation instead of row-level locking, delivering zero dead-tuple bloat, predictable performance under sustained load, and native fan-out — all from a single SQL file on any Postgres 14+ instance including managed providers (RDS, Aurora, Cloud SQL, Supabase, Neon).
Key Concepts
- Tick: A periodic snapshot that closes a batch of events. Nothing is delivered until a tick fires.
- Batch: A group of events captured in one tick, consumed atomically by a subscriber.
- Subscriber/Consumer: A named cursor on the event log. Multiple consumers get independent copies of every batch (fan-out).
- Zero bloat: Events are stored in rotating tables and cleared via
TRUNCATE, neverDELETE. No dead tuples. - Latency trade-off: End-to-end delivery is ~1–2 s (one tick interval + poll). Per-call function latency is microseconds.
Installation
Requirements
- Postgres 14+
pg_cron(recommended) or an external scheduler callingpgque.ticker()every second
Install from SQL file
# Clone the repo
git clone https://github.com/NikolayS/pgque.git
cd pgque
# Install in a single transaction
PAGER=cat psql --no-psqlrc --single-transaction -d mydb -f sql/pgque.sqlOr inside a psql session:
begin;
\i sql/pgque.sql
commit;Start the ticker (pg_cron)
-- Creates pg_cron jobs for ticker (every 1s) and maintenance (every 30s)
select pgque.start();Start the ticker (without pg_cron)
Run these externally on a schedule:
# Every 1 second
psql -d mydb -c "select pgque.ticker()"
# Every 30 seconds
psql -d mydb -c "select pgque.maint()"Warning: Without a running ticker, consumers see nothing. Enqueue works, but no batches are created.
Uninstall
\i sql/pgque_uninstall.sqlRoles & Grants
| Role | Use |
|---|---|
pgque_reader | Dashboards, metrics, read-only |
pgque_writer | Producers and consumers (most apps) |
pgque_admin | Operators, migrations |
-- Grant producer/consumer access to app user
CREATE USER app_worker WITH PASSWORD '...';
GRANT pgque_writer TO app_worker;
-- Grant read-only metrics access
CREATE USER metrics_reader WITH PASSWORD '...';
GRANT pgque_reader TO metrics_reader;Core API (Modern Style)
Create a Queue
SELECT pgque.create_queue('orders');Subscribe a Consumer
-- Register a named consumer on the queue
SELECT pgque.subscribe('orders', 'order-processor');Send Events (Enqueue)
-- Send a single event (type, data)
SELECT pgque.send('orders', 'new_order', '{"order_id": 42, "amount": 99.99}');
-- Send a batch of events
SELECT pgque.send_batch('orders', ARRAY[
ROW('new_order', '{"order_id": 43}')::pgque.event_data,
ROW('new_order', '{"order_id": 44}')::pgque.event_data
]);Receive and Acknowledge Events
-- Receive next batch for a consumer (returns batch_id + events)
SELECT * FROM pgque.receive('orders', 'order-processor');
-- Acknowledge successful processing (batch_id from receive)
SELECT pgque.ack('orders', 'order-processor', :batch_id);
-- Negative-acknowledge (retry / dead-letter)
SELECT pgque.nack('orders', 'order-processor', :batch_id);Unsubscribe
SELECT pgque.unsubscribe('orders', 'order-processor');Low-Level PgQ API
These map directly to the original PgQ primitives and are also available via pgque_writer:
-- Enqueue a single event
SELECT pgque.insert_event('orders', 'new_order', '{"order_id": 42}');
-- Register a consumer
SELECT pgque.register_consumer('orders', 'order-processor');
-- Get the next available batch ID
SELECT pgque.next_batch('orders', 'order-processor');
-- Returns: batch_id (bigint), or NULL if nothing ready
-- Fetch all events in a batch
SELECT * FROM pgque.get_batch_events(:batch_id);
-- Returns: ev_id, ev_time, ev_txid, ev_retry, ev_type, ev_data, ev_extra1..4
-- Mark batch as successfully processed
SELECT pgque.finish_batch(:batch_id);
-- Schedule an event for retry (with delay in seconds)
SELECT pgque.event_retry(:batch_id, :ev_id, 60); -- retry in 60s
-- Unregister consumer
SELECT pgque.unregister_consumer('orders', 'order-processor');Complete Working Example
Producer (Python with psycopg2)
import psycopg2
import json
import os
conn = psycopg2.connect(os.environ["DATABASE_URL"])
conn.autocommit = False
def enqueue_order(order_id: int, amount: float):
with conn.cursor() as cur:
cur.execute(
"SELECT pgque.send(%s, %s, %s)",
("orders", "new_order", json.dumps({"order_id": order_id, "amount": amount}))
)
conn.commit()
enqueue_order(42, 99.99)Consumer (Python with psycopg2)
import psycopg2
import psycopg2.extras
import json
import os
import time
conn = psycopg2.connect(os.environ["DATABASE_URL"])
conn.autocommit = False
QUEUE = "orders"
CONSUMER = "order-processor"
def setup():
with conn.cursor() as cur:
cur.execute("SELECT pgque.subscribe(%s, %s)", (QUEUE, CONSUMER))
conn.commit()
def process_batch():
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM pgque.receive(%s, %s)", (QUEUE, CONSUMER))
rows = cur.fetchall()
if not rows:
conn.rollback()
return False
batch_id = rows[0]["batch_id"]
for row in rows:
event = json.loads(row["ev_data"])
print(f"Processing order {event['order_id']}")
# ... your processing logic ...
with conn.cursor() as cur:
cur.execute("SELECT pgque.ack(%s, %s, %s)", (QUEUE, CONSUMER, batch_id))
conn.commit()
return True
setup()
while True:
if not process_batch():
time.sleep(1) # wait for next tickFan-Out Example (Multiple Independent Consumers)
-- One queue, multiple consumers each get ALL events independently
SELECT pgque.create_queue('user-events');
SELECT pgque.subscribe('user-events', 'analytics-service');
SELECT pgque.subscribe('user-events', 'notification-service');
SELECT pgque.subscribe('user-events', 'audit-log');
-- Producer sends once
SELECT pgque.send('user-events', 'user_signup', '{"user_id": 1}');
-- Each consumer independently receives the same event
SELECT * FROM pgque.receive('user-events', 'analytics-service');
SELECT * FROM pgque.receive('user-events', 'notification-service');
SELECT * FROM pgque.receive('user-events', 'audit-log');Retry / Dead Letter Pattern
-- Using low-level API with retry logic
DO $$
DECLARE
v_batch_id bigint;
v_ev record;
BEGIN
-- Get next batch
SELECT pgque.next_batch('orders', 'order-processor') INTO v_batch_id;
IF v_batch_id IS NULL THEN
RAISE NOTICE 'No batch available';
RETURN;
END IF;
-- Process each event
FOR v_ev IN SELECT * FROM pgque.get_batch_events(v_batch_id) LOOP
BEGIN
-- Attempt processing
RAISE NOTICE 'Processing event % type %', v_ev.ev_id, v_ev.ev_type;
-- On transient failure, retry after 30 seconds
-- SELECT pgque.event_retry(v_batch_id, v_ev.ev_id, 30);
EXCEPTION WHEN OTHERS THEN
-- Schedule retry
PERFORM pgque.event_retry(v_batch_id, v_ev.ev_id, 60);
RAISE NOTICE 'Event % queued for retry', v_ev.ev_id;
END;
END LOOP;
-- Finish batch (events not retried are acked)
PERFORM pgque.finish_batch(v_batch_id);
END;
$$;Monitoring & Introspection
-- Queue info (depth, consumer count, last tick)
SELECT * FROM pgque.get_queue_info();
SELECT * FROM pgque.get_queue_info('orders');
-- Consumer lag and position
SELECT * FROM pgque.get_consumer_info();
SELECT * FROM pgque.get_consumer_info('orders');More from reason-machines
Use this skill in a project
Open any RiverX project, search the Skills panel for reason-machines/trending-skills/pgque-postgres-queue, and install it in one click.
