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.
Files changed (40) hide show
  1. toro_queue-0.1.0/.github/workflows/ci.yml +41 -0
  2. toro_queue-0.1.0/.github/workflows/release.yml +37 -0
  3. toro_queue-0.1.0/.gitignore +10 -0
  4. toro_queue-0.1.0/.pre-commit-config.yaml +18 -0
  5. toro_queue-0.1.0/DESIGN.md +114 -0
  6. toro_queue-0.1.0/LICENSE +21 -0
  7. toro_queue-0.1.0/PKG-INFO +127 -0
  8. toro_queue-0.1.0/README.md +100 -0
  9. toro_queue-0.1.0/bench/bench.py +73 -0
  10. toro_queue-0.1.0/examples/basic.py +42 -0
  11. toro_queue-0.1.0/examples/stalled.py +57 -0
  12. toro_queue-0.1.0/pyproject.toml +114 -0
  13. toro_queue-0.1.0/tests/conftest.py +117 -0
  14. toro_queue-0.1.0/tests/integration/test_admin.py +114 -0
  15. toro_queue-0.1.0/tests/integration/test_connection.py +14 -0
  16. toro_queue-0.1.0/tests/integration/test_introspection.py +43 -0
  17. toro_queue-0.1.0/tests/integration/test_processing.py +71 -0
  18. toro_queue-0.1.0/tests/integration/test_reliability.py +484 -0
  19. toro_queue-0.1.0/tests/integration/test_retries.py +64 -0
  20. toro_queue-0.1.0/tests/integration/test_scheduler.py +39 -0
  21. toro_queue-0.1.0/tests/integration/test_workers.py +128 -0
  22. toro_queue-0.1.0/tests/load/harness.py +226 -0
  23. toro_queue-0.1.0/tests/load/test_load.py +44 -0
  24. toro_queue-0.1.0/tests/unit/test_backoff.py +24 -0
  25. toro_queue-0.1.0/tests/unit/test_job.py +60 -0
  26. toro_queue-0.1.0/tests/unit/test_job_options.py +53 -0
  27. toro_queue-0.1.0/tests/unit/test_keys.py +30 -0
  28. toro_queue-0.1.0/tests/unit/test_priority.py +22 -0
  29. toro_queue-0.1.0/tests/unit/test_scheduler.py +30 -0
  30. toro_queue-0.1.0/toro/__init__.py +9 -0
  31. toro_queue-0.1.0/toro/connection.py +31 -0
  32. toro_queue-0.1.0/toro/errors.py +15 -0
  33. toro_queue-0.1.0/toro/job.py +154 -0
  34. toro_queue-0.1.0/toro/keys.py +108 -0
  35. toro_queue-0.1.0/toro/py.typed +0 -0
  36. toro_queue-0.1.0/toro/queue.py +545 -0
  37. toro_queue-0.1.0/toro/scheduler.py +37 -0
  38. toro_queue-0.1.0/toro/scripts.py +433 -0
  39. toro_queue-0.1.0/toro/worker.py +525 -0
  40. 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,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ *.pyc
7
+ /dist/
8
+ /build/
9
+
10
+ .coverage
@@ -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).
@@ -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())