What pgtask is¶
pgtask is a durable task and workflow engine that keeps all of its state in PostgreSQL.
You get a queue, a scheduler, and a durable workflow runtime. You do not get a server to operate. Producers, workers, and tools all connect straight to the database.
What you can build with it¶
The engine covers the work that usually pulls in a broker, a cron container, and a workflow service:
| You want to | You use |
|---|---|
| Run work outside a request | A task with a handler |
| Run work later | run_at, or a schedule |
| Retry failures with backoff | A retry policy on the handler version |
| Run something once per key | An idempotency key |
| Survive a restart mid-workflow | Checkpoints, durable sleeps, signals, child tasks |
| Keep workloads from starving each other | Separate queues |
The one guarantee that matters¶
pgtask delivers at least once.
A worker can finish an external side effect and then lose its lease before it records the result. PostgreSQL makes the task available again, and your handler runs a second time. This is not a defect you can configure away. Any system that survives a process dying at an arbitrary instant behaves this way.
So the rule is: your handler must be safe to run again. In practice that means pushing a stable key to whatever you call:
@tasks.task("billing.charge")
async def charge(task: Task, request: ChargeRequest) -> ChargeResult:
# The provider deduplicates on this key, so a second attempt does not double-charge.
receipt = await provider.charge(
amount=request["amount"],
idempotency_key=f"charge:{request['invoice_id']}",
)
return {"receipt": receipt.id}
Idempotency keys work at two levels
An idempotency key on enqueue deduplicates tasks. An idempotency key you pass to an external API deduplicates
side effects. They solve different problems, and you usually want both. See
Idempotency.
What it does not do¶
Being honest about the edges is more useful than a feature list.
- It does not give you exactly-once side effects. Nothing that talks to an external system can.
- It does not scale past your database. Queue traffic, retention, and your application queries share one PostgreSQL instance. That is the trade you make in exchange for transactional enqueue.
- It does not work through a transaction-pooling proxy alone. Workers need one session-capable connection for
LISTEN. See Scaling and deployment. - It has no global rate limiter. Concurrency is per worker and per queue.
Why PostgreSQL only¶
A separate broker gives you throughput and takes away consistency.
The moment your queue lives outside your database, enqueueing becomes a second write that can succeed when your transaction fails, or fail when it succeeds. You then spend real effort on outbox tables and reconciliation jobs to get back what a single transaction gave you for free.
pgtask starts from the other end. Your task row is application data. It commits with your other writes or not at all.
The cost is that PostgreSQL must carry the load. For the workloads this engine targets - thousands of tasks per second, not millions - that is a trade worth making, and one you can measure before you commit to it.