1- # dtq: Distributed Task Queue Engine
2-
3- dtq is a Redis Streams-backed distributed task queue in Python. Producers
4- submit JSON tasks over HTTP; workers claim them from priority-ordered
5- streams, execute registered handlers with timeouts and retries, and move
6- exhausted tasks to a dead-letter stream. It ships with an HTTP API, a CLI, a
7- real-time dashboard, and Prometheus metrics, and it runs locally with one
8- ` docker compose up ` .
9-
10- ## Key features
11-
12- - ** Strict priority queues** : ten priority bands per queue (p9 down to p0),
13- each a Redis Stream with a shared ` workers ` consumer group.
14- - ** At-least-once execution** : read, validate, execute, record, then ACK.
15- Crash redelivery via ` XAUTOCLAIM ` ; duplicate entries are absorbed, never
16- executed as new work.
17- - ** Retries with backoff** : exponential backoff with jitter, capped delay,
18- per-attempt error classification. Only retryable errors retry; everything
19- else fails fast to the DLQ.
20- - ** Idempotent ingest** : ` Idempotency-Key ` header scoped per queue; replays
21- return the stored task, in-flight duplicates get 409.
22- - ** Dead-letter stream** : bounded, inspectable, with requeue (attempt reset,
23- history preserved), discard, and purge, all audit-logged.
24- - ** Backpressure** : queue-depth admission gate (429 with ` Retry-After ` ),
25- payload size cap (413), bounded worker prefetch, bounded WS buffers.
26- - ** Observability** : Prometheus metrics, ` /health ` vs ` /ready ` , structured
27- JSON logs, a WebSocket event stream, and an audit stream for admin actions.
28- - ** Delayed tasks** : ` delay_seconds ` parks tasks in the retry schedule until
29- they are due.
30- - ** Task cancellation** : cooperative cancel for queued and running tasks.
31- - ** Single-binary ops story** : ` docker compose up ` gives you Redis, API,
32- worker, dashboard, and Prometheus.
33-
34- ## Architecture
1+ <div align =" center " >
352
36- ```
37- +------------------+
38- | dashboard | React SPA + WebSocket client (:3000)
39- +--------+---------+
40- | REST + WS /ws/events
41- +--------v---------+
42- | api | FastAPI (:8000)
43- | ingest, reads, | /api/v1, /health, /ready, /metrics
44- | admin, WS fanout |
45- +--------+---------+
46- |
47- +--------------v---------------+
48- | redis | streams, hashes, schedules,
49- | dtq:stream:<queue>:p<0-9> | DLQ, idempotency keys, audit,
50- | dtq:retry:schedule | worker heartbeats, pub/sub
51- | dtq:stream:dead-letter |
52- +--------------^---------------+
53- |
54- +--------------+---------------+
55- | worker (x N) | poll / reaper / scheduler /
56- | dtq worker run | heartbeat loops, executors
57- +------------------------------+
58- ```
3+ # dtq
4+
5+ ** A distributed task queue on Redis Streams, with the operational parts actually built.**
596
60- A task's life: ` POST /api/v1/tasks ` validates and writes the task hash, then
61- XADDs to the priority stream (or parks it in the retry schedule when
62- delayed). Workers XREADGROUP with bounded prefetch, claim the entry
63- atomically (` QUEUED -> RUNNING ` via Lua), execute the registered handler with
64- a timeout, record the outcome, publish the lifecycle event, and only then
65- XACK. Failures schedule retries; exhausted tasks go to the dead-letter
66- stream. One worker at a time holds the scheduler leader lock and moves due
67- retries back into the streams.
7+ [ ![ License: MIT] ( https://img.shields.io/badge/license-MIT-green.svg )] ( LICENSE )
8+ ![ Python 3.11+] ( https://img.shields.io/badge/python-3.11%2B-blue.svg )
9+ ![ FastAPI] ( https://img.shields.io/badge/FastAPI-0.115-009688.svg )
10+ ![ Redis Streams] ( https://img.shields.io/badge/redis-streams-DC382D.svg )
6811
69- ## Quickstart
12+ </ div >
7013
71- ### With docker compose
14+ dtq takes JSON tasks over HTTP, runs them on a pool of workers, and keeps
15+ running them until they succeed, fail for good, or land in the dead-letter
16+ stream where you can inspect and requeue them. Priorities, retries with
17+ backoff, idempotent submission, delayed tasks, backpressure, Prometheus
18+ metrics, a live dashboard. One ` docker compose up ` and all of it is running.
19+
20+ I built it on Redis Streams rather than a list-based queue because streams
21+ already solve the hard parts: consumer groups, pending entries, and claiming
22+ work from a dead consumer. The engine is the code around that: claim
23+ atomically, execute, record the outcome, and only then acknowledge.
24+
25+ ## Try it
7226
7327```
7428docker compose up --build
7529```
7630
77- - API: http://localhost:8000 (` /api/v1 ` , ` /health ` , ` /ready ` , ` /metrics ` ,
78- ` /ws/events ` )
31+ That starts Redis, the API, a worker, the dashboard, and Prometheus.
32+
33+ - API: http://localhost:8000 (routes under ` /api/v1 ` , plus ` /health ` , ` /ready ` , ` /metrics ` )
7934- Dashboard: http://localhost:3000
8035- Prometheus: http://localhost:9090
8136
82- Scale workers: ` docker compose up --build --scale worker=3 ` .
37+ Submit something:
38+
39+ ```
40+ curl -s -X POST localhost:8000/api/v1/tasks \
41+ -H 'Content-Type: application/json' \
42+ -d '{"task_type":"echo_task","payload":{"hello":"world"},"priority":9}'
43+ curl -s localhost:8000/api/v1/tasks/<task_id>
44+ ```
8345
84- ### Local (no docker)
46+ More workers when you want them: ` docker compose up --build --scale worker=3 ` .
47+
48+ No Docker? There is a local path too:
8549
8650```
8751python3 -m venv .venv && .venv/bin/pip install -e .
@@ -90,53 +54,124 @@ redis-server --daemonize yes --save '' --appendonly no # dev only, no persiste
9054.venv/bin/dtq worker run --concurrency 4
9155```
9256
93- Submit a task (the worker loads the demo handlers in ` examples/tasks ` by
94- default):
57+ And ` scripts/demo.sh ` walks six end-to-end scenarios (retries, the DLQ,
58+ backpressure, a worker dying mid-task, idempotent submits) against a scratch
59+ Redis DB if you want to see the moving parts without reading code first.
60+
61+ ## How it fits together
62+
63+ ``` mermaid
64+ flowchart TB
65+ dashboard["dashboard<br/>React SPA, WebSocket client (:3000)"]
66+ api["api<br/>FastAPI (:8000)<br/>ingest, reads, admin, WS fanout"]
67+ redis[("redis<br/>priority streams p0 to p9, task hashes,<br/>retry schedule, dead-letter stream,<br/>idempotency keys, audit stream")]
68+ worker["worker (x N)<br/>poll, claim, execute, plus the<br/>reaper and scheduler loops"]
9569
70+ dashboard -->|"REST + WS /ws/events"| api
71+ api --> redis
72+ worker --> redis
9673```
97- curl -s -X POST localhost:8000/api/v1/tasks \
98- -H 'Content-Type: application/json' \
99- -d '{"task_type":"echo_task","payload":{"hello":"world"},"priority":9}' | head -c 300
100- echo
101- curl -s localhost:8000/api/v1/tasks/<task_id>
74+
75+ The life of a task is short to describe. ` POST /api/v1/tasks ` validates the
76+ request, writes the task hash, and XADDs to one of ten priority streams (or
77+ parks the task in the retry schedule if it has a delay). Workers read through
78+ a shared consumer group with bounded prefetch, flip the task from QUEUED to
79+ RUNNING with a Lua script so two workers cannot claim the same entry, run the
80+ handler under a timeout, record the result, publish a lifecycle event, and
81+ only then XACK. A worker that dies mid-task leaves its entries pending, and
82+ ` XAUTOCLAIM ` hands them to a live worker. One worker holds the scheduler
83+ leader lock and moves due retries back into the streams.
84+
85+ ## What you get
86+
87+ - ** Strict priorities.** Ten bands per queue, p9 down to p0. A p9 task never
88+ waits behind a p0 backlog.
89+ - ** Retries that know when to stop.** Exponential backoff with jitter and a
90+ capped delay. Errors are classified per attempt: retryable ones retry,
91+ everything else fails fast to the DLQ instead of burning attempts.
92+ - ** Idempotent ingest.** Send an ` Idempotency-Key ` header and replays return
93+ the stored task. A duplicate that arrives while the first is still in
94+ flight gets a 409, not a second execution.
95+ - ** A dead-letter stream you can work with.** Bounded and inspectable, with
96+ requeue (attempts reset, history kept), discard, and purge. Admin actions
97+ land in an audit stream.
98+ - ** Backpressure instead of collapse.** A queue-depth admission gate returns
99+ 429 with ` Retry-After ` , payloads are size-capped (413), and worker prefetch
100+ and WebSocket buffers are bounded.
101+ - ** Observability.** Prometheus metrics, separate ` /health ` and ` /ready `
102+ probes, structured JSON logs, and a WebSocket event stream for the
103+ dashboard.
104+ - ** Delayed and cancellable tasks.** ` delay_seconds ` parks a task until it is
105+ due. Cancellation is cooperative for both queued and running tasks.
106+
107+ ## Writing a handler
108+
109+ A handler is a function with a decorator. Workers only ever run registered
110+ handlers, never anything named by a request payload.
111+
112+ ``` python
113+ from dtq_worker.registry import TaskContext, task
114+
115+ @task (" echo_task" )
116+ def echo_task (payload , ctx : TaskContext):
117+ return {" echo" : payload, " attempt" : ctx.attempt}
102118```
103119
104- More CLI: ` dtq get ` , ` dtq list ` , ` dtq cancel ` , ` dtq dlq list ` ,
105- ` dtq retry-dlq ` , ` dtq queue pause <q> ` , ` dtq queue stats <q> ` .
106- Set ` DTQ_API_KEY ` to require ` X-API-Key ` (or ` --api-key ` ) for the destructive
107- commands.
120+ Point ` DTQ_TASK_MODULES ` at your module so the worker imports it at startup,
121+ and the task type is live. ` examples/tasks ` has a handful to copy from,
122+ including a flaky one and an always-failing one for watching retries and the
123+ DLQ do their thing.
124+
125+ The CLI covers the day-to-day: ` dtq get ` , ` dtq list ` , ` dtq cancel ` ,
126+ ` dtq dlq list ` , ` dtq retry-dlq ` , ` dtq queue pause <q> ` , ` dtq queue stats <q> ` .
127+ Set ` DTQ_API_KEY ` and the destructive commands require an ` X-API-Key ` header.
128+
129+ ## Numbers, with the context attached
130+
131+ Measured on 2026-10-01 on a 2 vCPU VM with 7 GB of RAM, against a local
132+ Redis 8.10.2 with persistence off, running the no-op ` echo_task ` : the ingest
133+ path took about 1,135 submissions a second. A single task with an idle
134+ worker went from submit to succeeded in about 106 ms, which is the floor for
135+ a claim round-trip, a handler call, an outcome write, an event publish, and
136+ an ACK on this machine. Under a 2,000 task burst the completion rate was
137+ about 262 tasks a second, so most of the latency there was queueing delay,
138+ not execution cost. The full method, the raw results, and what actually
139+ limits throughput are in [ docs/performance.md] ( docs/performance.md ) , and
140+ ` scripts/load_test.py ` reruns the whole thing.
141+
142+ The test suite is 169 tests across unit, integration, and end-to-end; run
143+ ` pytest ` and count them yourself.
108144
109- ## Delivery semantics
145+ ## Delivery semantics, stated plainly
110146
111147dtq guarantees ** at-least-once** execution. A task is never silently dropped
112- while its data exists in Redis, but a handler may run more than once: a crash
113- after side effects and before the completion write causes redelivery, and the
114- re-execution will repeat those side effects. Duplicate * tasks* are absorbed by
115- claim-time checks; duplicate * executions* are inherent to the model. Write
116- handlers to be idempotent (see ` dtq_tasks.idempotent_operation ` ), and do not
117- treat the WebSocket event stream as a record: it is Redis pub/sub,
118- fire-and-forget, with no replay for disconnected clients.
148+ while its data exists in Redis, but a handler can run more than once: if a
149+ worker crashes after the side effects and before the completion write, the
150+ task is redelivered and the side effects happen again. Duplicate tasks are
151+ absorbed by the claim-time checks; duplicate executions are part of the
152+ model, so write handlers to be idempotent (` dtq_tasks.idempotent_operation `
153+ helps). Also, do not treat the WebSocket stream as a record of anything. It
154+ is Redis pub/sub, fire-and-forget, and a disconnected client misses events
155+ with no replay.
119156
120157## Docs
121158
122159- [ docs/performance.md] ( docs/performance.md ) : measured throughput and
123- latency numbers (this machine, local Redis), what limits them, and honest
124- caveats.
125- - [ docs/observability.md] ( docs/observability.md ) : Prometheus metrics,
126- ` /health ` vs ` /ready ` , structured logging, WebSocket stream, audit trail,
127- local Prometheus setup.
128- - [ docs/failure-modes.md] ( docs/failure-modes.md ) : crash/restart scenarios,
129- data-loss windows, duplicate execution, operator recovery checklist.
130- - [ docs/security.md] ( docs/security.md ) : trust boundaries, handler execution
131- guarantees, API-key auth, validation limits, what is not protected.
160+ latency, the method behind them, and what limits them.
161+ - [ docs/observability.md] ( docs/observability.md ) : metrics, probes, logging,
162+ the WebSocket stream, and the audit trail.
163+ - [ docs/failure-modes.md] ( docs/failure-modes.md ) : crash and restart
164+ scenarios, data-loss windows, and the operator recovery checklist.
165+ - [ docs/security.md] ( docs/security.md ) : trust boundaries, API-key auth,
166+ validation limits, and what is not protected.
132167- [ docs/threat-model.md] ( docs/threat-model.md ) : assets, actors, attack
133- surface, abuse cases, residual risks.
134- - [ docs/deployment.md] ( docs/deployment.md ) : compose topology, scaling,
135- full ` DTQ_ ` env reference, backup/restore, zero-downtime notes .
168+ surface, and residual risks.
169+ - [ docs/deployment.md] ( docs/deployment.md ) : compose topology, scaling, the
170+ full ` DTQ_ ` environment reference, backup and restore .
136171- [ docs/contributing.md] ( docs/contributing.md ) : repo layout, dev setup,
137- tests, lint/typecheck, commit conventions, adding a handler .
138- - [ CONTRACT.md] ( CONTRACT.md ) : the normative spec ( sections 1-15) . Where code
139- and contract differ , the docs describe the code and note the difference .
172+ tests, and commit conventions.
173+ - [ CONTRACT.md] ( CONTRACT.md ) : the normative spec, sections 1 to 15 . Where
174+ the code and the contract disagree , the docs describe the code and say so .
140175
141176## License
142177
0 commit comments