toro-queue 0.2.0__tar.gz → 0.4.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.2.0 → toro_queue-0.4.0}/.github/workflows/pr-check.yaml +2 -2
  2. {toro_queue-0.2.0 → toro_queue-0.4.0}/.github/workflows/release.yml +1 -1
  3. toro_queue-0.4.0/.pre-commit-config.yaml +26 -0
  4. {toro_queue-0.2.0 → toro_queue-0.4.0}/PKG-INFO +20 -5
  5. {toro_queue-0.2.0 → toro_queue-0.4.0}/README.md +19 -4
  6. {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/architecture.md +29 -20
  7. {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/concepts.md +7 -5
  8. {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/data-model.md +12 -4
  9. toro_queue-0.4.0/docs/flows-design.md +192 -0
  10. toro_queue-0.4.0/docs/flows.md +189 -0
  11. toro_queue-0.4.0/docs/index.md +29 -0
  12. {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/processing.md +22 -11
  13. {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/producing.md +17 -11
  14. {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/reliability.md +10 -8
  15. {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/scheduling.md +6 -6
  16. toro_queue-0.4.0/docs/security.md +38 -0
  17. toro_queue-0.4.0/examples/README.md +19 -0
  18. {toro_queue-0.2.0 → toro_queue-0.4.0}/examples/stalled.py +2 -2
  19. {toro_queue-0.2.0 → toro_queue-0.4.0}/pyproject.toml +5 -5
  20. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/conftest.py +21 -2
  21. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_admin.py +2 -2
  22. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_admin_ordering.py +4 -4
  23. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_finished_retention.py +4 -4
  24. toro_queue-0.4.0/tests/integration/test_flows.py +388 -0
  25. toro_queue-0.4.0/tests/integration/test_flows_edges.py +813 -0
  26. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_introspection.py +1 -1
  27. toro_queue-0.4.0/tests/integration/test_metrics.py +416 -0
  28. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_processing.py +2 -2
  29. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_reliability.py +27 -6
  30. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_result_dispatcher.py +3 -3
  31. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_retries.py +1 -1
  32. toro_queue-0.4.0/tests/integration/test_roots.py +194 -0
  33. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_worker_resilience.py +1 -1
  34. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_workers.py +3 -3
  35. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/harness.py +8 -8
  36. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_active_list_cost.py +18 -6
  37. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_admin_scaling.py +4 -4
  38. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_enqueue_rtt.py +5 -3
  39. toro_queue-0.4.0/tests/load/test_flows_load.py +75 -0
  40. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_load.py +7 -7
  41. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_promote_blocking.py +4 -4
  42. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_result_fanout.py +1 -1
  43. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_worker_concurrency.py +3 -3
  44. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/unit/test_backoff.py +1 -1
  45. toro_queue-0.4.0/tests/unit/test_flow.py +54 -0
  46. toro_queue-0.4.0/tests/unit/test_histogram.py +64 -0
  47. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/unit/test_job.py +2 -2
  48. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/unit/test_job_options.py +2 -2
  49. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/unit/test_keys.py +16 -1
  50. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/unit/test_scheduler.py +1 -1
  51. {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/__init__.py +10 -3
  52. {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/connection.py +1 -1
  53. toro_queue-0.4.0/toro/flow.py +161 -0
  54. {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/job.py +39 -2
  55. {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/keys.py +41 -3
  56. {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/queue.py +544 -70
  57. {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/scheduler.py +3 -3
  58. toro_queue-0.4.0/toro/scripts.py +797 -0
  59. {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/worker.py +17 -13
  60. {toro_queue-0.2.0 → toro_queue-0.4.0}/uv.lock +1 -1
  61. toro_queue-0.2.0/.pre-commit-config.yaml +0 -18
  62. toro_queue-0.2.0/docs/index.md +0 -20
  63. toro_queue-0.2.0/examples/README.md +0 -19
  64. toro_queue-0.2.0/toro/scripts.py +0 -459
  65. {toro_queue-0.2.0 → toro_queue-0.4.0}/.gitignore +0 -0
  66. {toro_queue-0.2.0 → toro_queue-0.4.0}/.vscode/extensions.json +0 -0
  67. {toro_queue-0.2.0 → toro_queue-0.4.0}/.vscode/settings.json +0 -0
  68. {toro_queue-0.2.0 → toro_queue-0.4.0}/LICENSE +0 -0
  69. {toro_queue-0.2.0 → toro_queue-0.4.0}/bench/bench.py +0 -0
  70. {toro_queue-0.2.0 → toro_queue-0.4.0}/examples/basic.py +0 -0
  71. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_connection.py +0 -0
  72. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_scheduler.py +0 -0
  73. {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/unit/test_priority.py +0 -0
  74. {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/errors.py +0 -0
  75. {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/py.typed +0 -0
@@ -9,7 +9,7 @@ on:
9
9
  pull_request:
10
10
  types: [opened, synchronize, reopened]
11
11
 
12
- # Read-only by default nothing here writes to the repo. SonarCloud PR
12
+ # Read-only by default - nothing here writes to the repo. SonarCloud PR
13
13
  # decoration comes from the SonarCloud GitHub App, not GITHUB_TOKEN write scopes.
14
14
  permissions:
15
15
  contents: read
@@ -68,7 +68,7 @@ jobs:
68
68
  run: uv run pytest -m "unit or integration" --cov=toro --cov-report=xml
69
69
 
70
70
  # Runs only when SONAR_TOKEN is set (skipped on forks / before setup, so the
71
- # check stays green), and only once per matrix one coverage upload.
71
+ # check stays green), and only once per matrix - one coverage upload.
72
72
  # Config is passed inline; there is no sonar-project.properties.
73
73
  - name: SonarCloud scan
74
74
  if: matrix.python-version == '3.13' && env.SONAR_TOKEN
@@ -1,7 +1,7 @@
1
1
  name: Release
2
2
 
3
3
  # A version tag (v*) builds and publishes to PyPI via trusted publishing (OIDC)
4
- # no API tokens stored. The publish job runs in the `pypi` environment, which
4
+ # - no API tokens stored. The publish job runs in the `pypi` environment, which
5
5
  # must match the trusted publisher registered on PyPI.
6
6
  on:
7
7
  push:
@@ -0,0 +1,26 @@
1
+ # All hooks run through `uv run`, so versions come from the lockfile - one
2
+ # source of truth shared by pre-commit, CI and dev, nothing to drift.
3
+ # Install once with: uvx pre-commit install
4
+ repos:
5
+ - repo: local
6
+ hooks:
7
+ - id: ruff-check
8
+ name: ruff check
9
+ entry: uv run ruff check --force-exclude
10
+ language: system
11
+ types_or: [python, pyi]
12
+ require_serial: true
13
+
14
+ - id: ruff-format
15
+ name: ruff format
16
+ entry: uv run ruff format --check --force-exclude
17
+ language: system
18
+ types_or: [python, pyi]
19
+ require_serial: true
20
+
21
+ - id: ty
22
+ name: ty type check
23
+ entry: uv run ty check
24
+ language: system
25
+ pass_filenames: false
26
+ types: [python]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toro-queue
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: An async-first, Redis-backed job queue for Python.
5
5
  Project-URL: Homepage, https://github.com/ilovepixelart/toro
6
6
  Project-URL: Repository, https://github.com/ilovepixelart/toro
@@ -55,12 +55,12 @@ Pairs with **[matador](https://github.com/ilovepixelart/matador)**, a live web d
55
55
 
56
56
  ## Why toro
57
57
 
58
- - **Async-native.** Enqueue and process with `async`/`await` no thread pools,
58
+ - **Async-native.** Enqueue and process with `async`/`await` - no thread pools,
59
59
  no sync bridge. A natural fit for FastAPI, aiohttp, or any asyncio app.
60
60
  - **Atomic by construction.** Claims, retries, promotions and finishes are Lua
61
61
  scripts, so a job can't be lost or double-committed between two round trips.
62
62
  - **At-least-once delivery.** Per-job locks + a background mark-and-sweep recover
63
- jobs from workers that crashed without the visibility-timeout double-delivery
63
+ jobs from workers that crashed - without the visibility-timeout double-delivery
64
64
  trap of some other queues.
65
65
  - **Typed.** Ships `py.typed`; the public API is fully annotated.
66
66
 
@@ -71,13 +71,14 @@ Pairs with **[matador](https://github.com/ilovepixelart/matador)**, a live web d
71
71
  | **Enqueue** | delayed jobs, global **priorities** (FIFO within a band) |
72
72
  | **Retries** | fixed or exponential **backoff**, capped attempts |
73
73
  | **Schedules** | repeatable **cron** and fixed-interval (`every`) jobs |
74
+ | **Flows** | parent/child job trees: fan-out/fan-in, failure policies, flow-aware retry |
74
75
  | **Rate limiting** | queue-wide token bucket shared across all workers |
75
76
  | **Dedup** | custom (idempotent) job ids + a throttle window (`{id, ttl}`) |
76
77
  | **Auto-removal** | keep the last N and/or finished-within-age completed/failed |
77
78
  | **Reliability** | per-job locks, lock renewal, stalled-job recovery |
78
79
  | **Observability** | progress, per-job logs, lifecycle events, `await result()` |
79
80
  | **Lifecycle** | pause / resume, graceful shutdown that drains in-flight jobs |
80
- | **Dashboard** | [matador](https://github.com/ilovepixelart/matador) a live web UI |
81
+ | **Dashboard** | [matador](https://github.com/ilovepixelart/matador) - a live web UI |
81
82
 
82
83
  ## Quick start
83
84
 
@@ -113,6 +114,11 @@ await queue.add("charge", data, job_id="order-1234")
113
114
  # A repeatable schedule (cron or every-N-ms); "run now" with trigger_scheduler
114
115
  await queue.add_scheduler("nightly-rollup", cron="0 0 * * *")
115
116
 
117
+ # A flow: children run first (fan-out), the parent runs on their results (fan-in)
118
+ from toro import FlowChild as c
119
+ report = await queue.add_flow("report", {"q": 3},
120
+ children=[c("fetch", {"shard": i}) for i in range(3)])
121
+
116
122
  # Queue-wide rate limit: at most 100 jobs / second across every worker
117
123
  worker = Worker("emails", process, rate_limit={"max": 100, "duration": 1000})
118
124
 
@@ -121,6 +127,15 @@ job = await queue.add("resize", {"src": "a.png"})
121
127
  print(await job.result(timeout=30))
122
128
  ```
123
129
 
130
+ ## Flows
131
+
132
+ A flow enqueues a parent and its children as one atomic tree. The children run
133
+ first (fan-out, nested arbitrarily); the parent parks until every child has
134
+ settled, then runs and reads their results (fan-in). One primitive covers
135
+ fan-out/fan-in and chained steps, with per-child failure policies and
136
+ flow-aware retry that recovers a whole failed flow in one shot. Full guide:
137
+ [docs/flows.md](docs/flows.md).
138
+
124
139
  ## Develop
125
140
 
126
141
  Managed with [uv](https://astral.sh/uv); the Astral toolchain throughout.
@@ -134,7 +149,7 @@ uv run pytest -m "unit or integration" # tests (integration needs Redis on :63
134
149
  uv run python examples/basic.py
135
150
  ```
136
151
 
137
- The suite is a pyramid `-m unit` (fast, no Redis), `-m integration` (Redis),
152
+ The suite is a pyramid - `-m unit` (fast, no Redis), `-m integration` (Redis),
138
153
  and `-m load` (the open-loop benchmark harness in `tests/load/`).
139
154
 
140
155
  ## License
@@ -28,12 +28,12 @@ Pairs with **[matador](https://github.com/ilovepixelart/matador)**, a live web d
28
28
 
29
29
  ## Why toro
30
30
 
31
- - **Async-native.** Enqueue and process with `async`/`await` no thread pools,
31
+ - **Async-native.** Enqueue and process with `async`/`await` - no thread pools,
32
32
  no sync bridge. A natural fit for FastAPI, aiohttp, or any asyncio app.
33
33
  - **Atomic by construction.** Claims, retries, promotions and finishes are Lua
34
34
  scripts, so a job can't be lost or double-committed between two round trips.
35
35
  - **At-least-once delivery.** Per-job locks + a background mark-and-sweep recover
36
- jobs from workers that crashed without the visibility-timeout double-delivery
36
+ jobs from workers that crashed - without the visibility-timeout double-delivery
37
37
  trap of some other queues.
38
38
  - **Typed.** Ships `py.typed`; the public API is fully annotated.
39
39
 
@@ -44,13 +44,14 @@ Pairs with **[matador](https://github.com/ilovepixelart/matador)**, a live web d
44
44
  | **Enqueue** | delayed jobs, global **priorities** (FIFO within a band) |
45
45
  | **Retries** | fixed or exponential **backoff**, capped attempts |
46
46
  | **Schedules** | repeatable **cron** and fixed-interval (`every`) jobs |
47
+ | **Flows** | parent/child job trees: fan-out/fan-in, failure policies, flow-aware retry |
47
48
  | **Rate limiting** | queue-wide token bucket shared across all workers |
48
49
  | **Dedup** | custom (idempotent) job ids + a throttle window (`{id, ttl}`) |
49
50
  | **Auto-removal** | keep the last N and/or finished-within-age completed/failed |
50
51
  | **Reliability** | per-job locks, lock renewal, stalled-job recovery |
51
52
  | **Observability** | progress, per-job logs, lifecycle events, `await result()` |
52
53
  | **Lifecycle** | pause / resume, graceful shutdown that drains in-flight jobs |
53
- | **Dashboard** | [matador](https://github.com/ilovepixelart/matador) a live web UI |
54
+ | **Dashboard** | [matador](https://github.com/ilovepixelart/matador) - a live web UI |
54
55
 
55
56
  ## Quick start
56
57
 
@@ -86,6 +87,11 @@ await queue.add("charge", data, job_id="order-1234")
86
87
  # A repeatable schedule (cron or every-N-ms); "run now" with trigger_scheduler
87
88
  await queue.add_scheduler("nightly-rollup", cron="0 0 * * *")
88
89
 
90
+ # A flow: children run first (fan-out), the parent runs on their results (fan-in)
91
+ from toro import FlowChild as c
92
+ report = await queue.add_flow("report", {"q": 3},
93
+ children=[c("fetch", {"shard": i}) for i in range(3)])
94
+
89
95
  # Queue-wide rate limit: at most 100 jobs / second across every worker
90
96
  worker = Worker("emails", process, rate_limit={"max": 100, "duration": 1000})
91
97
 
@@ -94,6 +100,15 @@ job = await queue.add("resize", {"src": "a.png"})
94
100
  print(await job.result(timeout=30))
95
101
  ```
96
102
 
103
+ ## Flows
104
+
105
+ A flow enqueues a parent and its children as one atomic tree. The children run
106
+ first (fan-out, nested arbitrarily); the parent parks until every child has
107
+ settled, then runs and reads their results (fan-in). One primitive covers
108
+ fan-out/fan-in and chained steps, with per-child failure policies and
109
+ flow-aware retry that recovers a whole failed flow in one shot. Full guide:
110
+ [docs/flows.md](docs/flows.md).
111
+
97
112
  ## Develop
98
113
 
99
114
  Managed with [uv](https://astral.sh/uv); the Astral toolchain throughout.
@@ -107,7 +122,7 @@ uv run pytest -m "unit or integration" # tests (integration needs Redis on :63
107
122
  uv run python examples/basic.py
108
123
  ```
109
124
 
110
- The suite is a pyramid `-m unit` (fast, no Redis), `-m integration` (Redis),
125
+ The suite is a pyramid - `-m unit` (fast, no Redis), `-m integration` (Redis),
111
126
  and `-m load` (the open-loop benchmark harness in `tests/load/`).
112
127
 
113
128
  ## License
@@ -9,12 +9,13 @@ every job is durable in Redis.
9
9
  ## Atomic state transitions via Lua
10
10
 
11
11
  Every state move (`wait→active`, `active→completed/failed/delayed`,
12
- `delayed→wait`) is a single Redis Lua script, run atomically, so multi-key
12
+ `delayed→wait`, and the flow transitions around `waiting-children` -
13
+ see [Flows](flows.md)) is a single Redis Lua script, run atomically, so multi-key
13
14
  "check-then-act" sequences can't interleave. That removes whole classes of race:
14
15
 
15
- - **pop-then-lock gap** two workers claiming the same job: the claim pops from
16
+ - **pop-then-lock gap** - two workers claiming the same job: the claim pops from
16
17
  the priority set and sets the lock inside one script.
17
- - **finish-after-steal** a worker committing a result for a job a stalled sweep
18
+ - **finish-after-steal** - a worker committing a result for a job a stalled sweep
18
19
  already re-queued: guarded by a token check plus `LREM active` returning 0.
19
20
 
20
21
  Scripts live in `scripts.py`, registered with `redis.asyncio`'s `register_script`.
@@ -23,7 +24,7 @@ The Python side only assembles KEYS/ARGV; the guarantees live in the Lua.
23
24
  ## Claiming a job: the prioritized set + a wakeup marker
24
25
 
25
26
  All waiting jobs live in one `prioritized` ZSET, scored
26
- `(PRIORITY_OFFSET - priority) * 2^32 + seq` a single global order where higher
27
+ `(PRIORITY_OFFSET - priority) * 2^32 + seq` - a single global order where higher
27
28
  priority is more urgent and ties stay FIFO (`seq` is a per-queue counter). This
28
29
  *is* the `wait` state; there is no separate fast-lane list, so a low-priority job
29
30
  can't starve a high-priority one.
@@ -74,20 +75,24 @@ due jobs into the prioritized set.
74
75
 
75
76
  ## Higher-level features
76
77
 
77
- - **Priorities** every job is in the one prioritized ZSET above, so priority is
78
+ - **Priorities** - every job is in the one prioritized ZSET above, so priority is
78
79
  a single global order with no starvation, FIFO within a band.
79
- - **Repeatable / cron** `add_scheduler(every=ms | cron=...)` stores a template
80
+ - **Repeatable / cron** - `add_scheduler(every=ms | cron=...)` stores a template
80
81
  and enqueues the first occurrence as a delayed job; each occurrence mints its
81
82
  successor with a deterministic id when a worker picks it up. `trigger_scheduler`
82
83
  runs one now, `remove_scheduler` stops the chain. See [Scheduling](scheduling.md).
83
- - **Rate limiting** a queue-wide token bucket in Redis
84
+ - **Rate limiting** - a queue-wide token bucket in Redis
84
85
  (`Worker(rate_limit={"max": N, "duration": ms})`), shared by every worker on the
85
86
  queue. An over-limit claim returns a sentinel and the worker waits out the window.
86
- - **Events** Redis pub/sub on an `events` channel (`added`, `progress`,
87
+ - **Events** - Redis pub/sub on an `events` channel (`added`, `progress`,
87
88
  `completed`, `failed`); `Queue.result()` awaits the terminal event and
88
89
  `Worker.on(event, fn)` exposes in-process hooks. See [Concepts](concepts.md).
89
- - **Auto-removal** `remove_on_complete` / `remove_on_fail` (bool / count /
90
+ - **Auto-removal** - `remove_on_complete` / `remove_on_fail` (bool / count /
90
91
  `{count, age}`) enforced inside the finish script, not by a separate sweeper.
92
+ - **Flows** - `add_flow()` creates a parent/child tree atomically; children
93
+ settle into the parent's `:deps` barrier inside the same finish scripts that
94
+ commit their own transitions, so the fan-in resolves on the crash path too.
95
+ See [Flows](flows.md).
91
96
 
92
97
  ## The Lua scripts
93
98
 
@@ -104,40 +109,44 @@ The scripts share a small library of routines:
104
109
  | `acquireNext` | Pops the top prioritized job into `active` and locks it, honoring the rate limit. |
105
110
  | `tryRateLimit` | Token bucket: ms until a token frees, or 0 to proceed. |
106
111
  | `recordFinished` | Records a terminal job in `completed`/`failed` and applies auto-removal. |
112
+ | `settleChildCompleted` / `settleChildFailed` / `releaseParent` | A finishing flow child settles into its parent's `:deps` barrier; the last one releases the parent - or fails it eagerly, per `on_fail`. |
113
+ | `keepArgsFromOpts` | The Lua twin of `JobOptions.keep_args`, for eager parent failures that have no Python caller. |
107
114
 
108
115
  And the scripts themselves:
109
116
 
110
117
  | Script | Caller | Does |
111
118
  |---|---|---|
112
119
  | `ADD_JOB` | producer | Mint/accept an id, write the hash, enqueue or delay, dedup, publish `added`. |
120
+ | `ADD_FLOW` | producer | Create a whole flow tree atomically: leaves enqueued/delayed, parents parked with their `:deps` barrier. |
113
121
  | `MOVE_TO_ACTIVE` | worker wakeup | Claim the next job: `ZPOPMIN prioritized` → `active` → lock + load. |
114
- | `MOVE_TO_COMPLETED` | worker finish | Commit the result and fetch-next in one round trip. |
115
- | `MOVE_TO_FAILED` | worker finish | Retry (to `wait`/`delayed`) or terminally fail, and fetch-next. |
122
+ | `MOVE_TO_COMPLETED` | worker finish | Commit the result (settling a flow child into its parent) and fetch-next in one round trip. |
123
+ | `MOVE_TO_FAILED` | worker finish | Retry (to `wait`/`delayed`) or terminally fail (applying a flow child's `on_fail`), and fetch-next. |
116
124
  | `EXTEND_LOCK` | renewer | Token-guarded lock renewal; clears the job from `stalled`. |
117
125
  | `MOVE_STALLED` | sweep | Mark-and-sweep recovery of jobs whose lock expired. |
118
126
  | `PROMOTE_DELAYED` | promote loop | Move up to `PROMOTE_BATCH` (1000) due delayed jobs to `prioritized`. |
119
127
  | `ADD_SCHEDULED` | scheduler | Enqueue a scheduler occurrence under a deterministic id (idempotent). |
120
- | `PROMOTE_JOB` / `RETRY_JOB` / `REMOVE_JOB` | dashboard | Run a delayed job now / re-enqueue a failed one / delete a job with its lock and logs. |
128
+ | `PROMOTE_JOB` / `RETRY_JOB` / `REMOVE_JOB` | dashboard | Run a delayed job now / re-enqueue a failed one (flow-aware: a parent with unsettled children re-parks, a child re-joins the barrier) / delete a job with its lock, logs and flow aux keys (a flow parent takes its subtree). |
121
129
 
122
130
  ### Lua → Python return protocol
123
131
 
124
132
  Scripts signal outcomes with sentinels the worker decodes:
125
133
 
126
- - `RL_SENTINEL` (`"__rl__"`) a claim hit the rate limiter; the second value is
134
+ - `RL_SENTINEL` (`"__rl__"`) - a claim hit the rate limiter; the second value is
127
135
  ms until a token frees, so the worker waits instead of busy-spinning.
128
- - `LOCK_LOST` (`-2`) a finish ran but the worker no longer held the lock (the
136
+ - `LOCK_LOST` (`-2`) - a finish ran but the worker no longer held the lock (the
129
137
  job was reclaimed); the result is dropped.
130
- - `NOT_ACTIVE` (`-3`) a finish ran but the job was no longer in `active`.
131
- - `OUTCOME_FAILED` (`1`) vs `0` `MOVE_TO_FAILED` telling the worker whether the
138
+ - `NOT_ACTIVE` (`-3`) - a finish ran but the job was no longer in `active`.
139
+ - `OUTCOME_FAILED` (`1`) vs `0` - `MOVE_TO_FAILED` telling the worker whether the
132
140
  job terminally failed or will retry.
133
141
 
134
142
  Scores are packed under 2^53 (`PRIORITY_OFFSET = 2^20`, `SEQ_MOD = 2^32`) so ZSET
135
- double scores stay exact, and the scripts use only plain JSON and integer ARGV
136
- no `cmsgpack` / `bit` / `cjson` so they run on any Redis build.
143
+ double scores stay exact, and the scripts use only plain JSON and integer ARGV with the built-in
144
+ `cjson` for encode/decode - no `cmsgpack` / `bit` - so they run on any
145
+ Redis build.
137
146
 
138
147
  ## Python-specific choices
139
148
 
140
- - **async-first** `redis.asyncio`, `async def` processors, one event loop;
149
+ - **async-first** - `redis.asyncio`, `async def` processors, one event loop;
141
150
  concurrency is N `asyncio` tasks sharing the loop.
142
- - **Cluster** a `{braces}` hash-tag in the prefix keeps all of a queue's keys on
151
+ - **Cluster** - a `{braces}` hash-tag in the prefix keeps all of a queue's keys on
143
152
  one slot, which the multi-key Lua scripts require.
@@ -20,7 +20,7 @@ toro has a clean producer/consumer split, and both talk to the same Redis.
20
20
  bookkeeping the system fills in: `state`, `attempts_made`, timestamps
21
21
  (`timestamp`, `processed_on`, `finished_on`), `progress`, `stacktrace`, and
22
22
  either a `returnvalue` or a `failed_reason`. (A job's log lines and its lock
23
- live in separate Redis keys, not as fields on the `Job` see the
23
+ live in separate Redis keys, not as fields on the `Job` - see the
24
24
  [data model](data-model.md).)
25
25
 
26
26
  Producers and consumers never call each other. They coordinate only through
@@ -37,15 +37,17 @@ type, `JobState`:
37
37
  | `wait` | Ready to run, waiting for a free worker. (Stored in the priority-ordered set, so "wait" and "prioritized" are the same place.) |
38
38
  | `delayed` | Scheduled for the future; not yet runnable. Promoted to `wait` when due. |
39
39
  | `active` | Claimed by a worker and currently running. |
40
+ | `waiting-children` | A flow parent, parked until every child settles; released to `wait` by its last child. |
40
41
  | `completed` | Finished successfully; `returnvalue` holds the result. |
41
42
  | `failed` | Exhausted its retry attempts; `failed_reason` holds the error. |
42
43
 
43
44
  The normal path is `wait → active → completed`. A failure with retries left goes
44
45
  `active → wait` (or `active → delayed`, if a backoff delay applies) and tries
45
46
  again; only after the last attempt does it land in `failed`. A delayed or
46
- repeatable job starts in `delayed`. See [Job lifecycle](architecture.md) for the
47
- exact transitions and [Producing jobs](producing.md) for how delay and retries
48
- are configured.
47
+ repeatable job starts in `delayed`; a flow parent starts in `waiting-children`
48
+ (see [Flows](flows.md)). See [Job lifecycle](architecture.md) for the exact
49
+ transitions and [Producing jobs](producing.md) for how delay and retries are
50
+ configured.
49
51
 
50
52
  ## Workers vs. slots
51
53
 
@@ -91,7 +93,7 @@ not on a retry. Two things consume the channel:
91
93
 
92
94
  `Worker.on(event, fn)` lets a worker react to its own lifecycle with in-process
93
95
  callbacks (`completed`, `failed`, `retrying`, `stalled`, `lock-lost`,
94
- `rate-limited`) separate from the pub/sub channel above. See
96
+ `rate-limited`) - separate from the pub/sub channel above. See
95
97
  [Processing jobs](processing.md).
96
98
 
97
99
  ## Reliability in one sentence
@@ -29,6 +29,7 @@ Redis Cluster slot, which the multi-key Lua scripts require.
29
29
  | `delayed` | ZSET | Ids scored by their process-at timestamp (ms); promoted to `prioritized` when due. |
30
30
  | `completed` | ZSET | Successfully-finished ids, scored by finish time (for auto-removal + listing). |
31
31
  | `failed` | ZSET | Terminally-failed ids, scored by finish time. |
32
+ | `waiting-children` | ZSET | Flow parents parked until their children settle, scored by enqueue time. |
32
33
  | `meta-paused` | string (flag) | Exists only while the queue is paused; workers stop claiming new jobs. |
33
34
  | `events` | pub/sub channel | Carries `added` / `progress` / `completed` / `failed`; drives `result()` and live dashboards. |
34
35
  | `limiter` | HASH | The queue-wide rate-limit token bucket (`{tokens, ts}`), shared by every worker. |
@@ -37,6 +38,8 @@ Redis Cluster slot, which the multi-key Lua scripts require.
37
38
  | `repeat` | ZSET | Scheduler id -> next-run timestamp. |
38
39
  | `workers` | ZSET | Live worker id -> last-heartbeat ms; stale entries pruned lazily on read. |
39
40
  | `departed` | LIST (capped) | Recent worker departures: graceful `stopped` or `lost` (crashed). |
41
+ | `metrics:<minute>` | HASH | Per-minute counters (`added`/`completed`/`failed`/`ms`, per-name fields, histograms); self-expiring. |
42
+ | `de:<dedupId>` | string (PX) | A live deduplication throttle window; holds the already-queued job's id. |
40
43
 
41
44
  ## Per-scheduler, per-worker, per-job keys
42
45
 
@@ -44,9 +47,12 @@ Redis Cluster slot, which the multi-key Lua scripts require.
44
47
  |---|---|---|
45
48
  | `repeat:<schedulerId>` | HASH | A scheduler's template: `name`, `every`/`cron`, `data`, `opts`. |
46
49
  | `worker:<workerId>` | HASH | A worker's presence record: host, pid, concurrency, current jobs, processed/failed counts, state. |
47
- | `<jobId>` | HASH | The job itself: `name`, `data`, `opts`, `state`, `attemptsMade`, timestamps, `returnvalue`/`failedReason`, `progress`, `stacktrace`, ... |
50
+ | `<jobId>` | HASH | The job itself: `name`, `data`, `opts`, `state`, `attemptsMade`, timestamps, `returnvalue`/`failedReason`, `progress`, `stacktrace`, plus flow linkage on flow jobs: `parentId`/`onFail` (children), `children` (parents). |
48
51
  | `<jobId>:lock` | string (token, PX) | The per-job lock: the owning worker's token with an expiry. Only the holder may finish or renew it. |
49
52
  | `<jobId>:logs` | LIST | Log lines appended by `job.log(...)` from inside a processor. |
53
+ | `<jobId>:deps` | SET | A flow parent's still-pending child ids - the fan-in barrier; the parent releases when it empties. |
54
+ | `<jobId>:results` | HASH | Child id → returnvalue JSON, written as each child completes. |
55
+ | `<jobId>:cfail` | HASH | Child id → failure reason for children failed under `on_fail="continue"`. |
50
56
 
51
57
  Note the job hash key is just `<prefix>:<name>:<jobId>` (no extra segment), so a job
52
58
  `5` on `toro:emails:` is the hash `toro:emails:5`, with `toro:emails:5:lock` and
@@ -54,9 +60,11 @@ Note the job hash key is just `<prefix>:<name>:<jobId>` (no extra segment), so a
54
60
 
55
61
  ## How the pieces connect
56
62
 
57
- - A job moves between `prioritized` / `active` / `delayed` / `completed` / `failed`
58
- as its state changes; the move and the hash update happen in one Lua script. See
59
- [Architecture](architecture.md).
63
+ - A job moves between `prioritized` / `active` / `delayed` / `waiting-children` /
64
+ `completed` / `failed` as its state changes; the move and the hash update happen
65
+ in one Lua script. See [Architecture](architecture.md).
66
+ - `:deps` + `:results` + `:cfail` are the flow fan-in machinery - children settle
67
+ into them as they finish. See [Flows](flows.md).
60
68
  - The `lock` + `stalled` keys are the at-least-once machinery. See
61
69
  [Reliability](reliability.md).
62
70
  - `repeat` + `repeat:<id>` drive [scheduling](scheduling.md); `workers` +
@@ -0,0 +1,192 @@
1
+ # Flows: design notes
2
+
3
+ > Status: SHIPPED. This documents the design decisions and the research behind
4
+ > them; the user guide is [flows.md](flows.md).
5
+
6
+ A flow is a parent job enqueued together with its children. Children run first
7
+ (in parallel, nesting allowed); the parent becomes runnable only when every
8
+ child has settled. This gives fan-out/fan-in ("fetch 10 parts, then summarize")
9
+ and chains ("A, then B") with one primitive.
10
+
11
+ ## Why this design: lessons from the landscape
12
+
13
+ Every mature queue eventually grew a dependency story, and each one left a
14
+ public trail of what went wrong. Before building, we surveyed the dependency /
15
+ workflow features of the established queues across the Node.js, Python and
16
+ Postgres ecosystems - both how they model things and what their issue trackers
17
+ say users keep hitting. The design below is shaped by that trail.
18
+
19
+ What the survey says, distilled:
20
+
21
+ 1. **Fan-in barriers die at the crash boundary** unless the bookkeeping is
22
+ atomic with the child's settle. Counter- and callback-based barriers
23
+ coordinated from the client side have produced a decade of "the callback
24
+ never fired" bugs: a worker killed mid-job, a nested group counted wrong,
25
+ a result expiring before the barrier read it. toro already funnels every
26
+ settle through one Lua script - the barrier belongs inside it, crash path
27
+ included.
28
+ 2. **Implicit result injection ages badly.** Passing the previous job's return
29
+ value as a magic argument (first or last position, depending on the
30
+ framework) always grows an opt-out flag and confuses arity forever.
31
+ Explicit pull by handle is the shape that aged well - provided the
32
+ ergonomics are good; "fetch the dependency object and read `.result` off
33
+ it" is the unergonomic version users complain about.
34
+ 3. **"Wait forever" as a failure default is universally hated.** Two major
35
+ queues default to leaving the dependent/parent parked indefinitely when a
36
+ dependency fails terminally, silently; in both, it's the single most
37
+ complained-about behavior of the feature. Failure must propagate by
38
+ default.
39
+ 4. **Graph state lives in the datastore, not the message.** Serializing the
40
+ remaining workflow into message headers means bloat, no nesting, and no
41
+ visibility (one framework's own redesign RFC concedes exactly this). Keys
42
+ in the datastore are also the only way a dashboard can show a flow.
43
+ 5. **Cleanup must be structural.** The most mature implementation leaks its
44
+ children-results hashes (no TTL, surviving auto-removal - tens of
45
+ thousands of stale keys reported in production) because aux keys are
46
+ cleaned on a separate path from job removal. Cleanup has to ride the
47
+ removal paths that already exist.
48
+ 6. **Eager beats lazy on parent failure.** Failing the parent "lazily" -
49
+ marking it and waiting for a worker on the parent's queue to actually
50
+ transition it - surprises people whenever no such worker exists. The
51
+ parent should fail in the same atomic step as the child.
52
+ 7. **Failure policy frozen at enqueue time** (denormalized into the stored
53
+ job) is a recurring complaint: teams want to change their minds after the
54
+ flow exists. Storing the policy as a plain mutable field costs nothing.
55
+
56
+ ## Design
57
+
58
+ ### Scope and shape
59
+
60
+ - **Trees, not DAGs.** A child has exactly one parent. Multi-parent is rare,
61
+ expensive, and even the most mature implementation declined it;
62
+ fan-out/fan-in plus nesting covers the real use cases.
63
+ - **Single queue per flow (v1).** toro workers dispatch on `job.name` inside
64
+ one processor, so flow steps are just different names on one queue. This
65
+ removes the cross-queue hazard class outright (scripts reaching into other
66
+ queues' keys; a purged children's queue stranding parents elsewhere).
67
+ Cross-queue can be revisited later without breaking the model.
68
+ - **Static shape.** The tree is declared at enqueue time. Children spawned
69
+ dynamically from inside a processor is the messiest corner of the systems
70
+ that have it, and is out of scope.
71
+
72
+ ### API
73
+
74
+ ```python
75
+ from toro import FlowChild as c # name, data, opts, children, on_fail
76
+
77
+ parent: Job = await queue.add_flow(name, data, children=[...], **opts)
78
+ ```
79
+
80
+ - `add_flow` enqueues the whole tree **atomically** (one Lua call) and returns
81
+ the parent `Job`. `await parent.result()` resolves when the flow does.
82
+ - Children accept the same options as `Queue.add()` (priority, attempts,
83
+ backoff, delay, auto-removal), plus `on_fail` (below). Scheduler-style
84
+ options, custom ids and deduplication are not valid on flow nodes.
85
+ - Inside the parent's processor, results are **pulled explicitly**:
86
+
87
+ ```python
88
+ async def process(job):
89
+ if job.name == "report":
90
+ results = await job.children_results() # {child_id: returnvalue}
91
+ failures = await job.failed_children() # {child_id: failed_reason}
92
+ ```
93
+
94
+ - Introspection: `await queue.get_flow(parent_id)` returns the tree
95
+ (`{job, children: [...]}`), for the dashboard and for users.
96
+
97
+ ### Failure semantics: two policies, no third
98
+
99
+ Per-child `on_fail`, stored as a plain mutable field on the child's hash (so
100
+ tooling *can* change it after enqueue - see lesson 7):
101
+
102
+ | `on_fail` | when this child terminally fails |
103
+ | ----------------------- | ------------------------------------------------------ |
104
+ | `"fail_parent"` (default) | the parent fails **immediately and eagerly**, in the same Lua script, recursively up ancestors that also default; no worker on the parent required |
105
+ | `"continue"` | the failure is recorded in the parent's failures hash, the dependency cleared; the parent runs once all children settle and inspects `failed_children()` |
106
+
107
+ There is deliberately **no "wait indefinitely" option** (lesson 3). Retries
108
+ still happen first: "terminally fails" means after the child's own `attempts`
109
+ are exhausted (or it stalls past `max_stalled_count`). A child retried to
110
+ success *after* its parent already failed does not resurrect the parent
111
+ (documented, v1); retrying the parent re-arms its barrier instead.
112
+
113
+ ### Data model
114
+
115
+ New job state `waiting-children` (a sixth `JobState`), backed by a per-queue
116
+ ZSET (timestamp-scored, like `completed`/`failed`), surfaced in `counts()`,
117
+ `get_jobs()`, `clean()` and the dashboard.
118
+
119
+ Per parent job, three aux keys (joining `:lock` / `:logs`):
120
+
121
+ | key | type | content |
122
+ | --------------- | ---- | -------------------------------------------------- |
123
+ | `{id}:deps` | SET | ids of children not yet settled (the barrier) |
124
+ | `{id}:results` | HASH | child id → returnvalue JSON (completed children) |
125
+ | `{id}:cfail` | HASH | child id → failed reason (`on_fail="continue"` children) |
126
+
127
+ Plus two hash fields: `parentId` on every child; `children` (static JSON id
128
+ list) on every parent - the deps set shrinks, the dashboard tree needs the
129
+ full picture.
130
+
131
+ A SET rather than a counter: it's idempotent under re-delivery, inspectable
132
+ ("which children is this parent still waiting on?"), and the empty-check
133
+ (`SCARD == 0`) is the release condition. The counter-corruption bug class
134
+ (lesson 1) is the argument against counters.
135
+
136
+ ### Mechanics
137
+
138
+ - **`ADD_FLOW`** (new script): tree as JSON in ARGV, `cjson.decode`, walk
139
+ depth-first; leaves enqueue into `prioritized` (or `delayed`), interior
140
+ nodes land in `waiting-children` with their `:deps` set populated. Capped
141
+ (~1000 nodes per flow) to bound script time, like `PROMOTE_BATCH`. One
142
+ `added` increment of the node count and one announce (the root id) for
143
+ the whole tree.
144
+ - **Release** lives inside the existing finish scripts, the extension point
145
+ `_LIB` reserved ("to add markers-with-delay or grouping later, we change
146
+ only these functions"):
147
+ - `MOVE_TO_COMPLETED` of a child: `HSET parent:results`, `SREM parent:deps`;
148
+ on empty, move the parent from `waiting-children` through the shared
149
+ `enqueue()` at its stored priority.
150
+ - `MOVE_TO_FAILED` terminal branch: apply `on_fail` - either record into
151
+ `:cfail` + `SREM` (+ release if last), or fail the parent now through
152
+ `recordFinished` (so `remove_on_fail` retention applies), publish the
153
+ event, and **loop upward** while the ancestor itself has a parent with
154
+ `fail_parent`.
155
+ - **The stalled path gets identical parent bookkeeping**: `MOVE_STALLED`'s
156
+ fail-branch runs the same settle logic (lesson 1: the crash path is where
157
+ barriers historically break). One pre-existing nuance: the stall-escalated
158
+ child itself is recorded into `failed` without `recordFinished`, so its
159
+ own `remove_on_fail` retention does not apply on the stall path - true
160
+ for all stalled jobs, not just flow children.
161
+ - **Cleanup is structural** (lesson 5): `delJobs` and `REMOVE_JOB` know the
162
+ three aux keys, so every existing removal path (auto-removal
163
+ keepCount/keepAge, manual remove, `clean()`) deletes them for free.
164
+ Removing a parent removes its subtree; removing a child SREMs it from its
165
+ parent's deps (and releases the parent if it was the last). Settle writes
166
+ are guarded by a parent-exists check so a retention-trimmed parent can't
167
+ 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.
172
+
173
+ ### Edge cases pinned down (each has a test)
174
+
175
+ - **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`.
178
+ - **`remove_on_complete` on children**: allowed - the result is copied into
179
+ the parent's `:results` at completion, so the child hash is free to go.
180
+ Routine `clean("completed")` likewise never touches a pending parent's
181
+ collected results.
182
+ - **Empty `children=[]`**: rejected (`ValueError`) - that's `add()`.
183
+ - **Id rules**: flow nodes get server-side ids; custom ids on flow nodes are
184
+ not supported in v1 (id reuse inside trees is a documented zombie-job
185
+ hazard elsewhere).
186
+ - **Progress display counts completions only** - a failed flow must never
187
+ read as 100% done.
188
+
189
+ ## Out of scope (v1)
190
+
191
+ Cross-queue flows, DAGs/multi-parent, dynamic children from inside a
192
+ processor, and child-retry-resurrects-failed-parent.