Skip to content

Commit cd22d84

Browse files
committed
readme: rewrite the front page
1 parent 5377cac commit cd22d84

1 file changed

Lines changed: 140 additions & 105 deletions

File tree

‎README.md‎

Lines changed: 140 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,51 @@
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
```
7428
docker 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
```
8751
python3 -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

111147
dtq 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

Comments
 (0)