toro-queue 0.2.0__tar.gz → 0.3.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 (66) hide show
  1. {toro_queue-0.2.0 → toro_queue-0.3.0}/.github/workflows/pr-check.yaml +2 -2
  2. {toro_queue-0.2.0 → toro_queue-0.3.0}/.github/workflows/release.yml +1 -1
  3. toro_queue-0.3.0/.pre-commit-config.yaml +26 -0
  4. {toro_queue-0.2.0 → toro_queue-0.3.0}/PKG-INFO +5 -5
  5. {toro_queue-0.2.0 → toro_queue-0.3.0}/README.md +4 -4
  6. {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/architecture.md +16 -16
  7. {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/concepts.md +2 -2
  8. toro_queue-0.3.0/docs/index.md +22 -0
  9. {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/processing.md +11 -11
  10. {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/producing.md +11 -8
  11. {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/reliability.md +7 -7
  12. {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/scheduling.md +6 -6
  13. toro_queue-0.3.0/docs/security.md +38 -0
  14. toro_queue-0.3.0/examples/README.md +19 -0
  15. {toro_queue-0.2.0 → toro_queue-0.3.0}/examples/stalled.py +2 -2
  16. {toro_queue-0.2.0 → toro_queue-0.3.0}/pyproject.toml +5 -5
  17. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/conftest.py +2 -2
  18. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_admin.py +2 -2
  19. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_admin_ordering.py +4 -4
  20. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_finished_retention.py +4 -4
  21. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_introspection.py +1 -1
  22. toro_queue-0.3.0/tests/integration/test_metrics.py +323 -0
  23. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_processing.py +2 -2
  24. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_reliability.py +27 -6
  25. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_result_dispatcher.py +3 -3
  26. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_retries.py +1 -1
  27. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_worker_resilience.py +1 -1
  28. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_workers.py +3 -3
  29. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/harness.py +8 -8
  30. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_active_list_cost.py +18 -6
  31. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_admin_scaling.py +4 -4
  32. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_enqueue_rtt.py +5 -3
  33. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_load.py +7 -7
  34. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_promote_blocking.py +4 -4
  35. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_result_fanout.py +1 -1
  36. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_worker_concurrency.py +3 -3
  37. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/unit/test_backoff.py +1 -1
  38. toro_queue-0.3.0/tests/unit/test_histogram.py +64 -0
  39. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/unit/test_job.py +2 -2
  40. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/unit/test_job_options.py +2 -2
  41. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/unit/test_keys.py +1 -1
  42. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/unit/test_scheduler.py +1 -1
  43. {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/__init__.py +5 -3
  44. {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/connection.py +1 -1
  45. {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/job.py +1 -1
  46. {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/keys.py +9 -3
  47. {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/queue.py +189 -20
  48. {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/scheduler.py +3 -3
  49. {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/scripts.py +79 -20
  50. {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/worker.py +15 -13
  51. {toro_queue-0.2.0 → toro_queue-0.3.0}/uv.lock +1 -1
  52. toro_queue-0.2.0/.pre-commit-config.yaml +0 -18
  53. toro_queue-0.2.0/docs/index.md +0 -20
  54. toro_queue-0.2.0/examples/README.md +0 -19
  55. {toro_queue-0.2.0 → toro_queue-0.3.0}/.gitignore +0 -0
  56. {toro_queue-0.2.0 → toro_queue-0.3.0}/.vscode/extensions.json +0 -0
  57. {toro_queue-0.2.0 → toro_queue-0.3.0}/.vscode/settings.json +0 -0
  58. {toro_queue-0.2.0 → toro_queue-0.3.0}/LICENSE +0 -0
  59. {toro_queue-0.2.0 → toro_queue-0.3.0}/bench/bench.py +0 -0
  60. {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/data-model.md +0 -0
  61. {toro_queue-0.2.0 → toro_queue-0.3.0}/examples/basic.py +0 -0
  62. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_connection.py +0 -0
  63. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_scheduler.py +0 -0
  64. {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/unit/test_priority.py +0 -0
  65. {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/errors.py +0 -0
  66. {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/py.typed +0 -0
@@ -9,7 +9,7 @@ on:
9
9
  pull_request:
10
10
  types: [opened, synchronize, reopened]
11
11
 
12
- # Read-only by default nothing here writes to the repo. SonarCloud PR
12
+ # Read-only by default - nothing here writes to the repo. SonarCloud PR
13
13
  # decoration comes from the SonarCloud GitHub App, not GITHUB_TOKEN write scopes.
14
14
  permissions:
15
15
  contents: read
@@ -68,7 +68,7 @@ jobs:
68
68
  run: uv run pytest -m "unit or integration" --cov=toro --cov-report=xml
69
69
 
70
70
  # Runs only when SONAR_TOKEN is set (skipped on forks / before setup, so the
71
- # check stays green), and only once per matrix one coverage upload.
71
+ # check stays green), and only once per matrix - one coverage upload.
72
72
  # Config is passed inline; there is no sonar-project.properties.
73
73
  - name: SonarCloud scan
74
74
  if: matrix.python-version == '3.13' && env.SONAR_TOKEN
@@ -1,7 +1,7 @@
1
1
  name: Release
2
2
 
3
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
4
+ # - no API tokens stored. The publish job runs in the `pypi` environment, which
5
5
  # must match the trusted publisher registered on PyPI.
6
6
  on:
7
7
  push:
@@ -0,0 +1,26 @@
1
+ # All hooks run through `uv run`, so versions come from the lockfile - one
2
+ # source of truth shared by pre-commit, CI and dev, nothing to drift.
3
+ # Install once with: uvx pre-commit install
4
+ repos:
5
+ - repo: local
6
+ hooks:
7
+ - id: ruff-check
8
+ name: ruff check
9
+ entry: uv run ruff check --force-exclude
10
+ language: system
11
+ types_or: [python, pyi]
12
+ require_serial: true
13
+
14
+ - id: ruff-format
15
+ name: ruff format
16
+ entry: uv run ruff format --check --force-exclude
17
+ language: system
18
+ types_or: [python, pyi]
19
+ require_serial: true
20
+
21
+ - id: ty
22
+ name: ty type check
23
+ entry: uv run ty check
24
+ language: system
25
+ pass_filenames: false
26
+ types: [python]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toro-queue
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: An async-first, Redis-backed job queue for Python.
5
5
  Project-URL: Homepage, https://github.com/ilovepixelart/toro
6
6
  Project-URL: Repository, https://github.com/ilovepixelart/toro
@@ -55,12 +55,12 @@ Pairs with **[matador](https://github.com/ilovepixelart/matador)**, a live web d
55
55
 
56
56
  ## Why toro
57
57
 
58
- - **Async-native.** Enqueue and process with `async`/`await` no thread pools,
58
+ - **Async-native.** Enqueue and process with `async`/`await` - no thread pools,
59
59
  no sync bridge. A natural fit for FastAPI, aiohttp, or any asyncio app.
60
60
  - **Atomic by construction.** Claims, retries, promotions and finishes are Lua
61
61
  scripts, so a job can't be lost or double-committed between two round trips.
62
62
  - **At-least-once delivery.** Per-job locks + a background mark-and-sweep recover
63
- jobs from workers that crashed without the visibility-timeout double-delivery
63
+ jobs from workers that crashed - without the visibility-timeout double-delivery
64
64
  trap of some other queues.
65
65
  - **Typed.** Ships `py.typed`; the public API is fully annotated.
66
66
 
@@ -77,7 +77,7 @@ Pairs with **[matador](https://github.com/ilovepixelart/matador)**, a live web d
77
77
  | **Reliability** | per-job locks, lock renewal, stalled-job recovery |
78
78
  | **Observability** | progress, per-job logs, lifecycle events, `await result()` |
79
79
  | **Lifecycle** | pause / resume, graceful shutdown that drains in-flight jobs |
80
- | **Dashboard** | [matador](https://github.com/ilovepixelart/matador) a live web UI |
80
+ | **Dashboard** | [matador](https://github.com/ilovepixelart/matador) - a live web UI |
81
81
 
82
82
  ## Quick start
83
83
 
@@ -134,7 +134,7 @@ uv run pytest -m "unit or integration" # tests (integration needs Redis on :63
134
134
  uv run python examples/basic.py
135
135
  ```
136
136
 
137
- The suite is a pyramid `-m unit` (fast, no Redis), `-m integration` (Redis),
137
+ The suite is a pyramid - `-m unit` (fast, no Redis), `-m integration` (Redis),
138
138
  and `-m load` (the open-loop benchmark harness in `tests/load/`).
139
139
 
140
140
  ## License
@@ -28,12 +28,12 @@ Pairs with **[matador](https://github.com/ilovepixelart/matador)**, a live web d
28
28
 
29
29
  ## Why toro
30
30
 
31
- - **Async-native.** Enqueue and process with `async`/`await` no thread pools,
31
+ - **Async-native.** Enqueue and process with `async`/`await` - no thread pools,
32
32
  no sync bridge. A natural fit for FastAPI, aiohttp, or any asyncio app.
33
33
  - **Atomic by construction.** Claims, retries, promotions and finishes are Lua
34
34
  scripts, so a job can't be lost or double-committed between two round trips.
35
35
  - **At-least-once delivery.** Per-job locks + a background mark-and-sweep recover
36
- jobs from workers that crashed without the visibility-timeout double-delivery
36
+ jobs from workers that crashed - without the visibility-timeout double-delivery
37
37
  trap of some other queues.
38
38
  - **Typed.** Ships `py.typed`; the public API is fully annotated.
39
39
 
@@ -50,7 +50,7 @@ Pairs with **[matador](https://github.com/ilovepixelart/matador)**, a live web d
50
50
  | **Reliability** | per-job locks, lock renewal, stalled-job recovery |
51
51
  | **Observability** | progress, per-job logs, lifecycle events, `await result()` |
52
52
  | **Lifecycle** | pause / resume, graceful shutdown that drains in-flight jobs |
53
- | **Dashboard** | [matador](https://github.com/ilovepixelart/matador) a live web UI |
53
+ | **Dashboard** | [matador](https://github.com/ilovepixelart/matador) - a live web UI |
54
54
 
55
55
  ## Quick start
56
56
 
@@ -107,7 +107,7 @@ uv run pytest -m "unit or integration" # tests (integration needs Redis on :63
107
107
  uv run python examples/basic.py
108
108
  ```
109
109
 
110
- The suite is a pyramid `-m unit` (fast, no Redis), `-m integration` (Redis),
110
+ The suite is a pyramid - `-m unit` (fast, no Redis), `-m integration` (Redis),
111
111
  and `-m load` (the open-loop benchmark harness in `tests/load/`).
112
112
 
113
113
  ## License
@@ -12,9 +12,9 @@ Every state move (`wait→active`, `active→completed/failed/delayed`,
12
12
  `delayed→wait`) is a single Redis Lua script, run atomically, so multi-key
13
13
  "check-then-act" sequences can't interleave. That removes whole classes of race:
14
14
 
15
- - **pop-then-lock gap** two workers claiming the same job: the claim pops from
15
+ - **pop-then-lock gap** - two workers claiming the same job: the claim pops from
16
16
  the priority set and sets the lock inside one script.
17
- - **finish-after-steal** a worker committing a result for a job a stalled sweep
17
+ - **finish-after-steal** - a worker committing a result for a job a stalled sweep
18
18
  already re-queued: guarded by a token check plus `LREM active` returning 0.
19
19
 
20
20
  Scripts live in `scripts.py`, registered with `redis.asyncio`'s `register_script`.
@@ -23,7 +23,7 @@ The Python side only assembles KEYS/ARGV; the guarantees live in the Lua.
23
23
  ## Claiming a job: the prioritized set + a wakeup marker
24
24
 
25
25
  All waiting jobs live in one `prioritized` ZSET, scored
26
- `(PRIORITY_OFFSET - priority) * 2^32 + seq` a single global order where higher
26
+ `(PRIORITY_OFFSET - priority) * 2^32 + seq` - a single global order where higher
27
27
  priority is more urgent and ties stay FIFO (`seq` is a per-queue counter). This
28
28
  *is* the `wait` state; there is no separate fast-lane list, so a low-priority job
29
29
  can't starve a high-priority one.
@@ -74,19 +74,19 @@ due jobs into the prioritized set.
74
74
 
75
75
  ## Higher-level features
76
76
 
77
- - **Priorities** every job is in the one prioritized ZSET above, so priority is
77
+ - **Priorities** - every job is in the one prioritized ZSET above, so priority is
78
78
  a single global order with no starvation, FIFO within a band.
79
- - **Repeatable / cron** `add_scheduler(every=ms | cron=...)` stores a template
79
+ - **Repeatable / cron** - `add_scheduler(every=ms | cron=...)` stores a template
80
80
  and enqueues the first occurrence as a delayed job; each occurrence mints its
81
81
  successor with a deterministic id when a worker picks it up. `trigger_scheduler`
82
82
  runs one now, `remove_scheduler` stops the chain. See [Scheduling](scheduling.md).
83
- - **Rate limiting** a queue-wide token bucket in Redis
83
+ - **Rate limiting** - a queue-wide token bucket in Redis
84
84
  (`Worker(rate_limit={"max": N, "duration": ms})`), shared by every worker on the
85
85
  queue. An over-limit claim returns a sentinel and the worker waits out the window.
86
- - **Events** Redis pub/sub on an `events` channel (`added`, `progress`,
86
+ - **Events** - Redis pub/sub on an `events` channel (`added`, `progress`,
87
87
  `completed`, `failed`); `Queue.result()` awaits the terminal event and
88
88
  `Worker.on(event, fn)` exposes in-process hooks. See [Concepts](concepts.md).
89
- - **Auto-removal** `remove_on_complete` / `remove_on_fail` (bool / count /
89
+ - **Auto-removal** - `remove_on_complete` / `remove_on_fail` (bool / count /
90
90
  `{count, age}`) enforced inside the finish script, not by a separate sweeper.
91
91
 
92
92
  ## The Lua scripts
@@ -123,21 +123,21 @@ And the scripts themselves:
123
123
 
124
124
  Scripts signal outcomes with sentinels the worker decodes:
125
125
 
126
- - `RL_SENTINEL` (`"__rl__"`) a claim hit the rate limiter; the second value is
126
+ - `RL_SENTINEL` (`"__rl__"`) - a claim hit the rate limiter; the second value is
127
127
  ms until a token frees, so the worker waits instead of busy-spinning.
128
- - `LOCK_LOST` (`-2`) a finish ran but the worker no longer held the lock (the
128
+ - `LOCK_LOST` (`-2`) - a finish ran but the worker no longer held the lock (the
129
129
  job was reclaimed); the result is dropped.
130
- - `NOT_ACTIVE` (`-3`) a finish ran but the job was no longer in `active`.
131
- - `OUTCOME_FAILED` (`1`) vs `0` `MOVE_TO_FAILED` telling the worker whether the
130
+ - `NOT_ACTIVE` (`-3`) - a finish ran but the job was no longer in `active`.
131
+ - `OUTCOME_FAILED` (`1`) vs `0` - `MOVE_TO_FAILED` telling the worker whether the
132
132
  job terminally failed or will retry.
133
133
 
134
134
  Scores are packed under 2^53 (`PRIORITY_OFFSET = 2^20`, `SEQ_MOD = 2^32`) so ZSET
135
- double scores stay exact, and the scripts use only plain JSON and integer ARGV
136
- no `cmsgpack` / `bit` / `cjson` so they run on any Redis build.
135
+ double scores stay exact, and the scripts use only plain JSON and integer ARGV -
136
+ no `cmsgpack` / `bit` / `cjson` - so they run on any Redis build.
137
137
 
138
138
  ## Python-specific choices
139
139
 
140
- - **async-first** `redis.asyncio`, `async def` processors, one event loop;
140
+ - **async-first** - `redis.asyncio`, `async def` processors, one event loop;
141
141
  concurrency is N `asyncio` tasks sharing the loop.
142
- - **Cluster** a `{braces}` hash-tag in the prefix keeps all of a queue's keys on
142
+ - **Cluster** - a `{braces}` hash-tag in the prefix keeps all of a queue's keys on
143
143
  one slot, which the multi-key Lua scripts require.
@@ -20,7 +20,7 @@ toro has a clean producer/consumer split, and both talk to the same Redis.
20
20
  bookkeeping the system fills in: `state`, `attempts_made`, timestamps
21
21
  (`timestamp`, `processed_on`, `finished_on`), `progress`, `stacktrace`, and
22
22
  either a `returnvalue` or a `failed_reason`. (A job's log lines and its lock
23
- live in separate Redis keys, not as fields on the `Job` see the
23
+ live in separate Redis keys, not as fields on the `Job` - see the
24
24
  [data model](data-model.md).)
25
25
 
26
26
  Producers and consumers never call each other. They coordinate only through
@@ -91,7 +91,7 @@ not on a retry. Two things consume the channel:
91
91
 
92
92
  `Worker.on(event, fn)` lets a worker react to its own lifecycle with in-process
93
93
  callbacks (`completed`, `failed`, `retrying`, `stalled`, `lock-lost`,
94
- `rate-limited`) separate from the pub/sub channel above. See
94
+ `rate-limited`) - separate from the pub/sub channel above. See
95
95
  [Processing jobs](processing.md).
96
96
 
97
97
  ## Reliability in one sentence
@@ -0,0 +1,22 @@
1
+ # toro documentation
2
+
3
+ Reference docs for how toro works. The [README](../README.md) is the quick start.
4
+
5
+ ## Pages
6
+
7
+ - **[Concepts](concepts.md)** - the mental model: queues, workers, jobs, the five
8
+ job states, and the difference between *workers* and *slots*.
9
+ - **[Data model](data-model.md)** - the exact Redis keys a queue uses and what
10
+ each one stores.
11
+ - **[Reliability](reliability.md)** - the at-least-once guarantee: per-job locks,
12
+ worker tokens, and stalled-job recovery.
13
+ - **[Producing jobs](producing.md)** - `Queue.add()` and every option (priority,
14
+ delay, retries/backoff, deduplication, custom ids).
15
+ - **[Processing jobs](processing.md)** - `Worker`: concurrency, lifecycle events,
16
+ rate limiting, and graceful shutdown.
17
+ - **[Scheduling](scheduling.md)** - repeatable and cron jobs, and how each
18
+ occurrence schedules the next.
19
+ - **[Architecture](architecture.md)** - the atomic-Lua core and the design
20
+ decisions behind the queue.
21
+ - **[Security](security.md)** - what toro guarantees (JSON-only, no dynamic
22
+ dispatch, no string-built commands) and what you own (Redis access, secrets).
@@ -16,7 +16,7 @@ await worker.run() # awaits until stop()
16
16
 
17
17
  The processor is an `async` function of one argument, the `Job`. Returning
18
18
  commits the job as `completed` (the return value, JSON-serialized, becomes
19
- `returnvalue`); raising routes it through the retry policy back to the queue
19
+ `returnvalue`); raising routes it through the retry policy - back to the queue
20
20
  (or to `delayed` under [backoff](producing.md)) while attempts remain, then
21
21
  terminally `failed` with the exception text and a `stacktrace` field.
22
22
 
@@ -37,7 +37,7 @@ async def handle(job):
37
37
  ## Concurrency
38
38
 
39
39
  `concurrency=N` runs N processing loops ("slots") as `asyncio` tasks on one
40
- event loop see [workers vs. slots](concepts.md). Two practical consequences:
40
+ event loop - see [workers vs. slots](concepts.md). Two practical consequences:
41
41
 
42
42
  - **Stay `await`-y.** Slots are not threads: CPU-bound or blocking code stalls
43
43
  every sibling slot *and* the lock renewers that keep your jobs from being
@@ -47,7 +47,7 @@ event loop — see [workers vs. slots](concepts.md). Two practical consequences:
47
47
  you pass your own `connection`, size its pool accordingly.
48
48
 
49
49
  A busy slot doesn't return to the blocking wait between jobs: the finish call
50
- also claims the next job in the same round trip (fetch-next see
50
+ also claims the next job in the same round trip (fetch-next - see
51
51
  [Architecture](architecture.md)), so a saturated worker runs at one round trip
52
52
  per job.
53
53
 
@@ -56,10 +56,10 @@ per job.
56
56
  | Option | Default | Meaning |
57
57
  |---|---|---|
58
58
  | `concurrency` | 1 | Parallel slots in this worker. |
59
- | `rate_limit` | `None` | `{"max": N, "duration": ms}` queue-wide token bucket (below). |
59
+ | `rate_limit` | `None` | `{"max": N, "duration": ms}` - queue-wide token bucket (below). |
60
60
  | `block_timeout` | 5.0 s | How long an idle slot blocks waiting for a wakeup before re-checking. |
61
- | `lock_duration` / `lock_renew_time` / `renew_locks` | 30000 / half / `True` | The at-least-once lease see [Reliability](reliability.md). |
62
- | `stalled_interval` / `max_stalled_count` | 30000 / 1 | The recovery sweep same page. |
61
+ | `lock_duration` / `lock_renew_time` / `renew_locks` | 30000 / half / `True` | The at-least-once lease - see [Reliability](reliability.md). |
62
+ | `stalled_interval` / `max_stalled_count` | 30000 / 1 | The recovery sweep - same page. |
63
63
  | `grace_period` | 30.0 s | Default drain window for `stop()`. |
64
64
  | `heartbeat_interval` | 5000 ms | Presence cadence for the workers view. |
65
65
 
@@ -69,7 +69,7 @@ per job.
69
69
  worker = Worker("emails", handle, rate_limit={"max": 100, "duration": 60_000})
70
70
  ```
71
71
 
72
- At most `max` jobs start per `duration`, across **all** workers on the queue
72
+ At most `max` jobs start per `duration`, across **all** workers on the queue -
73
73
  the token bucket lives in Redis, shared, so adding workers doesn't multiply the
74
74
  limit (give every worker the same config). When a claim hits the limit the job
75
75
  goes back untouched: no attempt is consumed, and the worker sleeps until a token
@@ -82,14 +82,14 @@ frees (emitting a `rate-limited` event with the wait).
82
82
  | Event | Args | When |
83
83
  |---|---|---|
84
84
  | `completed` | `job, result` | A job committed successfully. |
85
- | `failed` | `job, exc` | A job failed terminally. The sweep fires it too for stall-failed jobs there with the job *id* (not a `Job`) and a `RuntimeError("job stalled too many times")`, while the job hash's `failedReason` reads `"job stalled more than allowable limit"`. |
85
+ | `failed` | `job, exc` | A job failed terminally. The sweep fires it too for stall-failed jobs - there with the job *id* (not a `Job`) and a `RuntimeError("job stalled too many times")`, while the job hash's `failedReason` reads `"job stalled more than allowable limit"`. |
86
86
  | `retrying` | `job, exc` | A failure with attempts left was re-queued. |
87
87
  | `stalled` | `job_id` | The sweep recovered one of this queue's jobs. |
88
88
  | `lock-lost` | `job_id` | This worker's lock was taken over; its result was dropped. |
89
89
  | `rate-limited` | `retry_ms` | A claim hit the rate limit. |
90
90
 
91
91
  These are this worker's own hooks. Cross-process consumers (dashboards,
92
- `result()`) use the pub/sub events channel instead see [Concepts](concepts.md).
92
+ `result()`) use the pub/sub events channel instead - see [Concepts](concepts.md).
93
93
 
94
94
  ## Presence
95
95
 
@@ -97,7 +97,7 @@ Every `heartbeat_interval` the worker flushes a presence record (host, pid,
97
97
  concurrency, what it's running, processed/failed counts, state). That powers the
98
98
  dashboard's workers view; a worker that misses heartbeats long enough is pruned
99
99
  and logged as a `lost` departure, while `stop()` flips it to a visible
100
- `stopping` state first and logs `stopped` so the dashboard can tell a drain
100
+ `stopping` state first and logs `stopped` - so the dashboard can tell a drain
101
101
  from a crash.
102
102
 
103
103
  ## Shutdown
@@ -108,5 +108,5 @@ await worker.stop() # or stop(grace_period=10)
108
108
 
109
109
  `stop()` stops claiming new jobs, lets in-flight jobs finish for up to the grace
110
110
  period, cancels whatever remains (those jobs' locks expire and the sweep
111
- recovers them nothing is lost), deregisters presence, and closes the
111
+ recovers them - nothing is lost), deregisters presence, and closes the
112
112
  connection. Pair `run()`/`stop()` with your framework's startup/shutdown hooks.
@@ -11,7 +11,7 @@ job = await queue.add("welcome", {"user_id": 42})
11
11
  ```
12
12
 
13
13
  `add(name, data=None, *, job_id=None, deduplication=None, **options)` writes the
14
- job hash and enqueues (or delays) it in one atomic script the `added` event is
14
+ job hash and enqueues (or delays) it in one atomic script - the `added` event is
15
15
  published from inside that script, so an enqueue is a single round trip and the
16
16
  event can't be lost between the two.
17
17
 
@@ -35,19 +35,19 @@ Per-queue defaults go on the constructor and merge under per-call options:
35
35
  queue = Queue("emails", default_job_options={"remove_on_complete": 1000, "attempts": 3})
36
36
  ```
37
37
 
38
- Auto-removal is enforced inside the finish script itself there is no separate
38
+ Auto-removal is enforced inside the finish script itself - there is no separate
39
39
  cleanup process to run or forget.
40
40
 
41
41
  ## Custom ids and deduplication
42
42
 
43
43
  Two distinct tools, usable independently:
44
44
 
45
- - **`job_id="order-123"`** id-based dedup. Adding a job whose id already
45
+ - **`job_id="order-123"`** - id-based dedup. Adding a job whose id already
46
46
  exists is idempotent: nothing is enqueued and the existing job's id comes
47
47
  back. The id frees up when the job is removed (including by auto-removal).
48
- Must be a non-empty, non-all-digits string all-digit ids would collide with
48
+ Must be a non-empty, non-all-digits string - all-digit ids would collide with
49
49
  auto-generated ones.
50
- - **`deduplication={"id": "sync-user-42", "ttl": 60_000}`** a throttle window.
50
+ - **`deduplication={"id": "sync-user-42", "ttl": 60_000}`** - a throttle window.
51
51
  While the ttl lives, repeat adds with the same dedup id are ignored and the
52
52
  already-queued job's id is returned. Self-expiring; nothing to clean up at
53
53
  finish time.
@@ -65,7 +65,7 @@ value = await job.result(timeout=30) # or queue.result(job.id)
65
65
  `result()` resolves with the processor's return value, raises `JobFailedError`
66
66
  on terminal failure, or `TimeoutError` after `timeout`. It registers for the
67
67
  job's events *before* checking state, so a job that finishes while you wait is
68
- never missed and it works even when the job hash was auto-removed, as long as
68
+ never missed - and it works even when the job hash was auto-removed, as long as
69
69
  `result()` was awaited before the job finished. A retrying job keeps you
70
70
  waiting; only the terminal outcome resolves the call.
71
71
 
@@ -77,9 +77,12 @@ waiting; only the terminal outcome resolves the call.
77
77
  | `await queue.get_job(job_id)` | A `Job` snapshot, or `None`. |
78
78
  | `await queue.get_jobs(state, start, end)` | A page of jobs; `wait` comes back in global priority order, finished states newest-first. |
79
79
  | `await queue.get_logs(job_id)` | Log lines appended by the processor. |
80
- | `await queue.search(state, query, scan_limit=500)` | Substring match over `name`/`data` within the most recent `scan_limit` jobs of a state. A bounded scan, not an index surface the bound honestly in UIs. |
80
+ | `await queue.search(state, query, scan_limit=500)` | Substring match over `name`/`data` within the most recent `scan_limit` jobs of a state. A bounded scan, not an index - surface the bound honestly in UIs. |
81
81
  | `await queue.workers()` | Live workers from their heartbeats; stale entries are pruned (and logged as `lost`) on read. |
82
82
  | `await queue.departed_workers()` | Recent departures, newest first: graceful `stopped` or crashed `lost`. |
83
+ | `await queue.metrics(minutes=60)` | Per-minute `{timestamp, added, completed, failed, ms}` points, oldest first, zero-filled for charting. Counters are written inside the same atomic scripts as the transitions (a count can never disagree with the state change it counts); `added` counts real inserts (dedup hits and id replays don't count), `failed` means terminal failures - retries don't count, stall-failures do. Buckets expire after 8 hours. |
84
+ | `await queue.metrics_by_name(minutes=60)` | Per-job-name `{name, completed, failed, ms}` totals over the window, failures first - the triage order ("which job is responsible"), not the volume order. |
85
+ | `await queue.latency()` | Age (ms) of the next-to-run waiting job, `0` when nothing waits. Depth says how much is queued; latency says how far behind the workers are. |
83
86
 
84
87
  ## Admin operations
85
88
 
@@ -93,7 +96,7 @@ waiting; only the terminal outcome resolves the call.
93
96
  | `await queue.pause()` / `resume()` / `is_paused()` | Stop workers claiming new jobs (in-flight jobs finish); resume wakes idle workers. |
94
97
 
95
98
  These are the operations a dashboard such as
96
- [matador](https://github.com/ilovepixelart/matador) calls under its buttons
99
+ [matador](https://github.com/ilovepixelart/matador) calls under its buttons -
97
100
  they're ordinary public API.
98
101
 
99
102
  ## Lifecycle
@@ -19,7 +19,7 @@ is exactly what turns a dead worker's jobs back into runnable ones.
19
19
 
20
20
  While the job runs, a per-job **renewer** task extends the lock every
21
21
  `lock_renew_time` (default `lock_duration / 2`) and clears the job from the
22
- `stalled` candidate set. Renewal is token-guarded a worker can never renew a
22
+ `stalled` candidate set. Renewal is token-guarded - a worker can never renew a
23
23
  lock another worker has since taken over. If a renewal finds the token gone, the
24
24
  worker emits `lock-lost` and stops touching the job.
25
25
 
@@ -39,7 +39,7 @@ per interval, not once per worker:
39
39
  becoming a candidate for the *next* sweep.
40
40
 
41
41
  A healthy job is marked, then unmarked by its renewer before the next sweep ever
42
- sees it. Only a job whose worker stopped renewing i.e. died stays marked
42
+ sees it. Only a job whose worker stopped renewing - i.e. died - stays marked
43
43
  with an expired lock long enough to be recovered. The whole pass is one Lua
44
44
  script, so recovery can't race a finish.
45
45
 
@@ -48,9 +48,9 @@ script, so recovery can't race a finish.
48
48
  The handler may run more than once; the *result* is committed exactly once. The
49
49
  finish scripts (`MOVE_TO_COMPLETED` / `MOVE_TO_FAILED`) begin with two guards:
50
50
 
51
- - the lock must still hold **this worker's token** otherwise the script
51
+ - the lock must still hold **this worker's token** - otherwise the script
52
52
  returns `LOCK_LOST` (-2) and commits nothing;
53
- - the job must still be in `active` otherwise `NOT_ACTIVE` (-3), same result.
53
+ - the job must still be in `active` - otherwise `NOT_ACTIVE` (-3), same result.
54
54
 
55
55
  So when a slow worker comes back from the dead after its job was recovered and
56
56
  re-run elsewhere, its late finish is dropped on the floor, with a `lock-lost`
@@ -75,10 +75,10 @@ are bounded by `max_stalled_count`.
75
75
 
76
76
  Two different counters bound two different failure modes:
77
77
 
78
- - `attempts_made` vs `attempts` *your code failed*: the processor raised.
78
+ - `attempts_made` vs `attempts` - *your code failed*: the processor raised.
79
79
  Decided at finish time; retries re-enqueue (with [backoff](producing.md) if
80
80
  configured) until attempts run out, then the job fails with your exception.
81
- - `stalledCounter` vs `max_stalled_count` *the worker failed*: nobody renewed
81
+ - `stalledCounter` vs `max_stalled_count` - *the worker failed*: nobody renewed
82
82
  the lock. Decided by the sweep; bounds how many times an apparently
83
83
  worker-killing job is allowed to take a worker down with it.
84
84
 
@@ -86,7 +86,7 @@ Two different counters bound two different failure modes:
86
86
 
87
87
  | Worker option | Default | Meaning |
88
88
  |---|---|---|
89
- | `lock_duration` | 30000 ms | Lock lease length. Must comfortably exceed your event-loop stalls, not your job length the renewer handles long jobs. |
89
+ | `lock_duration` | 30000 ms | Lock lease length. Must comfortably exceed your event-loop stalls, not your job length - the renewer handles long jobs. |
90
90
  | `lock_renew_time` | `lock_duration / 2` | Renewal cadence. |
91
91
  | `renew_locks` | `True` | Disable only to *test* stalled recovery. |
92
92
  | `stalled_interval` | 30000 ms | Sweep cadence; `0` disables the sweep entirely. |
@@ -23,12 +23,12 @@ every occurrence.
23
23
 
24
24
  ## The two cadences
25
25
 
26
- - **`every=ms`** slot-aligned to the interval grid: the next run is the next
26
+ - **`every=ms`** - slot-aligned to the interval grid: the next run is the next
27
27
  multiple of `every` on the wall clock, not "last run + interval". Successive
28
28
  runs don't drift, and a late tick (worker down for a while) catches up to the
29
29
  *next* slot instead of firing a backlog burst.
30
- - **`cron="*/5 * * * *"`** a standard cron expression, evaluated in **UTC**
31
- via [croniter](https://pypi.org/project/croniter/) an optional dependency
30
+ - **`cron="*/5 * * * *"`** - a standard cron expression, evaluated in **UTC**
31
+ via [croniter](https://pypi.org/project/croniter/) - an optional dependency
32
32
  (`pip install croniter`). Expressions are validated at `add_scheduler` time,
33
33
  so a typo fails at registration, not silently inside a worker later.
34
34
 
@@ -41,13 +41,13 @@ id:
41
41
  repeat:<schedulerId>:<dueMillis>
42
42
  ```
43
43
 
44
- That id makes enqueueing idempotent the same occurrence can never exist twice,
44
+ That id makes enqueueing idempotent - the same occurrence can never exist twice,
45
45
  no matter how many workers or producers race to create it.
46
46
 
47
47
  The chain sustains itself: when a worker *first picks up* an occurrence, it
48
48
  mints the successor before running the handler. The schedule therefore stays on
49
49
  time regardless of how long the run takes, whether it fails, or how many retries
50
- follow retries re-run *that occurrence*, they never shift the cadence. If the
50
+ follow - retries re-run *that occurrence*, they never shift the cadence. If the
51
51
  scheduler was removed in the meantime, no successor is minted and the chain
52
52
  ends.
53
53
 
@@ -66,6 +66,6 @@ ends.
66
66
  and re-run ([Reliability](reliability.md)). Make handlers idempotent.
67
67
  - **No catch-up replay.** If every worker is down across several due times, the
68
68
  pending occurrence is promoted late and the next one lands on the current
69
- grid/cron slot you get *one* late run, not a burst of missed ones.
69
+ grid/cron slot - you get *one* late run, not a burst of missed ones.
70
70
  - **One pending occurrence per schedule** exists at a time (the deterministic id
71
71
  guarantees it), so a misbehaving schedule can't flood the queue.
@@ -0,0 +1,38 @@
1
+ # Security
2
+
3
+ toro's security model in one sentence: **whoever can reach your Redis can do
4
+ anything your queue can do** - so secure the Redis, and toro takes care of not
5
+ amplifying that access.
6
+
7
+ ## What toro guarantees
8
+
9
+ - **JSON-only serialization.** Job data, options, results and events are
10
+ `json` in and out - never pickle. A compromised or shared Redis cannot
11
+ achieve code execution through toro's deserialization.
12
+ - **No dynamic dispatch from Redis.** A job's `name` is a label, not an import
13
+ path. The processor function is registered in your worker's code; nothing
14
+ read from Redis decides what code runs.
15
+ - **No string-built commands.** Every state transition is a Lua script taking
16
+ ids and payloads as arguments (`KEYS[]`/`ARGV[]`), and every published event
17
+ is one `cjson.encode` document - there is no string interpolation into
18
+ commands or event JSON anywhere.
19
+ - **Key-safe identifiers.** Custom job ids, scheduler ids and deduplication
20
+ ids are validated (no `:`, no control characters) so two logically distinct
21
+ ids can never collide into one Redis key.
22
+
23
+ ## What you own
24
+
25
+ - **Redis access control.** Use a password (`requirepass`/ACLs), network
26
+ isolation, and `rediss://` URLs with a trusted CA for anything that crosses
27
+ a network boundary. For custom TLS options, build the connection yourself
28
+ and pass it as `connection=`.
29
+ - **Secrets in job payloads and exceptions.** Payloads, return values, failure
30
+ reasons and stack traces are stored in Redis and visible to anything that
31
+ can read it (including dashboards). Don't put credentials in `data`, and
32
+ don't interpolate secrets into exception messages.
33
+ - **Payload discipline.** toro does not enforce a payload size limit. Large
34
+ payloads cost memory in every worker that touches them - store big data
35
+ elsewhere (object storage) and enqueue a reference.
36
+ - **Validate what you process.** Arriving as JSON doesn't make `job.data`
37
+ trustworthy if multiple producers share the queue; validate shape and
38
+ ranges in the handler like you would any external input.
@@ -0,0 +1,19 @@
1
+ # Examples
2
+
3
+ Runnable, self-contained demos of toro. Each needs a Redis on `localhost:6379`:
4
+
5
+ ```bash
6
+ docker run --rm -p 6379:6379 redis:7-alpine # or any local Redis
7
+ ```
8
+
9
+ Then run from the repo root:
10
+
11
+ | Example | What it shows |
12
+ |---|---|
13
+ | [`basic.py`](basic.py) | The end-to-end loop - enqueue jobs (including a delayed one and a flaky one with `attempts` + exponential backoff), process them with a `concurrency=4` worker, and react to `completed` / `retrying` / `failed` events. |
14
+ | [`stalled.py`](stalled.py) | Crash recovery - a "zombie" worker grabs a job and hangs; a healthy worker detects the stalled job, requeues it, and completes it **exactly once**. The zombie's late finish is rejected by its lock token. |
15
+
16
+ ```bash
17
+ python examples/basic.py
18
+ python examples/stalled.py
19
+ ```
@@ -1,4 +1,4 @@
1
- """Demo: a worker dies mid-job and another recovers it exactly one completion.
1
+ """Demo: a worker dies mid-job and another recovers it - exactly one completion.
2
2
 
3
3
  Run a Redis on localhost:6379, then: python examples/stalled.py
4
4
  """
@@ -36,7 +36,7 @@ async def main():
36
36
  healthy.on("completed", lambda j, r: print(f" ✓ #{j.id} completed by {r['by']}"))
37
37
  zombie.on(
38
38
  "lock-lost",
39
- lambda jid: print(f" [zombie] woke up, but #{jid} was taken finish rejected"),
39
+ lambda jid: print(f" [zombie] woke up, but #{jid} was taken - finish rejected"),
40
40
  )
41
41
 
42
42
  zt = asyncio.create_task(zombie.run())
@@ -1,8 +1,8 @@
1
1
  [project]
2
- # PyPI distribution name plain `toro` is taken (abandoned). The import package
2
+ # PyPI distribution name - plain `toro` is taken (abandoned). The import package
3
3
  # stays `toro` (e.g. `pip install toro-queue` then `import toro`).
4
4
  name = "toro-queue"
5
- version = "0.2.0"
5
+ version = "0.3.0"
6
6
  description = "An async-first, Redis-backed job queue for Python."
7
7
  readme = "README.md"
8
8
  requires-python = ">=3.10"
@@ -66,13 +66,13 @@ ignore = [
66
66
  "COM812", "ISC001", # conflict with the formatter (per Ruff docs)
67
67
  "EM101", "EM102", "TRY003", # inline exception messages read fine in this codebase
68
68
  "TRY300", # `else`-after-return reads worse than an early return here
69
- "D203", "D213", "D205", # docstring whitespace/placement niceties not worth churn
69
+ "D203", "D213", "D205", # docstring whitespace/placement niceties - not worth churn
70
70
  "D100", "D104", "D105", "D107", # don't require docstrings on modules/packages/dunder/__init__
71
71
  "ANN401", # `Any` is intentional at the Redis / JSON boundary
72
72
  "PLR2004", # small internal magic-number comparisons are clear in context
73
73
  "FBT001", "FBT002", # boolean flags read fine in this API surface
74
74
  "D102", # docstrings on every public method is too granular here
75
- "PLR0913", # Worker/Queue are config constructors many kwargs is fine
75
+ "PLR0913", # Worker/Queue are config constructors - many kwargs is fine
76
76
  "BLE001", "S110", "S112", # deliberate best-effort excepts in worker/redis cleanup paths
77
77
  "TC001", "TC002", "TC003", # prefer normal runtime imports over TYPE_CHECKING blocks
78
78
  ]
@@ -94,7 +94,7 @@ asyncio_mode = "auto"
94
94
  # `-m integration` (needs Redis), or `-m load` (slow). conftest auto-marks by folder.
95
95
  addopts = "--strict-markers --import-mode=importlib"
96
96
  markers = [
97
- "unit: pure-logic tests, no I/O fast and deterministic",
97
+ "unit: pure-logic tests, no I/O - fast and deterministic",
98
98
  "integration: Redis-backed tests of real state transitions",
99
99
  "load: throughput / scalability tests (slow; opt-in)",
100
100
  ]
@@ -109,14 +109,14 @@ async def _running_worker(queue: Queue, processor, **kw):
109
109
 
110
110
  @pytest.fixture
111
111
  def run_worker():
112
- """`async with run_worker(q, processor, concurrency=2) as w: ...` starts a
112
+ """`async with run_worker(q, processor, concurrency=2) as w: ...` - starts a
113
113
  worker and guarantees a clean shutdown on exit."""
114
114
  return _running_worker
115
115
 
116
116
 
117
117
  @pytest.fixture
118
118
  def run_until():
119
- """`await run_until(lambda: cond, timeout=2)` poll until true or time out.
119
+ """`await run_until(lambda: cond, timeout=2)` - poll until true or time out.
120
120
  Returns True if the condition held, False on timeout (assert on the result)."""
121
121
 
122
122
  async def _run_until(predicate, *, timeout: float = 5.0, interval: float = 0.02) -> bool:
@@ -1,4 +1,4 @@
1
- """Integration: admin/dashboard actions remove, promote, retry, clean, trigger.
1
+ """Integration: admin/dashboard actions - remove, promote, retry, clean, trigger.
2
2
 
3
3
  Each asserts the resulting state AND the negative case (acting on a missing job
4
4
  returns False rather than silently succeeding).
@@ -13,7 +13,7 @@ async def _count(q, state):
13
13
 
14
14
  @pytest.mark.parametrize("bad", ["", "a:b", "repeat:x", "ctrl\x01", "\n"])
15
15
  async def test_add_scheduler_rejects_unsafe_id(q, bad):
16
- # scheduler_id is a Redis key segment ':'/control chars enable key collisions
16
+ # scheduler_id is a Redis key segment - ':'/control chars enable key collisions
17
17
  with pytest.raises(ValueError, match="scheduler_id"):
18
18
  await q.add_scheduler(bad, cron="0 0 * * *")
19
19