toro-queue 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toro_queue-0.1.0/.github/workflows/ci.yml +41 -0
- toro_queue-0.1.0/.github/workflows/release.yml +37 -0
- toro_queue-0.1.0/.gitignore +10 -0
- toro_queue-0.1.0/.pre-commit-config.yaml +18 -0
- toro_queue-0.1.0/DESIGN.md +114 -0
- toro_queue-0.1.0/LICENSE +21 -0
- toro_queue-0.1.0/PKG-INFO +127 -0
- toro_queue-0.1.0/README.md +100 -0
- toro_queue-0.1.0/bench/bench.py +73 -0
- toro_queue-0.1.0/examples/basic.py +42 -0
- toro_queue-0.1.0/examples/stalled.py +57 -0
- toro_queue-0.1.0/pyproject.toml +114 -0
- toro_queue-0.1.0/tests/conftest.py +117 -0
- toro_queue-0.1.0/tests/integration/test_admin.py +114 -0
- toro_queue-0.1.0/tests/integration/test_connection.py +14 -0
- toro_queue-0.1.0/tests/integration/test_introspection.py +43 -0
- toro_queue-0.1.0/tests/integration/test_processing.py +71 -0
- toro_queue-0.1.0/tests/integration/test_reliability.py +484 -0
- toro_queue-0.1.0/tests/integration/test_retries.py +64 -0
- toro_queue-0.1.0/tests/integration/test_scheduler.py +39 -0
- toro_queue-0.1.0/tests/integration/test_workers.py +128 -0
- toro_queue-0.1.0/tests/load/harness.py +226 -0
- toro_queue-0.1.0/tests/load/test_load.py +44 -0
- toro_queue-0.1.0/tests/unit/test_backoff.py +24 -0
- toro_queue-0.1.0/tests/unit/test_job.py +60 -0
- toro_queue-0.1.0/tests/unit/test_job_options.py +53 -0
- toro_queue-0.1.0/tests/unit/test_keys.py +30 -0
- toro_queue-0.1.0/tests/unit/test_priority.py +22 -0
- toro_queue-0.1.0/tests/unit/test_scheduler.py +30 -0
- toro_queue-0.1.0/toro/__init__.py +9 -0
- toro_queue-0.1.0/toro/connection.py +31 -0
- toro_queue-0.1.0/toro/errors.py +15 -0
- toro_queue-0.1.0/toro/job.py +154 -0
- toro_queue-0.1.0/toro/keys.py +108 -0
- toro_queue-0.1.0/toro/py.typed +0 -0
- toro_queue-0.1.0/toro/queue.py +545 -0
- toro_queue-0.1.0/toro/scheduler.py +37 -0
- toro_queue-0.1.0/toro/scripts.py +433 -0
- toro_queue-0.1.0/toro/worker.py +525 -0
- toro_queue-0.1.0/uv.lock +492 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
check:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
services:
|
|
11
|
+
redis:
|
|
12
|
+
image: redis:7-alpine
|
|
13
|
+
ports:
|
|
14
|
+
- 6379:6379
|
|
15
|
+
options: >-
|
|
16
|
+
--health-cmd "redis-cli ping"
|
|
17
|
+
--health-interval 10s
|
|
18
|
+
--health-timeout 5s
|
|
19
|
+
--health-retries 5
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- name: Install uv
|
|
24
|
+
uses: astral-sh/setup-uv@v5
|
|
25
|
+
with:
|
|
26
|
+
enable-cache: true
|
|
27
|
+
|
|
28
|
+
- name: Sync dependencies
|
|
29
|
+
run: uv sync
|
|
30
|
+
|
|
31
|
+
- name: Lint (ruff)
|
|
32
|
+
run: uv run ruff check .
|
|
33
|
+
|
|
34
|
+
- name: Format (ruff)
|
|
35
|
+
run: uv run ruff format --check .
|
|
36
|
+
|
|
37
|
+
- name: Type check (ty)
|
|
38
|
+
run: uv run ty check
|
|
39
|
+
|
|
40
|
+
- name: Tests (unit + integration)
|
|
41
|
+
run: uv run pytest -m "unit or integration"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# A version tag (v*) builds and publishes to PyPI via trusted publishing (OIDC)
|
|
4
|
+
# — no API tokens stored. The publish job runs in the `pypi` environment, which
|
|
5
|
+
# must match the trusted publisher registered on PyPI.
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags: ["v*"]
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- name: Install uv
|
|
17
|
+
uses: astral-sh/setup-uv@v5
|
|
18
|
+
- name: Build sdist + wheel
|
|
19
|
+
run: uv build
|
|
20
|
+
- uses: actions/upload-artifact@v4
|
|
21
|
+
with:
|
|
22
|
+
name: dist
|
|
23
|
+
path: dist/
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
needs: build
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
environment: pypi
|
|
29
|
+
permissions:
|
|
30
|
+
id-token: write
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/download-artifact@v4
|
|
33
|
+
with:
|
|
34
|
+
name: dist
|
|
35
|
+
path: dist/
|
|
36
|
+
- name: Publish to PyPI
|
|
37
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Astral toolchain on every commit: ruff (lint + format) and ty (type check).
|
|
2
|
+
# Install once with: uvx pre-commit install
|
|
3
|
+
repos:
|
|
4
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
5
|
+
rev: v0.15.15
|
|
6
|
+
hooks:
|
|
7
|
+
- id: ruff-check
|
|
8
|
+
args: [--fix]
|
|
9
|
+
- id: ruff-format
|
|
10
|
+
|
|
11
|
+
- repo: local
|
|
12
|
+
hooks:
|
|
13
|
+
- id: ty
|
|
14
|
+
name: ty (type check)
|
|
15
|
+
entry: uv run ty check
|
|
16
|
+
language: system
|
|
17
|
+
types: [python]
|
|
18
|
+
pass_filenames: false
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# toro — design & architecture
|
|
2
|
+
|
|
3
|
+
`toro` is an async-first, Redis-backed job queue for Python. This doc describes
|
|
4
|
+
how it works and the reasoning behind the core choices.
|
|
5
|
+
|
|
6
|
+
> Prior art: the design draws on the proven Redis-queue patterns popularized by
|
|
7
|
+
> the Node.js ecosystem (atomic Lua transitions, reliable blocking pop, lock +
|
|
8
|
+
> stalled recovery). Credit where due — but the rest of this doc describes
|
|
9
|
+
> toro's own design.
|
|
10
|
+
|
|
11
|
+
## Core principles
|
|
12
|
+
|
|
13
|
+
### 1. Atomic state transitions via Lua
|
|
14
|
+
Every state move (`wait→active`, `active→completed/failed/delayed`,
|
|
15
|
+
`delayed→wait`) is a single Redis Lua script. Redis runs each script atomically,
|
|
16
|
+
so multi-key "check-then-act" sequences can't interleave. This kills three
|
|
17
|
+
classes of race:
|
|
18
|
+
- **pop-then-lock gap** — two workers both grabbing the same job.
|
|
19
|
+
- **finish-after-steal** — a worker committing a result for a job a stalled-sweep
|
|
20
|
+
already re-queued (guarded with a token check + `LREM active` returning 0).
|
|
21
|
+
- **priority insertion vs concurrent consume** — `LINSERT` position computed and
|
|
22
|
+
used in one snapshot.
|
|
23
|
+
|
|
24
|
+
Scripts live in `scripts.py`, registered via `redis.asyncio`'s `register_script`.
|
|
25
|
+
The Python side only assembles KEYS/ARGV — the guarantees live in the Lua.
|
|
26
|
+
|
|
27
|
+
### 2. Reliable fetch: blocking pop on a dedicated connection
|
|
28
|
+
The worker pops `wait→active` with `BLMOVE` (the non-deprecated `BRPOPLPUSH`) so
|
|
29
|
+
the job is on `active` *before* the worker starts. If the worker dies mid-job,
|
|
30
|
+
the id is still on `active` and the stalled sweep (principle 3) recovers it. The
|
|
31
|
+
blocking call should use a **separate** Redis connection, since a blocked
|
|
32
|
+
connection can't issue other commands.
|
|
33
|
+
|
|
34
|
+
→ **toro:** uses `BLMOVE wait active RIGHT LEFT`. TODO: isolate the blocking pop
|
|
35
|
+
on its own connection.
|
|
36
|
+
|
|
37
|
+
### 3. Locks + token + stalled recovery — the at-least-once guarantee
|
|
38
|
+
- On move-to-active, set `<q>:<id>:lock = <token> PX lockDuration` (default 30s).
|
|
39
|
+
The token is a per-worker UUID — only the owner can renew.
|
|
40
|
+
- A renewer extends the lock every `lockDuration/2` (token-guarded `GET==token`
|
|
41
|
+
then re-`SET PX`); a successful renew also `SREM`s the job from the `stalled`
|
|
42
|
+
set.
|
|
43
|
+
- A sweep runs every `stalledInterval` (~30s), throttled cluster-wide by a
|
|
44
|
+
`stalled-check` PX key. It is **mark-and-sweep**: sweep the `stalled` set (any
|
|
45
|
+
member whose `:lock` is gone → `LREM active`, push back to `wait`, or fail if
|
|
46
|
+
`stalledCounter > maxStalledCount`, default 1), then re-`SADD` the current
|
|
47
|
+
`active` list into `stalled`. Live workers renew and remove themselves before
|
|
48
|
+
the next sweep; only genuinely dead jobs remain.
|
|
49
|
+
|
|
50
|
+
**Guarantee:** at-least-once. A job is never lost while Redis persists, but its
|
|
51
|
+
handler may run more than once (bounded by `maxStalledCount`). Exactly-once
|
|
52
|
+
*result commit* is enforced by the token-guarded lock at finish time, not by
|
|
53
|
+
preventing duplicate handler execution.
|
|
54
|
+
|
|
55
|
+
→ **toro:** NOT YET IMPLEMENTED. Top priority after the core. Plan: per-job lock
|
|
56
|
+
key + token, an `asyncio` lock-renewal task per in-flight job, and a background
|
|
57
|
+
`_stalled_loop` running a mark-and-sweep Lua script.
|
|
58
|
+
|
|
59
|
+
### 4. One delay timer, pub/sub-woken
|
|
60
|
+
Delayed jobs live in a ZSET scored `(ts << 12) | (counter & 0xFFF)` — low 12 bits
|
|
61
|
+
keep FIFO order among same-ms jobs. Arm a single timer for the next due job
|
|
62
|
+
(capped by a guard interval) and re-arm it instantly when a sooner job is added,
|
|
63
|
+
via a pub/sub message on the `delayed` channel. A promotion script moves all due
|
|
64
|
+
jobs to `wait`.
|
|
65
|
+
|
|
66
|
+
→ **toro:** currently a 1s poll in `_promote_loop` (simple, correct, slightly
|
|
67
|
+
wasteful). Upgrade path: single `asyncio` timer re-armed via a pub/sub
|
|
68
|
+
subscriber. Also adopt the packed score for correct same-ms ordering.
|
|
69
|
+
|
|
70
|
+
### 5. Fetch-next-in-finish ✅ done
|
|
71
|
+
The finish script commits the current job and pops + locks the next one in the
|
|
72
|
+
same round trip — skipped when shutting down (fetch flag), so the queue drains
|
|
73
|
+
cleanly.
|
|
74
|
+
|
|
75
|
+
→ **toro:** implemented. All job acquisition funnels through one shared Lua
|
|
76
|
+
routine (`lockAndLoad` / `acquireNext` in `scripts.py`), used by both the
|
|
77
|
+
blocking-wakeup path (`MOVE_TO_ACTIVE`) and the fetch-next tail of
|
|
78
|
+
`MOVE_TO_COMPLETED` / `MOVE_TO_FAILED`. This routine is the seed of a future
|
|
79
|
+
`moveToActive`: to add priorities/markers we change only *which* job
|
|
80
|
+
`acquireNext` picks — `lockAndLoad` and every caller stay untouched.
|
|
81
|
+
|
|
82
|
+
**Measured:** process throughput went ~6,080 → ~13,900 jobs/s (~2.3×) at
|
|
83
|
+
concurrency 20. Notably cmds/job barely changed (13 → 12) — the win is fewer
|
|
84
|
+
*round trips* (the old `BLMOVE` + `MOVE_TO_ACTIVE` + `HGETALL` per job collapse
|
|
85
|
+
into the finish call), not less server work.
|
|
86
|
+
|
|
87
|
+
## Higher-level features (roadmap)
|
|
88
|
+
- **Priorities:** ✅ done — and we deliberately *diverge* from the common approach. The usual design
|
|
89
|
+
inserts into `wait` with `LINSERT` (O(N)) and keeps a separate `wait`
|
|
90
|
+
fast-lane that strictly beats `prioritized` (priority-0 jobs can starve
|
|
91
|
+
prioritized ones). toro instead puts **every** job in one `prioritized` ZSET
|
|
92
|
+
scored `(PRIORITY_OFFSET - priority) * 2^32 + seq` — a single GLOBAL order,
|
|
93
|
+
higher priority = more urgent, FIFO within a level, no fast lane. Because a
|
|
94
|
+
single ZSET can't be `BLMOVE`'d, this brings in the base marker: producers
|
|
95
|
+
`ZADD marker 0 "0"` (idempotent), idle workers `BZPOPMIN marker`, and the
|
|
96
|
+
atomic `MOVE_TO_ACTIVE` does `ZPOPMIN prioritized` → active → lock. The 1s
|
|
97
|
+
delay poll still promotes delayed → prioritized (delay-marker is future work).
|
|
98
|
+
- **Repeatable/cron:** a `repeat` ZSET of schedule entries; each occurrence
|
|
99
|
+
schedules its successor as a *delayed* job with a deterministic id. Port with
|
|
100
|
+
`croniter`.
|
|
101
|
+
- **Rate limiting:** a `PSETEX`/`INCR` counter per window (optionally per group);
|
|
102
|
+
limited jobs parked in `delayed`. Disables fetch-next.
|
|
103
|
+
- **Events:** Redis pub/sub or Streams. Streams give replay + consumer groups,
|
|
104
|
+
which suits an async dashboard.
|
|
105
|
+
- **Auto-removal:** `removeOnComplete`/`removeOnFail` (bool/count/{count,age})
|
|
106
|
+
enforced inside the finish script, not by a sweeper.
|
|
107
|
+
|
|
108
|
+
## Python-specific choices
|
|
109
|
+
- **async-first**: `redis.asyncio`, `async def` processors, one event loop.
|
|
110
|
+
Concurrency = N `asyncio` tasks sharing the loop.
|
|
111
|
+
- **Cluster:** use a `{braces}` hash-tag prefix so all keys for a queue share a
|
|
112
|
+
slot (multi-key Lua needs one slot).
|
|
113
|
+
- **Lua deps:** plain JSON, integers passed as ARGV — keeps scripts portable
|
|
114
|
+
across Redis builds (no `cmsgpack`/`bit`/`cjson` requirement).
|
toro_queue-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ilovepixelart
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toro-queue
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An async-first, Redis-backed job queue for Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ilovepixelart/toro
|
|
6
|
+
Project-URL: Repository, https://github.com/ilovepixelart/toro
|
|
7
|
+
Project-URL: Issues, https://github.com/ilovepixelart/toro/issues
|
|
8
|
+
Author: ilovepixelart
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: async,asyncio,jobs,queue,redis,scheduler,task-queue,worker
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: croniter>=2.0
|
|
25
|
+
Requires-Dist: redis>=5.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# toro 🐂
|
|
29
|
+
|
|
30
|
+
An **async-first**, Redis-backed job queue for Python. Every state transition is
|
|
31
|
+
an atomic Lua script; producing and processing are `asyncio` end to end.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install toro-queue # the import name is `toro`
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> Installed as **`toro-queue`** on PyPI (the name `toro` was taken), but you
|
|
38
|
+
> `import toro`. See [DESIGN.md](https://github.com/ilovepixelart/toro/blob/main/DESIGN.md) for the architecture and the
|
|
39
|
+
> at-least-once reliability model.
|
|
40
|
+
|
|
41
|
+
## Why toro
|
|
42
|
+
|
|
43
|
+
- **Async-native.** Enqueue and process with `async`/`await` — no thread pools,
|
|
44
|
+
no sync bridge. A natural fit for FastAPI, aiohttp, or any asyncio app.
|
|
45
|
+
- **Atomic by construction.** Claims, retries, promotions and finishes are Lua
|
|
46
|
+
scripts, so a job can't be lost or double-committed between two round trips.
|
|
47
|
+
- **At-least-once delivery.** Per-job locks + a background mark-and-sweep recover
|
|
48
|
+
jobs from workers that crashed — without the visibility-timeout double-delivery
|
|
49
|
+
trap of some other queues.
|
|
50
|
+
- **Typed.** Ships `py.typed`; the public API is fully annotated.
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
| | |
|
|
55
|
+
|---|---|
|
|
56
|
+
| **Enqueue** | delayed jobs, global **priorities** (FIFO within a band) |
|
|
57
|
+
| **Retries** | fixed or exponential **backoff**, capped attempts |
|
|
58
|
+
| **Schedules** | repeatable **cron** and fixed-interval (`every`) jobs |
|
|
59
|
+
| **Rate limiting** | queue-wide token bucket shared across all workers |
|
|
60
|
+
| **Dedup** | custom (idempotent) job ids + a throttle window (`{id, ttl}`) |
|
|
61
|
+
| **Auto-removal** | keep the last N and/or finished-within-age completed/failed |
|
|
62
|
+
| **Reliability** | per-job locks, lock renewal, stalled-job recovery |
|
|
63
|
+
| **Observability** | progress, per-job logs, lifecycle events, `await result()` |
|
|
64
|
+
| **Lifecycle** | pause / resume, graceful shutdown that drains in-flight jobs |
|
|
65
|
+
| **Dashboard** | [matador](https://github.com/ilovepixelart/matador) — a live web UI |
|
|
66
|
+
|
|
67
|
+
## Quick start
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import asyncio
|
|
71
|
+
from toro import Queue, Worker
|
|
72
|
+
|
|
73
|
+
async def main():
|
|
74
|
+
queue = Queue("emails")
|
|
75
|
+
await queue.add("welcome", {"to": "ada@example.com"})
|
|
76
|
+
|
|
77
|
+
async def process(job):
|
|
78
|
+
print("sending", job.data)
|
|
79
|
+
return {"ok": True}
|
|
80
|
+
|
|
81
|
+
worker = Worker("emails", process, concurrency=8)
|
|
82
|
+
worker.on("completed", lambda job, result: print("done", job.id))
|
|
83
|
+
await worker.run()
|
|
84
|
+
|
|
85
|
+
asyncio.run(main())
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## A taste of the options
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
# Priorities, delay, and retry-with-backoff
|
|
92
|
+
await queue.add("report", data, priority=10, delay=5000,
|
|
93
|
+
attempts=5, backoff={"type": "exponential", "delay": 1000})
|
|
94
|
+
|
|
95
|
+
# Idempotent custom id (a second add with the same id is ignored)
|
|
96
|
+
await queue.add("charge", data, job_id="order-1234")
|
|
97
|
+
|
|
98
|
+
# A repeatable schedule (cron or every-N-ms); "run now" with trigger_scheduler
|
|
99
|
+
await queue.add_scheduler("nightly-rollup", cron="0 0 * * *")
|
|
100
|
+
|
|
101
|
+
# Queue-wide rate limit: at most 100 jobs / second across every worker
|
|
102
|
+
worker = Worker("emails", process, rate_limit={"max": 100, "duration": 1000})
|
|
103
|
+
|
|
104
|
+
# Wait for a result from the producer side
|
|
105
|
+
job = await queue.add("resize", {"src": "a.png"})
|
|
106
|
+
print(await job.result(timeout=30))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Develop
|
|
110
|
+
|
|
111
|
+
Managed with [uv](https://astral.sh/uv); the Astral toolchain throughout.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
uv sync # venv + deps + dev group
|
|
115
|
+
uv run ruff check . # lint (strict: select = ALL)
|
|
116
|
+
uv run ruff format . # format
|
|
117
|
+
uv run ty check # type check
|
|
118
|
+
uv run pytest -m "unit or integration" # tests (integration needs Redis on :6379)
|
|
119
|
+
uv run python examples/basic.py
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The suite is a pyramid — `-m unit` (fast, no Redis), `-m integration` (Redis),
|
|
123
|
+
and `-m load` (the open-loop benchmark harness in `tests/load/`).
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
[MIT](https://github.com/ilovepixelart/toro/blob/main/LICENSE)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# toro 🐂
|
|
2
|
+
|
|
3
|
+
An **async-first**, Redis-backed job queue for Python. Every state transition is
|
|
4
|
+
an atomic Lua script; producing and processing are `asyncio` end to end.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install toro-queue # the import name is `toro`
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
> Installed as **`toro-queue`** on PyPI (the name `toro` was taken), but you
|
|
11
|
+
> `import toro`. See [DESIGN.md](https://github.com/ilovepixelart/toro/blob/main/DESIGN.md) for the architecture and the
|
|
12
|
+
> at-least-once reliability model.
|
|
13
|
+
|
|
14
|
+
## Why toro
|
|
15
|
+
|
|
16
|
+
- **Async-native.** Enqueue and process with `async`/`await` — no thread pools,
|
|
17
|
+
no sync bridge. A natural fit for FastAPI, aiohttp, or any asyncio app.
|
|
18
|
+
- **Atomic by construction.** Claims, retries, promotions and finishes are Lua
|
|
19
|
+
scripts, so a job can't be lost or double-committed between two round trips.
|
|
20
|
+
- **At-least-once delivery.** Per-job locks + a background mark-and-sweep recover
|
|
21
|
+
jobs from workers that crashed — without the visibility-timeout double-delivery
|
|
22
|
+
trap of some other queues.
|
|
23
|
+
- **Typed.** Ships `py.typed`; the public API is fully annotated.
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
| | |
|
|
28
|
+
|---|---|
|
|
29
|
+
| **Enqueue** | delayed jobs, global **priorities** (FIFO within a band) |
|
|
30
|
+
| **Retries** | fixed or exponential **backoff**, capped attempts |
|
|
31
|
+
| **Schedules** | repeatable **cron** and fixed-interval (`every`) jobs |
|
|
32
|
+
| **Rate limiting** | queue-wide token bucket shared across all workers |
|
|
33
|
+
| **Dedup** | custom (idempotent) job ids + a throttle window (`{id, ttl}`) |
|
|
34
|
+
| **Auto-removal** | keep the last N and/or finished-within-age completed/failed |
|
|
35
|
+
| **Reliability** | per-job locks, lock renewal, stalled-job recovery |
|
|
36
|
+
| **Observability** | progress, per-job logs, lifecycle events, `await result()` |
|
|
37
|
+
| **Lifecycle** | pause / resume, graceful shutdown that drains in-flight jobs |
|
|
38
|
+
| **Dashboard** | [matador](https://github.com/ilovepixelart/matador) — a live web UI |
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from toro import Queue, Worker
|
|
45
|
+
|
|
46
|
+
async def main():
|
|
47
|
+
queue = Queue("emails")
|
|
48
|
+
await queue.add("welcome", {"to": "ada@example.com"})
|
|
49
|
+
|
|
50
|
+
async def process(job):
|
|
51
|
+
print("sending", job.data)
|
|
52
|
+
return {"ok": True}
|
|
53
|
+
|
|
54
|
+
worker = Worker("emails", process, concurrency=8)
|
|
55
|
+
worker.on("completed", lambda job, result: print("done", job.id))
|
|
56
|
+
await worker.run()
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## A taste of the options
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
# Priorities, delay, and retry-with-backoff
|
|
65
|
+
await queue.add("report", data, priority=10, delay=5000,
|
|
66
|
+
attempts=5, backoff={"type": "exponential", "delay": 1000})
|
|
67
|
+
|
|
68
|
+
# Idempotent custom id (a second add with the same id is ignored)
|
|
69
|
+
await queue.add("charge", data, job_id="order-1234")
|
|
70
|
+
|
|
71
|
+
# A repeatable schedule (cron or every-N-ms); "run now" with trigger_scheduler
|
|
72
|
+
await queue.add_scheduler("nightly-rollup", cron="0 0 * * *")
|
|
73
|
+
|
|
74
|
+
# Queue-wide rate limit: at most 100 jobs / second across every worker
|
|
75
|
+
worker = Worker("emails", process, rate_limit={"max": 100, "duration": 1000})
|
|
76
|
+
|
|
77
|
+
# Wait for a result from the producer side
|
|
78
|
+
job = await queue.add("resize", {"src": "a.png"})
|
|
79
|
+
print(await job.result(timeout=30))
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Develop
|
|
83
|
+
|
|
84
|
+
Managed with [uv](https://astral.sh/uv); the Astral toolchain throughout.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv sync # venv + deps + dev group
|
|
88
|
+
uv run ruff check . # lint (strict: select = ALL)
|
|
89
|
+
uv run ruff format . # format
|
|
90
|
+
uv run ty check # type check
|
|
91
|
+
uv run pytest -m "unit or integration" # tests (integration needs Redis on :6379)
|
|
92
|
+
uv run python examples/basic.py
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The suite is a pyramid — `-m unit` (fast, no Redis), `-m integration` (Redis),
|
|
96
|
+
and `-m load` (the open-loop benchmark harness in `tests/load/`).
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
[MIT](https://github.com/ilovepixelart/toro/blob/main/LICENSE)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Measure toro's hot path: enqueue + process throughput and Redis cmds/job.
|
|
2
|
+
|
|
3
|
+
Compares against the pre-fetch-next baseline we recorded (~6,080 jobs/s,
|
|
4
|
+
13 cmds/job) to show the round-trip savings.
|
|
5
|
+
|
|
6
|
+
Usage: uv run python bench/bench.py [N] [concurrency]
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import redis.asyncio as aioredis
|
|
14
|
+
|
|
15
|
+
from toro import Queue, Worker
|
|
16
|
+
|
|
17
|
+
URL = "redis://localhost:6379"
|
|
18
|
+
N = int(sys.argv[1]) if len(sys.argv) > 1 else 5000
|
|
19
|
+
CONC = int(sys.argv[2]) if len(sys.argv) > 2 else 20
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _clear(r, base):
|
|
23
|
+
keys = await r.keys(base + "*")
|
|
24
|
+
if keys:
|
|
25
|
+
await r.delete(*keys)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _cmds(r):
|
|
29
|
+
return (await r.info("stats"))["total_commands_processed"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def main():
|
|
33
|
+
r = aioredis.from_url(URL, decode_responses=True)
|
|
34
|
+
await _clear(r, "bench:b:")
|
|
35
|
+
q = Queue("b", url=URL, prefix="bench")
|
|
36
|
+
|
|
37
|
+
c0 = await _cmds(r)
|
|
38
|
+
t0 = time.perf_counter()
|
|
39
|
+
for i in range(N):
|
|
40
|
+
await q.add("bench", {"i": i})
|
|
41
|
+
enq = time.perf_counter() - t0
|
|
42
|
+
enq_cmds = await _cmds(r) - c0
|
|
43
|
+
|
|
44
|
+
done = asyncio.Event()
|
|
45
|
+
count = 0
|
|
46
|
+
|
|
47
|
+
async def handler(job):
|
|
48
|
+
nonlocal count
|
|
49
|
+
count += 1
|
|
50
|
+
if count >= N:
|
|
51
|
+
done.set()
|
|
52
|
+
|
|
53
|
+
worker = Worker("b", handler, url=URL, prefix="bench", concurrency=CONC)
|
|
54
|
+
c1 = await _cmds(r)
|
|
55
|
+
t1 = time.perf_counter()
|
|
56
|
+
task = asyncio.create_task(worker.run())
|
|
57
|
+
await asyncio.wait_for(done.wait(), timeout=120)
|
|
58
|
+
proc = time.perf_counter() - t1
|
|
59
|
+
proc_cmds = await _cmds(r) - c1
|
|
60
|
+
|
|
61
|
+
await worker.stop()
|
|
62
|
+
task.cancel()
|
|
63
|
+
await q.close()
|
|
64
|
+
await r.aclose()
|
|
65
|
+
|
|
66
|
+
print(f"N={N}, concurrency={CONC}\n")
|
|
67
|
+
print(f"enqueue: {N / enq:>8,.0f} jobs/s {enq_cmds / N:>5.1f} cmds/job")
|
|
68
|
+
print(f"process: {count / proc:>8,.0f} jobs/s {proc_cmds / count:>5.1f} cmds/job")
|
|
69
|
+
print("\nbaseline (pre fetch-next): ~6,080 jobs/s, 13.0 cmds/job")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Minimal end-to-end demo.
|
|
2
|
+
|
|
3
|
+
Run a Redis on localhost:6379, then: python examples/basic.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from toro import Queue, Worker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def main():
|
|
12
|
+
queue = Queue("emails")
|
|
13
|
+
|
|
14
|
+
# Producer: enqueue a few jobs, one delayed, one that will fail-and-retry.
|
|
15
|
+
await queue.add("welcome", {"to": "ada@example.com"})
|
|
16
|
+
await queue.add("welcome", {"to": "alan@example.com"}, delay=2000)
|
|
17
|
+
await queue.add("flaky", {"n": 1}, attempts=3, backoff={"type": "exponential", "delay": 500})
|
|
18
|
+
|
|
19
|
+
# Consumer.
|
|
20
|
+
async def process(job):
|
|
21
|
+
if job.name == "flaky" and job.attempts_made < 3:
|
|
22
|
+
raise RuntimeError(f"transient failure (try {job.attempts_made})")
|
|
23
|
+
print(f" processed {job.name} #{job.id} -> {job.data}")
|
|
24
|
+
return {"ok": True}
|
|
25
|
+
|
|
26
|
+
worker = Worker("emails", process, concurrency=4)
|
|
27
|
+
worker.on("completed", lambda job, res: print(f" ✓ completed {job.name} #{job.id}"))
|
|
28
|
+
worker.on("retrying", lambda job, exc: print(f" ↻ retrying #{job.id}: {exc}"))
|
|
29
|
+
worker.on("failed", lambda job, exc: print(f" ✗ failed #{job.id}: {exc}"))
|
|
30
|
+
|
|
31
|
+
runner = asyncio.create_task(worker.run())
|
|
32
|
+
|
|
33
|
+
await asyncio.sleep(5) # let the delayed + retried jobs flush
|
|
34
|
+
print("counts:", await queue.counts())
|
|
35
|
+
|
|
36
|
+
await worker.stop()
|
|
37
|
+
runner.cancel()
|
|
38
|
+
await queue.close()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Demo: a worker dies mid-job and another recovers it — exactly one completion.
|
|
2
|
+
|
|
3
|
+
Run a Redis on localhost:6379, then: python examples/stalled.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from toro import Queue, Worker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def main():
|
|
12
|
+
queue = Queue("recovery_demo")
|
|
13
|
+
# clean slate for the demo
|
|
14
|
+
keys = await queue.redis.keys(queue.keys.base + "*")
|
|
15
|
+
if keys:
|
|
16
|
+
await queue.redis.delete(*keys)
|
|
17
|
+
|
|
18
|
+
await queue.add("report", {"id": 42})
|
|
19
|
+
|
|
20
|
+
async def hangs(job):
|
|
21
|
+
print(f" [zombie] picked up #{job.id}, then hangs forever...")
|
|
22
|
+
await asyncio.sleep(30) # never finishes in time
|
|
23
|
+
return {"by": "zombie"}
|
|
24
|
+
|
|
25
|
+
async def works(job):
|
|
26
|
+
print(f" [healthy] recovered #{job.id}, processing")
|
|
27
|
+
return {"by": "healthy"}
|
|
28
|
+
|
|
29
|
+
# A zombie: short lock, never renews, doesn't run stalled checks.
|
|
30
|
+
zombie = Worker(
|
|
31
|
+
"recovery_demo", hangs, lock_duration=500, renew_locks=False, stalled_interval=0
|
|
32
|
+
)
|
|
33
|
+
# A healthy worker that sweeps for stalled jobs every 500ms.
|
|
34
|
+
healthy = Worker("recovery_demo", works, stalled_interval=500, max_stalled_count=3)
|
|
35
|
+
healthy.on("stalled", lambda jid: print(f" [healthy] detected #{jid} stalled -> requeued"))
|
|
36
|
+
healthy.on("completed", lambda j, r: print(f" ✓ #{j.id} completed by {r['by']}"))
|
|
37
|
+
zombie.on(
|
|
38
|
+
"lock-lost",
|
|
39
|
+
lambda jid: print(f" [zombie] woke up, but #{jid} was taken — finish rejected"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
zt = asyncio.create_task(zombie.run())
|
|
43
|
+
await asyncio.sleep(0.4) # let the zombie grab the job
|
|
44
|
+
ht = asyncio.create_task(healthy.run())
|
|
45
|
+
|
|
46
|
+
await asyncio.sleep(3)
|
|
47
|
+
print("counts:", await queue.counts())
|
|
48
|
+
|
|
49
|
+
await zombie.stop()
|
|
50
|
+
await healthy.stop()
|
|
51
|
+
zt.cancel()
|
|
52
|
+
ht.cancel()
|
|
53
|
+
await queue.close()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
asyncio.run(main())
|