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.
- {toro_queue-0.2.0 → toro_queue-0.3.0}/.github/workflows/pr-check.yaml +2 -2
- {toro_queue-0.2.0 → toro_queue-0.3.0}/.github/workflows/release.yml +1 -1
- toro_queue-0.3.0/.pre-commit-config.yaml +26 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/PKG-INFO +5 -5
- {toro_queue-0.2.0 → toro_queue-0.3.0}/README.md +4 -4
- {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/architecture.md +16 -16
- {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/concepts.md +2 -2
- toro_queue-0.3.0/docs/index.md +22 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/processing.md +11 -11
- {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/producing.md +11 -8
- {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/reliability.md +7 -7
- {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/scheduling.md +6 -6
- toro_queue-0.3.0/docs/security.md +38 -0
- toro_queue-0.3.0/examples/README.md +19 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/examples/stalled.py +2 -2
- {toro_queue-0.2.0 → toro_queue-0.3.0}/pyproject.toml +5 -5
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/conftest.py +2 -2
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_admin.py +2 -2
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_admin_ordering.py +4 -4
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_finished_retention.py +4 -4
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_introspection.py +1 -1
- toro_queue-0.3.0/tests/integration/test_metrics.py +323 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_processing.py +2 -2
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_reliability.py +27 -6
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_result_dispatcher.py +3 -3
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_retries.py +1 -1
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_worker_resilience.py +1 -1
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_workers.py +3 -3
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/harness.py +8 -8
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_active_list_cost.py +18 -6
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_admin_scaling.py +4 -4
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_enqueue_rtt.py +5 -3
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_load.py +7 -7
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_promote_blocking.py +4 -4
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_result_fanout.py +1 -1
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/load/test_worker_concurrency.py +3 -3
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/unit/test_backoff.py +1 -1
- toro_queue-0.3.0/tests/unit/test_histogram.py +64 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/unit/test_job.py +2 -2
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/unit/test_job_options.py +2 -2
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/unit/test_keys.py +1 -1
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/unit/test_scheduler.py +1 -1
- {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/__init__.py +5 -3
- {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/connection.py +1 -1
- {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/job.py +1 -1
- {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/keys.py +9 -3
- {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/queue.py +189 -20
- {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/scheduler.py +3 -3
- {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/scripts.py +79 -20
- {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/worker.py +15 -13
- {toro_queue-0.2.0 → toro_queue-0.3.0}/uv.lock +1 -1
- toro_queue-0.2.0/.pre-commit-config.yaml +0 -18
- toro_queue-0.2.0/docs/index.md +0 -20
- toro_queue-0.2.0/examples/README.md +0 -19
- {toro_queue-0.2.0 → toro_queue-0.3.0}/.gitignore +0 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/.vscode/extensions.json +0 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/.vscode/settings.json +0 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/LICENSE +0 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/bench/bench.py +0 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/docs/data-model.md +0 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/examples/basic.py +0 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_connection.py +0 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/integration/test_scheduler.py +0 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/tests/unit/test_priority.py +0 -0
- {toro_queue-0.2.0 → toro_queue-0.3.0}/toro/errors.py +0 -0
- {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
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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`
|
|
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
|
|
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)
|
|
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
|
|
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`
|
|
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
|
|
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)
|
|
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
|
|
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**
|
|
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**
|
|
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`
|
|
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**
|
|
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**
|
|
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**
|
|
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**
|
|
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**
|
|
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__"`)
|
|
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`)
|
|
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`)
|
|
131
|
-
- `OUTCOME_FAILED` (`1`) vs `0`
|
|
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`
|
|
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**
|
|
140
|
+
- **async-first** - `redis.asyncio`, `async def` processors, one event loop;
|
|
141
141
|
concurrency is N `asyncio` tasks sharing the loop.
|
|
142
|
-
- **Cluster**
|
|
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`
|
|
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`)
|
|
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
|
|
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
|
|
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
|
|
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}`
|
|
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
|
|
62
|
-
| `stalled_interval` / `max_stalled_count` | 30000 / 1 | The recovery sweep
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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"`**
|
|
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
|
|
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}`**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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**
|
|
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`
|
|
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`
|
|
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`
|
|
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
|
|
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`**
|
|
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 * * * *"`**
|
|
31
|
-
via [croniter](https://pypi.org/project/croniter/)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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: ...`
|
|
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)`
|
|
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
|
|
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
|
|
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
|
|