database-queues
Skill0 starsChanged 3 months agoMITView on GitHub
This skill should be used when the user asks "how do I build a job queue in Postgres", "what is SELECT FOR UPDATE SKIP LOCKED", "why are jobs processed twice", "how do I publish an event and commit a row atomically", "what is the outbox pattern", or reports workers blocking each other polling a jobs table.
In this repository29 other files it gives its agents.
- algorithmic-complexity-attacksSkill
- cache-stampedeSkill
- clocks-and-timeSkill
- code-hotspotsSkill
- connascenceSkill
- connection-poolingSkill
- consistent-hashingSkill
- coordinated-omissionSkill
- cyclomatic-complexitySkill
- dependency-confusionSkill
- expand-contractSkill
- floating-pointSkill
- hyrums-lawSkill
- index-designSkill
- isolation-anomaliesSkill
- keyset-paginationSkill
- metamorphic-testingSkill
- module-depthSkill
- mutation-testingSkill
- mvcc-and-bloatSkill
- parse-dont-validateSkill
- query-plansSkill
- queueing-and-utilizationSkill
- retry-amplificationSkill
- start-hereSkill
- tail-latencySkill
- unicode-pitfallsSkill
- universal-scalability-lawSkill
- write-amplificationSkill
The file
We split this skill into the rules it gives an agent
5 rules. About 0 are common, 4 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 60
Delete completed jobs promptly, moving them to an archive table or dropping a partition.
- Distinctive: line 66
Add a reaper: when committing the claim before processing:
- Distinctive: line 97
Write the event to the same database in the same transaction, and relay it separately.
- Distinctive: line 131
Guaranteed: if the order exists, the event is eventually published.
- Distinctive: line 149
Delete or partition published outbox rows on a schedule, since the table otherwise grows forever.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Queues in the database
Part 1: SKIP LOCKED job queues
A naive jobs table fails with a second worker: workers lock the same oldest row and serialize, or all claim the same job.
FOR UPDATE SKIP LOCKED (Postgres 9.5+, MySQL 8+) fixes it by skipping rows already locked by another transaction rather than waiting.
BEGIN;
WITH claimed AS (
SELECT id FROM jobs
WHERE status = 'pending' AND run_at <= now()
ORDER BY priority DESC, run_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs SET status = 'running', started_at = now(), attempts = attempts + 1
FROM claimed WHERE jobs.id = claimed.id
RETURNING jobs.*;
-- process the jobs
COMMIT;Properties:
- Exactly one worker claims each job, guaranteed by row locks.
- Workers never block each other, which is what
SKIP LOCKEDbuys. - A crashed worker's transaction rolls back and its jobs return to
pendingautomatically. Committing the claim before processing instead requires a reaper for stuck rows. - The job and the business data commit together, which is the main reason to keep the queue in the database.
The index
CREATE INDEX ON jobs (priority DESC, run_at) WHERE status = 'pending';Partial, because in a healthy queue 99.9% of rows are done and a full index would be dead weight. See index-design.
Bloat is the real operational cost
A queue table is the highest-churn table in the system: every job inserted, updated two or three times, then deleted. Under default autovacuum settings it bloats and claim latency creeps up over weeks.
ALTER TABLE jobs SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_cost_delay = 0,
fillfactor = 70 -- room for HOT updates
);Delete completed jobs promptly, moving them to an archive table or dropping a partition. A jobs table retaining history is a bloat generator. See mvcc-and-bloat.
Operational details
- Do not poll tightly. Poll about once a second with jitter, or use
LISTEN/NOTIFYto wake workers with polling as fallback.LISTENneeds a dedicated connection and does not work through a transaction pooler, seeconnection-pooling. - Batch the claim to amortize round trips, but only when a worker can process the whole batch within its visibility window.
- Add a reaper when committing the claim before processing:
UPDATE jobs SET status = 'pending' WHERE status = 'running' AND started_at < now() - interval '10 minutes'; - Cap attempts and use a dead-letter status. A job failing forever at full speed is a self-inflicted denial of service. Back off exponentially with jitter, see
retry-amplification. - Make handlers idempotent. At-least-once is the guarantee, since a worker can die after doing the work and before committing.
When to use a broker instead
A database queue is the right default to roughly thousands of jobs per second: one less system, and transactional consistency with the data, which SQS and Kafka cannot provide. Move to a broker for fan-out to many independent consumers, very high throughput, long retention and replay, or cross-datacenter delivery.
Existing implementations: pgmq, River (Go), Oban (Elixir), Solid Queue (Rails), graphile-worker (Node), Procrastinate (Python).
Part 2: the dual-write problem
db.commit() # 1. save the order
queue.publish(OrderCreated(...)) # 2. tell everyoneThese cannot be made atomic. Three real failure modes:
- Crash between 1 and 2: the order exists and nobody hears about it, causing silent divergence.
- Publish succeeds, transaction rolls back: consumers act on an order that does not exist.
- Publish succeeds, commit fails on retry: duplicate events.
Reversing the order does not help. Two-phase commit introduces its own blocking failure mode and most brokers do not support it.
Transactional outbox
Write the event to the same database in the same transaction, and relay it separately.
CREATE TABLE outbox (
id bigserial PRIMARY KEY,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
CREATE INDEX ON outbox (id) WHERE published_at IS NULL;with db.transaction():
db.insert(order)
db.insert_outbox(event_type="OrderCreated", payload=..., aggregate_id=order.id)
# both rows or neitherThe relay uses the same SKIP LOCKED pattern:
BEGIN;
SELECT * FROM outbox WHERE published_at IS NULL
ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100;
-- publish to the broker
UPDATE outbox SET published_at = now() WHERE id = ANY($1);
COMMIT;What this guarantees and does not:
- Guaranteed: if the order exists, the event is eventually published. Atomicity restored.
- Not guaranteed: exactly-once delivery. A relay crashing after publishing but before the
UPDATEpublishes twice. Consumers must be idempotent, deduplicating on event id. At-least-once plus idempotent consumers is the achievable target, exactly-once delivery is not. - Ordering is per-aggregate at best. Publish in
idorder and partition the broker byaggregate_idwhen consumers need per-entity ordering.
CDC as the relay
Debezium reads the Postgres WAL and publishes changes, removing polling load. Costs: another system, and an inactive replication slot pins WAL and fills the disk, see mvcc-and-bloat. Polling the outbox is simpler and adequate for most systems.
The inbox
The mirror image on the consumer side, which is what makes idempotency concrete:
INSERT INTO processed_events (event_id) VALUES ($1); -- unique violation means already handledSame transaction as the side effect, so doing the work and recording it cannot diverge. Prune rows on a window longer than the broker's maximum redelivery period.
Delete or partition published outbox rows on a schedule, since the table otherwise grows forever.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

