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.
Files changed (75) hide show
  1. {toro_queue-0.4.0 → toro_queue-0.5.0}/.gitignore +2 -0
  2. {toro_queue-0.4.0 → toro_queue-0.5.0}/PKG-INFO +1 -1
  3. {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/flows-design.md +11 -7
  4. {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/flows.md +7 -6
  5. {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/producing.md +1 -1
  6. {toro_queue-0.4.0 → toro_queue-0.5.0}/pyproject.toml +1 -1
  7. toro_queue-0.5.0/tests/integration/test_concurrency_stress.py +99 -0
  8. toro_queue-0.5.0/tests/integration/test_fault_injection.py +163 -0
  9. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_flows_edges.py +42 -19
  10. toro_queue-0.5.0/tests/integration/test_invariants.py +197 -0
  11. toro_queue-0.5.0/tests/integration/test_leaks.py +151 -0
  12. {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/__init__.py +1 -1
  13. {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/queue.py +17 -4
  14. {toro_queue-0.4.0 → toro_queue-0.5.0}/uv.lock +1 -1
  15. {toro_queue-0.4.0 → toro_queue-0.5.0}/.github/workflows/pr-check.yaml +0 -0
  16. {toro_queue-0.4.0 → toro_queue-0.5.0}/.github/workflows/release.yml +0 -0
  17. {toro_queue-0.4.0 → toro_queue-0.5.0}/.pre-commit-config.yaml +0 -0
  18. {toro_queue-0.4.0 → toro_queue-0.5.0}/.vscode/extensions.json +0 -0
  19. {toro_queue-0.4.0 → toro_queue-0.5.0}/.vscode/settings.json +0 -0
  20. {toro_queue-0.4.0 → toro_queue-0.5.0}/LICENSE +0 -0
  21. {toro_queue-0.4.0 → toro_queue-0.5.0}/README.md +0 -0
  22. {toro_queue-0.4.0 → toro_queue-0.5.0}/bench/bench.py +0 -0
  23. {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/architecture.md +0 -0
  24. {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/concepts.md +0 -0
  25. {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/data-model.md +0 -0
  26. {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/index.md +0 -0
  27. {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/processing.md +0 -0
  28. {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/reliability.md +0 -0
  29. {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/scheduling.md +0 -0
  30. {toro_queue-0.4.0 → toro_queue-0.5.0}/docs/security.md +0 -0
  31. {toro_queue-0.4.0 → toro_queue-0.5.0}/examples/README.md +0 -0
  32. {toro_queue-0.4.0 → toro_queue-0.5.0}/examples/basic.py +0 -0
  33. {toro_queue-0.4.0 → toro_queue-0.5.0}/examples/stalled.py +0 -0
  34. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/conftest.py +0 -0
  35. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_admin.py +0 -0
  36. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_admin_ordering.py +0 -0
  37. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_connection.py +0 -0
  38. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_finished_retention.py +0 -0
  39. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_flows.py +0 -0
  40. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_introspection.py +0 -0
  41. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_metrics.py +0 -0
  42. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_processing.py +0 -0
  43. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_reliability.py +0 -0
  44. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_result_dispatcher.py +0 -0
  45. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_retries.py +0 -0
  46. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_roots.py +0 -0
  47. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_scheduler.py +0 -0
  48. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_worker_resilience.py +0 -0
  49. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/integration/test_workers.py +0 -0
  50. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/harness.py +0 -0
  51. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_active_list_cost.py +0 -0
  52. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_admin_scaling.py +0 -0
  53. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_enqueue_rtt.py +0 -0
  54. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_flows_load.py +0 -0
  55. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_load.py +0 -0
  56. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_promote_blocking.py +0 -0
  57. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_result_fanout.py +0 -0
  58. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/load/test_worker_concurrency.py +0 -0
  59. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_backoff.py +0 -0
  60. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_flow.py +0 -0
  61. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_histogram.py +0 -0
  62. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_job.py +0 -0
  63. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_job_options.py +0 -0
  64. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_keys.py +0 -0
  65. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_priority.py +0 -0
  66. {toro_queue-0.4.0 → toro_queue-0.5.0}/tests/unit/test_scheduler.py +0 -0
  67. {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/connection.py +0 -0
  68. {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/errors.py +0 -0
  69. {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/flow.py +0 -0
  70. {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/job.py +0 -0
  71. {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/keys.py +0 -0
  72. {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/py.typed +0 -0
  73. {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/scheduler.py +0 -0
  74. {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/scripts.py +0 -0
  75. {toro_queue-0.4.0 → toro_queue-0.5.0}/toro/worker.py +0 -0
@@ -9,3 +9,5 @@ __pycache__/
9
9
 
10
10
  .coverage
11
11
  coverage.xml
12
+
13
+ .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toro-queue
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -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 instead.
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 deps re-parks in
169
- `waiting-children`; a retried child re-joins a parked parent's barrier and
170
- clears its stale `:cfail` entry. `retry_all_failed()` therefore recovers a
171
- whole flow in any order.
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. Children are not re-run on parent retry -
177
- results are already in `:results`.
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-arms its barrier: it goes back to
87
- `waiting-children` until its unsettled children resolve (it will not run on
88
- partial results).
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. One pinned v1 edge: if the parent has
94
- already failed and you retry *only* the child, the child's later success does
95
- not resurrect the parent; retry the parent too (or use retry-all).
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 failed flow parent re-parks until its children settle; a retried child re-joins its parked parent's barrier ([Flows](flows.md)). |
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.4.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 test_retry_reparks_failed_parent_until_children_settle(q, run_worker, run_until):
176
- fail = True
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
- async def proc(job):
179
- if job.name == "bad" and fail:
180
- raise RuntimeError("boom")
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
- async with run_worker(q, proc):
184
- parent = await q.add_flow("report", {}, children=[c("bad", {})])
185
- assert await run_until(_count_is(q, "failed", 2))
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
- # retrying the parent re-arms the barrier - it must NOT run with
188
- # partial results while its failed child is unsettled
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
- # retrying the child completes it, which releases the re-parked parent
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
@@ -26,4 +26,4 @@ __all__ = [
26
26
  "ToroError",
27
27
  "Worker",
28
28
  ]
29
- __version__ = "0.4.0"
29
+ __version__ = "0.5.0"
@@ -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 failed flow parent whose children haven't all settled
936
- re-parks in `waiting-children` instead of running with partial results;
937
- a retried child re-joins its parked parent's barrier. Retrying parent
938
- and children in any order (retry_all_failed does) recovers the flow.
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
 
@@ -424,7 +424,7 @@ wheels = [
424
424
 
425
425
  [[package]]
426
426
  name = "toro-queue"
427
- version = "0.4.0"
427
+ version = "0.5.0"
428
428
  source = { editable = "." }
429
429
  dependencies = [
430
430
  { name = "croniter" },
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