toro-queue 0.4.0__tar.gz → 0.5.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.4.0 → toro_queue-0.5.0}/.gitignore +2 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/PKG-INFO +1 -1
- {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/flows-design.md +11 -7
- {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/flows.md +7 -6
- {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/producing.md +1 -1
- {toro_queue-0.4.0 → toro_queue-0.5.0}/pyproject.toml +1 -1
- toro_queue-0.5.0/tests/integration/test_concurrency_stress.py +99 -0
- toro_queue-0.5.0/tests/integration/test_fault_injection.py +163 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_flows_edges.py +42 -19
- toro_queue-0.5.0/tests/integration/test_invariants.py +197 -0
- toro_queue-0.5.0/tests/integration/test_leaks.py +151 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/__init__.py +1 -1
- {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/queue.py +17 -4
- {toro_queue-0.4.0 → toro_queue-0.5.0}/uv.lock +1 -1
- {toro_queue-0.4.0 → toro_queue-0.5.0}/.github/workflows/pr-check.yaml +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/.github/workflows/release.yml +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/.pre-commit-config.yaml +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/.vscode/extensions.json +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/.vscode/settings.json +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/LICENSE +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/README.md +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/bench/bench.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/architecture.md +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/concepts.md +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/data-model.md +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/index.md +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/processing.md +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/reliability.md +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/scheduling.md +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/security.md +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/examples/README.md +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/examples/basic.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/examples/stalled.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/conftest.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_admin.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_admin_ordering.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_connection.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_finished_retention.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_flows.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_introspection.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_metrics.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_processing.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_reliability.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_result_dispatcher.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_retries.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_roots.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_scheduler.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_worker_resilience.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_workers.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/harness.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_active_list_cost.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_admin_scaling.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_enqueue_rtt.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_flows_load.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_load.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_promote_blocking.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_result_fanout.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_worker_concurrency.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_backoff.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_flow.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_histogram.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_job.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_job_options.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_keys.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_priority.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_scheduler.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/connection.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/errors.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/flow.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/job.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/keys.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/py.typed +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/scheduler.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/scripts.py +0 -0
- {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/worker.py +0 -0
|
@@ -108,7 +108,8 @@ There is deliberately **no "wait indefinitely" option** (lesson 3). Retries
|
|
|
108
108
|
still happen first: "terminally fails" means after the child's own `attempts`
|
|
109
109
|
are exhausted (or it stalls past `max_stalled_count`). A child retried to
|
|
110
110
|
success *after* its parent already failed does not resurrect the parent
|
|
111
|
-
(documented, v1); retrying the parent re-arms its barrier
|
|
111
|
+
(documented, v1); retrying the parent re-arms its barrier and pulls its
|
|
112
|
+
failed children along instead.
|
|
112
113
|
|
|
113
114
|
### Data model
|
|
114
115
|
|
|
@@ -165,16 +166,19 @@ A SET rather than a counter: it's idempotent under re-delivery, inspectable
|
|
|
165
166
|
parent's deps (and releases the parent if it was the last). Settle writes
|
|
166
167
|
are guarded by a parent-exists check so a retention-trimmed parent can't
|
|
167
168
|
get orphan keys recreated by late siblings.
|
|
168
|
-
- **Retry is flow-aware**: a failed parent with unsettled
|
|
169
|
-
`waiting-children
|
|
170
|
-
|
|
171
|
-
whole
|
|
169
|
+
- **Retry is flow-aware**: at the script level a failed parent with unsettled
|
|
170
|
+
deps re-parks in `waiting-children`, and a retried child re-joins a parked
|
|
171
|
+
parent's barrier (clearing its stale `:cfail` entry). On top of that,
|
|
172
|
+
`retry_job(parent)` re-drives the whole failed subtree (delegating to
|
|
173
|
+
`retry_flow`), so `retry_all_failed()` and a single parent retry both recover
|
|
174
|
+
a whole flow in any order.
|
|
172
175
|
|
|
173
176
|
### Edge cases pinned down (each has a test)
|
|
174
177
|
|
|
175
178
|
- **Parent retries**: a released parent is a normal job; its own
|
|
176
|
-
`attempts`/`backoff` apply.
|
|
177
|
-
|
|
179
|
+
`attempts`/`backoff` apply. `retry_job(parent)` re-drives the subtree's
|
|
180
|
+
*failed* children too (root-first); completed children are not re-run -
|
|
181
|
+
their results are already in `:results`.
|
|
178
182
|
- **`remove_on_complete` on children**: allowed - the result is copied into
|
|
179
183
|
the parent's `:results` at completion, so the child hash is free to go.
|
|
180
184
|
Routine `clean("completed")` likewise never touches a pending parent's
|
|
@@ -83,16 +83,17 @@ the parent - removal cascades the subtree.
|
|
|
83
83
|
|
|
84
84
|
Retry is flow-aware:
|
|
85
85
|
|
|
86
|
-
- Retrying a failed **parent** re-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
- Retrying a failed **parent** re-drives its whole failed subtree: it re-parks
|
|
87
|
+
on every non-completed child (completed children keep their collected results)
|
|
88
|
+
and re-queues the failed ones root-first, so the flow recovers in a single
|
|
89
|
+
call. It re-arms the barrier rather than running on partial results.
|
|
89
90
|
- Retrying a failed **child** re-joins its parked parent's barrier and clears
|
|
90
91
|
the stale entry from the parent's failure report.
|
|
91
92
|
|
|
92
93
|
So `retry_all_failed()` - or the dashboard's *retry all* - recovers an entire
|
|
93
|
-
failed flow in one shot, in any order.
|
|
94
|
-
already
|
|
95
|
-
|
|
94
|
+
failed flow in one shot, in any order. Retrying just a **child** of an
|
|
95
|
+
already-failed parent still won't resurrect the parent on its own; retry the
|
|
96
|
+
parent (which now pulls its failed children along) or use retry-all.
|
|
96
97
|
|
|
97
98
|
To recover *one* flow without touching the rest of the queue, use
|
|
98
99
|
`retry_flow(parent_id)`: it retries every failed job in that subtree, root
|
|
@@ -91,7 +91,7 @@ waiting; only the terminal outcome resolves the call.
|
|
|
91
91
|
|
|
92
92
|
| Call | Does |
|
|
93
93
|
|---|---|
|
|
94
|
-
| `await queue.retry_job(job_id)` | Move one failed job back to the queue. Flow-aware: a
|
|
94
|
+
| `await queue.retry_job(job_id)` | Move one failed job back to the queue. Flow-aware: retrying a flow parent re-drives its whole failed subtree (failed children pulled along, completed ones kept); a retried child re-joins its parked parent's barrier ([Flows](flows.md)). |
|
|
95
95
|
| `await queue.retry_all_failed(limit=1000)` | Re-queue every failed job (pipelined, one round trip per batch); returns how many were retried. |
|
|
96
96
|
| `await queue.promote_job(job_id)` | Run a delayed job now. |
|
|
97
97
|
| `await queue.remove_job(job_id)` | Delete a job from every state, with its lock, logs and flow keys. Removing a flow parent removes its whole subtree - children included, even running ones. |
|
|
@@ -2,7 +2,7 @@
|
|
|
2
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.5.0"
|
|
6
6
|
description = "An async-first, Redis-backed job queue for Python."
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Concurrency stress: many workers (each its own connection, like separate
|
|
2
|
+
processes) hammering one Redis must process every job EXACTLY once - the atomic
|
|
3
|
+
BLMOVE+Lua-lock claim is what makes running N processes against one queue safe -
|
|
4
|
+
and flows must still settle under that contention. The stalled sweep runs the
|
|
5
|
+
whole time on a short interval, so this also proves healthy jobs aren't falsely
|
|
6
|
+
recovered (which would double-process).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import collections
|
|
11
|
+
import contextlib
|
|
12
|
+
|
|
13
|
+
from toro import FlowChild as c # noqa: N813
|
|
14
|
+
from toro import Worker
|
|
15
|
+
|
|
16
|
+
PREFIX = "torotest"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _completed(q, n):
|
|
20
|
+
async def check():
|
|
21
|
+
return (await q.counts())["completed"] >= n
|
|
22
|
+
|
|
23
|
+
return check
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@contextlib.asynccontextmanager
|
|
27
|
+
async def _fleet(name, proc, *, n: int, concurrency: int):
|
|
28
|
+
"""n independent workers (own connections), all sweeping on a short interval;
|
|
29
|
+
lock_duration comfortably exceeds a job so a healthy job is never false-swept."""
|
|
30
|
+
workers = [
|
|
31
|
+
Worker(
|
|
32
|
+
name,
|
|
33
|
+
proc,
|
|
34
|
+
prefix=PREFIX,
|
|
35
|
+
concurrency=concurrency,
|
|
36
|
+
stalled_interval=100,
|
|
37
|
+
lock_duration=3000,
|
|
38
|
+
block_timeout=0.2,
|
|
39
|
+
)
|
|
40
|
+
for _ in range(n)
|
|
41
|
+
]
|
|
42
|
+
tasks = [asyncio.create_task(w.run()) for w in workers]
|
|
43
|
+
try:
|
|
44
|
+
yield
|
|
45
|
+
finally:
|
|
46
|
+
for w in workers:
|
|
47
|
+
await w.stop(grace_period=2)
|
|
48
|
+
for t in tasks:
|
|
49
|
+
t.cancel()
|
|
50
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
51
|
+
await t
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def test_every_job_processed_exactly_once_under_contention(q, run_until):
|
|
55
|
+
total, n_workers, concurrency = 200, 4, 4
|
|
56
|
+
runs: collections.Counter[str] = collections.Counter()
|
|
57
|
+
|
|
58
|
+
async def proc(job):
|
|
59
|
+
await asyncio.sleep(0.002) # hold the slot so claims genuinely contend
|
|
60
|
+
runs[job.id] += 1 # single event loop, no await between r-m-w: safe
|
|
61
|
+
|
|
62
|
+
expected = {(await q.add(f"j{i}", {"i": i})).id for i in range(total)}
|
|
63
|
+
|
|
64
|
+
async with _fleet(q.name, proc, n=n_workers, concurrency=concurrency):
|
|
65
|
+
assert await run_until(_completed(q, total), timeout=30), "fleet did not drain"
|
|
66
|
+
|
|
67
|
+
counts = await q.counts()
|
|
68
|
+
assert counts["completed"] == total and counts["failed"] == 0
|
|
69
|
+
assert set(runs) == expected # nothing missed, nothing spurious
|
|
70
|
+
dupes = [jid for jid, runcount in runs.items() if runcount != 1]
|
|
71
|
+
assert not dupes, f"processed more than once: {dupes}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def test_flows_settle_under_contention(q, run_until):
|
|
75
|
+
n_flows, kids, n_workers = 30, 4, 4
|
|
76
|
+
parents = []
|
|
77
|
+
|
|
78
|
+
async def proc(job):
|
|
79
|
+
if job.name.startswith("flow"):
|
|
80
|
+
return sorted((await job.children_results()).values())
|
|
81
|
+
return job.data["i"]
|
|
82
|
+
|
|
83
|
+
for f in range(n_flows):
|
|
84
|
+
p = await q.add_flow(
|
|
85
|
+
f"flow{f}", {}, children=[c(f"c{f}-{i}", {"i": i}) for i in range(kids)]
|
|
86
|
+
)
|
|
87
|
+
parents.append(p.id)
|
|
88
|
+
|
|
89
|
+
nodes = n_flows * (kids + 1)
|
|
90
|
+
async with _fleet(q.name, proc, n=n_workers, concurrency=4):
|
|
91
|
+
assert await run_until(_completed(q, nodes), timeout=30), "flows did not drain"
|
|
92
|
+
|
|
93
|
+
# every parent ran exactly once on its children's results; nothing stuck
|
|
94
|
+
assert (await q.counts())["completed"] == nodes
|
|
95
|
+
assert (await q.counts())["waiting-children"] == 0
|
|
96
|
+
for pid in parents:
|
|
97
|
+
job = await q.get_job(pid)
|
|
98
|
+
assert job is not None and job.state == "completed"
|
|
99
|
+
assert len(job.returnvalue) == kids # the fan-in collected every child
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Fault injection / chaos: drive toro through faults the happy-path tests never
|
|
2
|
+
see and assert the at-least-once machinery still holds - a dropped Redis call
|
|
3
|
+
mid-commit, and two workers racing the same recovery or promotion.
|
|
4
|
+
|
|
5
|
+
These target the gaps left by test_reliability/test_worker_resilience: those cover
|
|
6
|
+
a worker that *dies*; here the worker *survives* a transient failure mid-finish
|
|
7
|
+
(the lock renewer is cancelled, the lock lapses, the stalled sweep recovers the
|
|
8
|
+
job) and two workers hit the same atomic guard at once.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
from toro import Queue, Worker
|
|
14
|
+
|
|
15
|
+
PREFIX = "torotest"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _noop(job):
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _completed(q: Queue, n: int = 1):
|
|
23
|
+
async def check():
|
|
24
|
+
return (await q.counts())["completed"] >= n
|
|
25
|
+
|
|
26
|
+
return check
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _failed(q: Queue, n: int = 1):
|
|
30
|
+
async def check():
|
|
31
|
+
return (await q.counts())["failed"] >= n
|
|
32
|
+
|
|
33
|
+
return check
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---- a dropped Redis call mid-commit recovers, exactly one terminal state ----------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def test_dropped_commit_recovers_and_completes_once(q, run_worker, run_until):
|
|
40
|
+
runs: list[str] = []
|
|
41
|
+
|
|
42
|
+
async def proc(job):
|
|
43
|
+
runs.append(job.id)
|
|
44
|
+
|
|
45
|
+
# short lock so it lapses fast once the renewer is cancelled; sweep is driven
|
|
46
|
+
# by hand for determinism (stalled_interval=0 disables the worker's own loop).
|
|
47
|
+
async with run_worker(
|
|
48
|
+
q, proc, concurrency=1, stalled_interval=0, lock_duration=150, block_timeout=0.2
|
|
49
|
+
) as w:
|
|
50
|
+
orig = w._finish_completed
|
|
51
|
+
hits = {"n": 0}
|
|
52
|
+
|
|
53
|
+
async def flaky(job, result):
|
|
54
|
+
hits["n"] += 1
|
|
55
|
+
if hits["n"] == 1:
|
|
56
|
+
raise ConnectionError("redis dropped mid-commit") # the first commit never lands
|
|
57
|
+
return await orig(job, result)
|
|
58
|
+
|
|
59
|
+
w._finish_completed = flaky
|
|
60
|
+
|
|
61
|
+
await q.add("j", {})
|
|
62
|
+
# claim + process happen, then the commit is dropped: the job is stranded
|
|
63
|
+
# on `active`, the renewer was cancelled, so the lock will lapse.
|
|
64
|
+
assert await run_until(lambda: hits["n"] >= 1, timeout=10), "commit never attempted"
|
|
65
|
+
await asyncio.sleep(0.3) # > lock_duration: the lock is now dead
|
|
66
|
+
await w.check_stalled(throttle_ms=0) # pass 1: mark
|
|
67
|
+
await w.check_stalled(throttle_ms=0) # pass 2: recover -> wait + wakeup marker
|
|
68
|
+
# the freed slot re-claims and this time the commit lands
|
|
69
|
+
assert await run_until(_completed(q, 1), timeout=10), "never recovered"
|
|
70
|
+
|
|
71
|
+
assert (await q.counts())["completed"] == 1 # exactly one terminal completion
|
|
72
|
+
assert hits["n"] >= 2 # the commit was genuinely retried after recovery
|
|
73
|
+
assert len(runs) >= 1 # at-least-once: the processor may have run twice
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def test_dropped_fail_commit_recovers_and_fails_once(q, run_worker, run_until):
|
|
77
|
+
async def proc(job):
|
|
78
|
+
raise RuntimeError("boom") # always fails
|
|
79
|
+
|
|
80
|
+
async with run_worker(
|
|
81
|
+
q, proc, concurrency=1, stalled_interval=0, lock_duration=150, block_timeout=0.2
|
|
82
|
+
) as w:
|
|
83
|
+
orig = w._finish_failed
|
|
84
|
+
hits = {"n": 0}
|
|
85
|
+
|
|
86
|
+
async def flaky(job, exc):
|
|
87
|
+
hits["n"] += 1
|
|
88
|
+
if hits["n"] == 1:
|
|
89
|
+
raise ConnectionError("redis dropped mid-fail-commit")
|
|
90
|
+
return await orig(job, exc)
|
|
91
|
+
|
|
92
|
+
w._finish_failed = flaky
|
|
93
|
+
|
|
94
|
+
await q.add("j", {}, attempts=1)
|
|
95
|
+
assert await run_until(lambda: hits["n"] >= 1, timeout=10), "fail-commit never attempted"
|
|
96
|
+
await asyncio.sleep(0.3)
|
|
97
|
+
await w.check_stalled(throttle_ms=0)
|
|
98
|
+
await w.check_stalled(throttle_ms=0)
|
|
99
|
+
assert await run_until(_failed(q, 1), timeout=10), "never recovered to failed"
|
|
100
|
+
|
|
101
|
+
assert (await q.counts())["failed"] == 1 # exactly one terminal failure
|
|
102
|
+
assert hits["n"] >= 2
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---- two workers hit the same atomic guard at once ---------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def test_concurrent_stalled_sweeps_recover_exactly_once(q):
|
|
109
|
+
# A dead job swept by two workers at the same instant: the atomic LREM-from-active
|
|
110
|
+
# guard inside MOVE_STALLED means exactly one recovers it - no double re-enqueue,
|
|
111
|
+
# no double counter bump.
|
|
112
|
+
job = await q.add("x", {}, attempts=5)
|
|
113
|
+
jid = job.id
|
|
114
|
+
w1 = Worker(q.name, _noop, prefix=PREFIX, max_stalled_count=3, connection=q.redis)
|
|
115
|
+
w2 = Worker(q.name, _noop, prefix=PREFIX, max_stalled_count=3, connection=q.redis)
|
|
116
|
+
|
|
117
|
+
# a worker grabbed it and died: on `active`, no lock
|
|
118
|
+
await q.redis.zrem(q.keys.prioritized, jid)
|
|
119
|
+
await q.redis.rpush(q.keys.active, jid)
|
|
120
|
+
|
|
121
|
+
await w1.check_stalled(throttle_ms=0) # pass 1: mark (single, sequential)
|
|
122
|
+
assert await q.redis.sismember(q.keys.stalled, jid)
|
|
123
|
+
# pass 2: BOTH workers try to recover the now-marked job at the same instant
|
|
124
|
+
results = await asyncio.gather(w1.check_stalled(throttle_ms=0), w2.check_stalled(throttle_ms=0))
|
|
125
|
+
|
|
126
|
+
recovered = [r for _failed_ids, rec in results for r in rec]
|
|
127
|
+
assert recovered.count(jid) == 1, results # exactly one sweep won
|
|
128
|
+
assert await q.redis.zscore(q.keys.prioritized, jid) is not None # back in the queue once
|
|
129
|
+
assert jid not in await q.redis.lrange(q.keys.active, 0, -1) # off active
|
|
130
|
+
assert await q.redis.hget(q.keys.job(jid), "stalledCounter") == "1" # bumped once, not twice
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def test_concurrent_delayed_promotion_promotes_each_once(q):
|
|
134
|
+
# Two workers promote the same due batch at once. Lua runs serially, so the
|
|
135
|
+
# first promotes the whole batch and the second finds an empty range: every
|
|
136
|
+
# due job lands in `prioritized` exactly once, none lost or duplicated.
|
|
137
|
+
from toro import scripts
|
|
138
|
+
|
|
139
|
+
now = await _server_now_ms(q)
|
|
140
|
+
ids = [f"d{i}" for i in range(5)]
|
|
141
|
+
for jid in ids:
|
|
142
|
+
await q.redis.hset(
|
|
143
|
+
q.keys.job(jid),
|
|
144
|
+
mapping={"id": jid, "name": jid, "data": "{}", "opts": "{}", "state": "delayed"},
|
|
145
|
+
)
|
|
146
|
+
await q.redis.zadd(q.keys.delayed, {jid: now - 1000}) # already due
|
|
147
|
+
|
|
148
|
+
w1 = Worker(q.name, _noop, prefix=PREFIX, connection=q.redis)
|
|
149
|
+
w2 = Worker(q.name, _noop, prefix=PREFIX, connection=q.redis)
|
|
150
|
+
keys = [q.keys.delayed, q.keys.prioritized, q.keys.marker, q.keys.base, q.keys.pc]
|
|
151
|
+
await asyncio.gather(
|
|
152
|
+
w1._promote_delayed(keys=keys, args=[now, scripts.PROMOTE_BATCH]),
|
|
153
|
+
w2._promote_delayed(keys=keys, args=[now, scripts.PROMOTE_BATCH]),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
promoted = sorted(await q.redis.zrange(q.keys.prioritized, 0, -1))
|
|
157
|
+
assert promoted == sorted(ids) # each due job promoted exactly once, none duplicated
|
|
158
|
+
assert await q.redis.zcard(q.keys.delayed) == 0 # nothing left behind
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _server_now_ms(q: Queue) -> int:
|
|
162
|
+
secs, micros = await q.redis.time()
|
|
163
|
+
return int(secs) * 1000 + int(micros) // 1000
|
|
@@ -172,30 +172,28 @@ async def test_retry_failed_child_after_parent_failed(q, run_worker, run_until):
|
|
|
172
172
|
assert (await q.get_job(parent.id)).state == "failed"
|
|
173
173
|
|
|
174
174
|
|
|
175
|
-
async def
|
|
176
|
-
|
|
175
|
+
async def test_retry_parent_reparks_and_redrives_its_failed_child(q, run_worker, run_until):
|
|
176
|
+
# retrying a parent re-arms the barrier AND re-drives its failed child, but the
|
|
177
|
+
# parent must still wait for that child - never run on partial results.
|
|
178
|
+
async def boom(job):
|
|
179
|
+
raise RuntimeError("boom")
|
|
177
180
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return "fixed" if job.name == "bad" else "report-ran"
|
|
181
|
+
parent = await q.add_flow("report", {}, children=[c("bad", {})])
|
|
182
|
+
async with run_worker(q, boom): # fail the flow, then the worker exits
|
|
183
|
+
assert await run_until(_count_is(q, "failed", 2)) # child + parent
|
|
182
184
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
185
|
+
# retry the PARENT only; with no worker running we can inspect the re-armed state
|
|
186
|
+
assert await q.retry_job(parent.id)
|
|
187
|
+
cid = (await q.get_flow(parent.id))["children"][0]["job"].id
|
|
188
|
+
assert (await q.get_job(parent.id)).state == "waiting-children" # re-parked, not run
|
|
189
|
+
assert (await q.get_job(cid)).state == "wait" # the failed child was re-driven too
|
|
190
|
+
assert await _count(q, "completed") == 0 # no partial run
|
|
186
191
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
assert await q.retry_job(parent.id)
|
|
190
|
-
assert (await q.get_job(parent.id)).state == "waiting-children"
|
|
191
|
-
assert await _count(q, "completed") == 0
|
|
192
|
+
async def fixed(job):
|
|
193
|
+
return "fixed" if job.name == "bad" else "report-ran"
|
|
192
194
|
|
|
193
|
-
|
|
194
|
-
fail = False
|
|
195
|
-
cid = (await q.get_flow(parent.id))["children"][0]["job"].id
|
|
196
|
-
assert await q.retry_job(cid)
|
|
195
|
+
async with run_worker(q, fixed): # the re-driven child completes, releasing the parent
|
|
197
196
|
assert await run_until(_count_is(q, "completed", 2))
|
|
198
|
-
|
|
199
197
|
assert (await q.get_job(parent.id)).returnvalue == "report-ran"
|
|
200
198
|
|
|
201
199
|
|
|
@@ -620,6 +618,31 @@ async def test_retry_flow_recovers_a_whole_failed_flow(q, run_worker, run_until)
|
|
|
620
618
|
assert (await q.get_job(parent.id)).returnvalue == ["fixed", "fixed"]
|
|
621
619
|
|
|
622
620
|
|
|
621
|
+
async def test_retry_parent_recovers_the_whole_flow(q, run_worker, run_until):
|
|
622
|
+
# retrying a PARENT directly re-drives its failed children too, so a flow that
|
|
623
|
+
# failed on a child recovers in one call instead of stranding on it.
|
|
624
|
+
fail = True
|
|
625
|
+
|
|
626
|
+
async def proc(job):
|
|
627
|
+
if job.name == "bad":
|
|
628
|
+
if fail:
|
|
629
|
+
raise RuntimeError("boom")
|
|
630
|
+
return "fixed"
|
|
631
|
+
if job.name == "ok":
|
|
632
|
+
return "ok-ran"
|
|
633
|
+
return "report-ran" # the parent
|
|
634
|
+
|
|
635
|
+
async with run_worker(q, proc, concurrency=2):
|
|
636
|
+
parent = await q.add_flow("report", {}, children=[c("ok", {}), c("bad", {})])
|
|
637
|
+
assert await run_until(_count_is(q, "failed", 2)) # the bad child + the parent
|
|
638
|
+
|
|
639
|
+
fail = False
|
|
640
|
+
assert await q.retry_job(parent.id) # parent only - the child rides along
|
|
641
|
+
assert await run_until(_count_is(q, "completed", 3)) # both children + parent
|
|
642
|
+
|
|
643
|
+
assert (await q.get_job(parent.id)).state == "completed"
|
|
644
|
+
|
|
645
|
+
|
|
623
646
|
async def test_retry_flow_leaves_completed_children_untouched(q, run_worker, run_until):
|
|
624
647
|
fail_bad = True
|
|
625
648
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Property-based invariants: drive the queue through long randomized operation
|
|
2
|
+
sequences and assert the global invariants hold after every step, then that every
|
|
3
|
+
flow settles once the queue is drained.
|
|
4
|
+
|
|
5
|
+
A deterministic seeded fuzzer (not Hypothesis): processing is driven by hand via
|
|
6
|
+
_acquire + _finish_* so each step is atomic with nothing else mutating - no live
|
|
7
|
+
worker, no timing races - and a failing case reproduces exactly from its seed.
|
|
8
|
+
|
|
9
|
+
Invariants per step:
|
|
10
|
+
1. counts() equals the real set cardinalities.
|
|
11
|
+
2. no job id is in two state sets at once (atomic moves never duplicate).
|
|
12
|
+
3. no orphan aux key (:deps/:results/:cfail/:lock) outlives its job hash.
|
|
13
|
+
Final invariant after a full drain:
|
|
14
|
+
4. flows always settle - waiting-children is empty (no parent stranded).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import random
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from toro import FlowChild as c # noqa: N813
|
|
22
|
+
from toro import Queue, Worker
|
|
23
|
+
from toro.job import Job
|
|
24
|
+
|
|
25
|
+
PREFIX = "torotest"
|
|
26
|
+
|
|
27
|
+
_STATE_SETS = ("prioritized", "active", "delayed", "completed", "failed", "waiting_children")
|
|
28
|
+
# counts() key -> the keys.py property backing it. They line up one-to-one except
|
|
29
|
+
# `wait`: the waiting set is the priority-ordered zset, so its key is `prioritized`
|
|
30
|
+
# (the state is named for what it means; the key for what it is).
|
|
31
|
+
_COUNT_TO_SET = {
|
|
32
|
+
"wait": "prioritized",
|
|
33
|
+
"active": "active",
|
|
34
|
+
"delayed": "delayed",
|
|
35
|
+
"completed": "completed",
|
|
36
|
+
"failed": "failed",
|
|
37
|
+
"waiting-children": "waiting_children",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _members(q: Queue) -> dict[str, set[str]]:
|
|
42
|
+
"""Current id membership of every state set (active is a LIST, rest ZSETs)."""
|
|
43
|
+
out: dict[str, set[str]] = {}
|
|
44
|
+
for name in _STATE_SETS:
|
|
45
|
+
key = getattr(q.keys, name)
|
|
46
|
+
ids = (
|
|
47
|
+
await q.redis.lrange(key, 0, -1)
|
|
48
|
+
if name == "active"
|
|
49
|
+
else await q.redis.zrange(key, 0, -1)
|
|
50
|
+
)
|
|
51
|
+
out[name] = set(ids)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _check_invariants(q: Queue) -> None:
|
|
56
|
+
members = await _members(q)
|
|
57
|
+
|
|
58
|
+
# 1. counts() agrees with the real cardinalities
|
|
59
|
+
counts = await q.counts()
|
|
60
|
+
for cname, sname in _COUNT_TO_SET.items():
|
|
61
|
+
assert counts[cname] == len(members[sname]), f"{cname} count {counts[cname]} != {sname}"
|
|
62
|
+
|
|
63
|
+
# 2. no id lives in two state sets at once
|
|
64
|
+
seen: dict[str, str] = {}
|
|
65
|
+
for sname, ids in members.items():
|
|
66
|
+
for jid in ids:
|
|
67
|
+
assert jid not in seen, f"{jid} in both {seen[jid]} and {sname}"
|
|
68
|
+
seen[jid] = sname
|
|
69
|
+
|
|
70
|
+
# 3. no orphan aux keys (a removed job leaves nothing behind)
|
|
71
|
+
base = q.keys.base
|
|
72
|
+
for suffix in (":deps", ":results", ":cfail", ":lock"):
|
|
73
|
+
for key in await q.redis.keys(f"{base}*{suffix}"):
|
|
74
|
+
jid = key[len(base) : -len(suffix)]
|
|
75
|
+
assert await q.redis.exists(q.keys.job(jid)), f"orphan {suffix} for {jid}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _process_one(w: Worker, *, succeed: bool) -> bool:
|
|
79
|
+
"""Claim one runnable job and finish it (atomic, no fetch-next chaining since
|
|
80
|
+
the worker isn't run()). Returns False when nothing was claimable."""
|
|
81
|
+
loaded = await w._acquire()
|
|
82
|
+
if loaded is None:
|
|
83
|
+
return False
|
|
84
|
+
job_id, fields = loaded
|
|
85
|
+
job = Job.from_hash(job_id, fields)
|
|
86
|
+
if succeed:
|
|
87
|
+
await w._finish_completed(job, {"ok": 1})
|
|
88
|
+
else:
|
|
89
|
+
await w._finish_failed(job, RuntimeError("boom"))
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _rand_member(q: Queue, rng: random.Random, *names: str) -> str | None:
|
|
94
|
+
members = await _members(q)
|
|
95
|
+
pool = sorted(jid for n in names for jid in members[n])
|
|
96
|
+
return rng.choice(pool) if pool else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---- the random operations (module level, so the test body stays simple) -----------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _op_add(q, w, rng, ctr):
|
|
103
|
+
ctr["j"] += 1
|
|
104
|
+
kw = {"priority": rng.randint(0, 5)}
|
|
105
|
+
if rng.random() < 0.3:
|
|
106
|
+
kw["delay"] = rng.randint(1, 50)
|
|
107
|
+
if rng.random() < 0.3:
|
|
108
|
+
kw["attempts"] = rng.randint(1, 3)
|
|
109
|
+
await q.add(f"job{ctr['j']}", {"i": ctr["j"]}, **kw)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _op_add_flow(q, w, rng, ctr):
|
|
113
|
+
ctr["j"] += 1
|
|
114
|
+
kids = [
|
|
115
|
+
c(f"child{ctr['j']}-{i}", {}, on_fail=rng.choice(["fail_parent", "continue"]))
|
|
116
|
+
for i in range(rng.randint(1, 3))
|
|
117
|
+
]
|
|
118
|
+
await q.add_flow(f"flow{ctr['j']}", {}, children=kids)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _op_process(q, w, rng, ctr):
|
|
122
|
+
await _process_one(w, succeed=rng.random() < 0.7)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _op_retry(q, w, rng, ctr):
|
|
126
|
+
jid = await _rand_member(q, rng, "failed")
|
|
127
|
+
if jid:
|
|
128
|
+
await q.retry_job(jid)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def _op_remove(q, w, rng, ctr):
|
|
132
|
+
jid = await _rand_member(q, rng, "prioritized", "active", "delayed", "completed", "failed")
|
|
133
|
+
if jid:
|
|
134
|
+
await q.remove_job(jid)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _op_clean(q, w, rng, ctr):
|
|
138
|
+
await q.clean(rng.choice(["wait", "delayed", "completed", "failed"]))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def _op_promote(q, w, rng, ctr):
|
|
142
|
+
jid = await _rand_member(q, rng, "delayed")
|
|
143
|
+
if jid:
|
|
144
|
+
await q.promote_job(jid)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_OPS = [
|
|
148
|
+
_op_add,
|
|
149
|
+
_op_add_flow,
|
|
150
|
+
_op_process,
|
|
151
|
+
_op_process, # process twice as often as the admin ops
|
|
152
|
+
_op_retry,
|
|
153
|
+
_op_remove,
|
|
154
|
+
_op_clean,
|
|
155
|
+
_op_promote,
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def _recover_and_drain(q: Queue, w: Worker) -> None:
|
|
160
|
+
"""Fully recover the queue: retry every failed job (re-arming any parked flow -
|
|
161
|
+
the documented retry-all path), promote the delayed, process the runnable as
|
|
162
|
+
success, to quiescence. A flow stranded on a failed child only settles because
|
|
163
|
+
the child is retried too, which is exactly the guarantee under test."""
|
|
164
|
+
for _ in range(5000):
|
|
165
|
+
progressed = bool(await q.retry_all_failed())
|
|
166
|
+
for jid in await q.redis.zrange(q.keys.delayed, 0, -1):
|
|
167
|
+
progressed = await q.promote_job(jid) or progressed
|
|
168
|
+
progressed = await _process_one(w, succeed=True) or progressed
|
|
169
|
+
if not progressed:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def _settle_diagnostic(q: Queue) -> str:
|
|
174
|
+
parts = []
|
|
175
|
+
for pid in await q.redis.zrange(q.keys.waiting_children, 0, -1):
|
|
176
|
+
info = {}
|
|
177
|
+
for d in await q.redis.smembers(q.keys.deps(pid)):
|
|
178
|
+
exists = await q.redis.exists(q.keys.job(d))
|
|
179
|
+
info[d] = await q.redis.hget(q.keys.job(d), "state") if exists else "GONE"
|
|
180
|
+
parts.append(f"parent {pid}: deps={info}")
|
|
181
|
+
return " | ".join(parts)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.mark.parametrize("seed", range(12))
|
|
185
|
+
async def test_invariants_hold_under_random_ops(q, seed):
|
|
186
|
+
rng = random.Random(seed) # noqa: S311 - a reproducible test fuzzer, not crypto
|
|
187
|
+
w = Worker(q.name, lambda j: None, prefix=PREFIX, connection=q.redis)
|
|
188
|
+
ctr = {"j": 0}
|
|
189
|
+
|
|
190
|
+
for _ in range(40):
|
|
191
|
+
await rng.choice(_OPS)(q, w, rng, ctr)
|
|
192
|
+
await _check_invariants(q)
|
|
193
|
+
|
|
194
|
+
await _recover_and_drain(q, w)
|
|
195
|
+
await _check_invariants(q)
|
|
196
|
+
settled = (await q.counts())["waiting-children"] == 0
|
|
197
|
+
assert settled, "a flow never settled -> " + await _settle_diagnostic(q)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Resource hygiene: a job (or a whole flow) that runs its course must leave
|
|
2
|
+
NOTHING behind - no hash, lock, logs, or flow aux keys, and no stray entry in the
|
|
3
|
+
children index or the roots scratch - and a worker that stops must leak no
|
|
4
|
+
background tasks. The fuzzer checks orphans mid-run; this pins the end state.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import contextlib
|
|
9
|
+
|
|
10
|
+
from toro import FlowChild as c # noqa: N813
|
|
11
|
+
from toro import Queue, Worker
|
|
12
|
+
from toro.job import Job
|
|
13
|
+
|
|
14
|
+
PREFIX = "torotest"
|
|
15
|
+
|
|
16
|
+
# Keys that legitimately outlive any single job: counters, the wakeup marker, the
|
|
17
|
+
# state sets themselves, and the self-expiring metrics buckets / presence records.
|
|
18
|
+
_INFRA = {
|
|
19
|
+
"id", "pc", "marker", "prioritized", "active", "delayed", "completed", "failed",
|
|
20
|
+
"waiting-children", "children", "stalled", "stalled-check", "meta-paused",
|
|
21
|
+
"limiter", "repeat", "workers", "departed", "roots-scratch", "events",
|
|
22
|
+
} # fmt: skip
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _noop(job):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _leaked_keys(q: Queue) -> list[str]:
|
|
30
|
+
"""Per-job/aux keys left under the queue's namespace (infra keys excluded)."""
|
|
31
|
+
base = q.keys.base
|
|
32
|
+
leaked = []
|
|
33
|
+
for key in await q.redis.keys(base + "*"):
|
|
34
|
+
suffix = key[len(base) :]
|
|
35
|
+
if suffix in _INFRA or suffix.split(":")[0] in ("metrics", "worker", "repeat"):
|
|
36
|
+
continue
|
|
37
|
+
leaked.append(suffix)
|
|
38
|
+
return leaked
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _process(w: Worker, *, succeed: bool = True) -> bool:
|
|
42
|
+
loaded = await w._acquire()
|
|
43
|
+
if loaded is None:
|
|
44
|
+
return False
|
|
45
|
+
job = Job.from_hash(loaded[0], loaded[1])
|
|
46
|
+
if succeed:
|
|
47
|
+
await w._finish_completed(job, {"ok": 1})
|
|
48
|
+
else:
|
|
49
|
+
await w._finish_failed(job, RuntimeError("boom"))
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---- a finished job leaves nothing behind ------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def test_completed_then_autoremoved_leaves_no_keys(q):
|
|
57
|
+
w = Worker(q.name, _noop, prefix=PREFIX, connection=q.redis)
|
|
58
|
+
job = await q.add("j", {"x": 1}, remove_on_complete=True)
|
|
59
|
+
await q.redis.rpush(q.keys.logs(job.id), "a log line") # the job logged something
|
|
60
|
+
|
|
61
|
+
assert await _process(w, succeed=True)
|
|
62
|
+
|
|
63
|
+
assert await q.redis.exists(q.keys.job(job.id)) == 0
|
|
64
|
+
assert await q.redis.exists(q.keys.logs(job.id)) == 0 # logs cleaned too
|
|
65
|
+
assert await q.redis.exists(q.keys.lock(job.id)) == 0
|
|
66
|
+
assert await _leaked_keys(q) == []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def test_failed_then_autoremoved_leaves_no_keys(q):
|
|
70
|
+
w = Worker(q.name, _noop, prefix=PREFIX, connection=q.redis)
|
|
71
|
+
await q.add("j", {}, attempts=1, remove_on_fail=True)
|
|
72
|
+
|
|
73
|
+
assert await _process(w, succeed=False)
|
|
74
|
+
|
|
75
|
+
assert await _leaked_keys(q) == []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---- a whole flow leaves nothing behind --------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def test_completed_flow_leaves_no_aux_keys_or_children_index(q):
|
|
82
|
+
w = Worker(q.name, _noop, prefix=PREFIX, connection=q.redis)
|
|
83
|
+
# every node auto-removes (a retained child legitimately stays in the index,
|
|
84
|
+
# so to prove the flow leaves NOTHING behind, remove the children too)
|
|
85
|
+
parent = await q.add_flow(
|
|
86
|
+
"p",
|
|
87
|
+
{},
|
|
88
|
+
children=[c("a", {}, remove_on_complete=True), c("b", {}, remove_on_complete=True)],
|
|
89
|
+
remove_on_complete=True,
|
|
90
|
+
)
|
|
91
|
+
# children index is populated while the flow exists
|
|
92
|
+
assert await q.redis.zcard(q.keys.children) == 2
|
|
93
|
+
|
|
94
|
+
# process both children, then the released parent
|
|
95
|
+
for _ in range(5):
|
|
96
|
+
if not await _process(w, succeed=True):
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
assert await q.redis.exists(q.keys.deps(parent.id)) == 0
|
|
100
|
+
assert await q.redis.exists(q.keys.results(parent.id)) == 0
|
|
101
|
+
assert await q.redis.exists(q.keys.cfail(parent.id)) == 0
|
|
102
|
+
assert await q.redis.zcard(q.keys.children) == 0 # index pruned with the nodes
|
|
103
|
+
assert await _leaked_keys(q) == []
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def test_removed_flow_leaves_no_keys(q):
|
|
107
|
+
parent = await q.add_flow("p", {}, children=[c("a", {}), c("b", {}, children=[c("d", {})])])
|
|
108
|
+
assert await q.redis.zcard(q.keys.children) == 3 # a, b, d are all children
|
|
109
|
+
|
|
110
|
+
await q.remove_job(parent.id) # cascades the whole subtree
|
|
111
|
+
|
|
112
|
+
assert await q.redis.zcard(q.keys.children) == 0
|
|
113
|
+
assert await _leaked_keys(q) == []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---- the roots scratch key never persists ------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def test_roots_queries_leave_no_scratch_key(q):
|
|
120
|
+
await q.add_flow("p", {}, children=[c("a", {}), c("b", {})])
|
|
121
|
+
await q.add("solo", {})
|
|
122
|
+
|
|
123
|
+
for state in ("wait", "completed", "failed", "waiting-children", "active"):
|
|
124
|
+
await q.get_jobs_roots(state, 0, 50)
|
|
125
|
+
await q.roots_counts()
|
|
126
|
+
|
|
127
|
+
# ZDIFFSTORE writes the scratch then DELs it inside the same atomic script
|
|
128
|
+
assert await q.redis.exists(q.keys.roots_scratch) == 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---- a stopped worker leaks no background tasks ------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def test_stopped_worker_leaks_no_tasks(q, run_until):
|
|
135
|
+
done = []
|
|
136
|
+
|
|
137
|
+
async def proc(job):
|
|
138
|
+
done.append(job.id)
|
|
139
|
+
|
|
140
|
+
worker = Worker(q.name, proc, prefix=PREFIX, connection=q.redis, heartbeat_interval=50)
|
|
141
|
+
task = asyncio.create_task(worker.run())
|
|
142
|
+
await q.add("j", {})
|
|
143
|
+
assert await run_until(lambda: len(done) >= 1, timeout=10)
|
|
144
|
+
await worker.stop()
|
|
145
|
+
task.cancel()
|
|
146
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
147
|
+
await task
|
|
148
|
+
|
|
149
|
+
# every background loop (stalled / heartbeat / promote) is done, none lingering
|
|
150
|
+
assert all(t.done() for t in worker._tasks), [t for t in worker._tasks if not t.done()]
|
|
151
|
+
await worker.stop() # idempotent: a second stop must not raise
|
|
@@ -932,11 +932,24 @@ class Queue:
|
|
|
932
932
|
async def retry_job(self, job_id: str) -> bool:
|
|
933
933
|
"""Move a failed job back to the queue for another attempt.
|
|
934
934
|
|
|
935
|
-
Flow-aware: a
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
935
|
+
Flow-aware: retrying a flow PARENT re-drives its whole failed subtree, not
|
|
936
|
+
the parent alone. The parent re-parks on every non-completed child
|
|
937
|
+
(completed children keep their collected results; failed and still-pending
|
|
938
|
+
ones stay in the barrier) and each failed descendant is re-queued root-first
|
|
939
|
+
- so a parent that failed because a child failed recovers in one call
|
|
940
|
+
instead of stranding on that still-failed child. A retried child re-joins
|
|
941
|
+
its parked parent's barrier. (retry_all_failed and retry_flow drive the
|
|
942
|
+
per-job script directly, so this convenience does not change them.)
|
|
943
|
+
|
|
944
|
+
The parent path keys off being a flow parent, not off being failed: called
|
|
945
|
+
on a parent that is not itself failed (e.g. still in-flight in
|
|
946
|
+
`waiting-children` with a `continue`-failed child), it re-drives the
|
|
947
|
+
subtree's failed nodes instead of being a no-op as it is for a non-failed
|
|
948
|
+
plain job. Pass a leaf child id to retry just that one job.
|
|
939
949
|
"""
|
|
950
|
+
job = await self.get_job(job_id)
|
|
951
|
+
if job is not None and job.children_ids: # a flow parent: recover the subtree
|
|
952
|
+
return await self.retry_flow(job_id) > 0
|
|
940
953
|
res = await self._retry_job(keys=self._retry_job_keys(job_id), args=[job_id, _now_ms()])
|
|
941
954
|
return bool(res)
|
|
942
955
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|