toro-queue 0.3.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.3.0 → toro_queue-0.5.0}/.gitignore +2 -0
  2. {toro_queue-0.3.0 → toro_queue-0.5.0}/PKG-INFO +16 -1
  3. {toro_queue-0.3.0 → toro_queue-0.5.0}/README.md +15 -0
  4. {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/architecture.md +15 -6
  5. {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/concepts.md +5 -3
  6. {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/data-model.md +12 -4
  7. toro_queue-0.5.0/docs/flows-design.md +196 -0
  8. toro_queue-0.5.0/docs/flows.md +190 -0
  9. {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/index.md +8 -1
  10. {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/processing.md +11 -0
  11. {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/producing.md +6 -3
  12. {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/reliability.md +3 -1
  13. {toro_queue-0.3.0 → toro_queue-0.5.0}/pyproject.toml +1 -1
  14. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/conftest.py +19 -0
  15. toro_queue-0.5.0/tests/integration/test_concurrency_stress.py +99 -0
  16. toro_queue-0.5.0/tests/integration/test_fault_injection.py +163 -0
  17. toro_queue-0.5.0/tests/integration/test_flows.py +388 -0
  18. toro_queue-0.5.0/tests/integration/test_flows_edges.py +836 -0
  19. toro_queue-0.5.0/tests/integration/test_invariants.py +197 -0
  20. toro_queue-0.5.0/tests/integration/test_leaks.py +151 -0
  21. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_metrics.py +93 -0
  22. toro_queue-0.5.0/tests/integration/test_roots.py +194 -0
  23. toro_queue-0.5.0/tests/load/test_flows_load.py +75 -0
  24. toro_queue-0.5.0/tests/unit/test_flow.py +54 -0
  25. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_keys.py +15 -0
  26. {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/__init__.py +7 -2
  27. toro_queue-0.5.0/toro/flow.py +161 -0
  28. {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/job.py +38 -1
  29. {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/keys.py +32 -0
  30. {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/queue.py +398 -80
  31. {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/scripts.py +304 -25
  32. {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/worker.py +2 -0
  33. {toro_queue-0.3.0 → toro_queue-0.5.0}/uv.lock +1 -1
  34. {toro_queue-0.3.0 → toro_queue-0.5.0}/.github/workflows/pr-check.yaml +0 -0
  35. {toro_queue-0.3.0 → toro_queue-0.5.0}/.github/workflows/release.yml +0 -0
  36. {toro_queue-0.3.0 → toro_queue-0.5.0}/.pre-commit-config.yaml +0 -0
  37. {toro_queue-0.3.0 → toro_queue-0.5.0}/.vscode/extensions.json +0 -0
  38. {toro_queue-0.3.0 → toro_queue-0.5.0}/.vscode/settings.json +0 -0
  39. {toro_queue-0.3.0 → toro_queue-0.5.0}/LICENSE +0 -0
  40. {toro_queue-0.3.0 → toro_queue-0.5.0}/bench/bench.py +0 -0
  41. {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/scheduling.md +0 -0
  42. {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/security.md +0 -0
  43. {toro_queue-0.3.0 → toro_queue-0.5.0}/examples/README.md +0 -0
  44. {toro_queue-0.3.0 → toro_queue-0.5.0}/examples/basic.py +0 -0
  45. {toro_queue-0.3.0 → toro_queue-0.5.0}/examples/stalled.py +0 -0
  46. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_admin.py +0 -0
  47. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_admin_ordering.py +0 -0
  48. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_connection.py +0 -0
  49. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_finished_retention.py +0 -0
  50. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_introspection.py +0 -0
  51. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_processing.py +0 -0
  52. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_reliability.py +0 -0
  53. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_result_dispatcher.py +0 -0
  54. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_retries.py +0 -0
  55. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_scheduler.py +0 -0
  56. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_worker_resilience.py +0 -0
  57. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_workers.py +0 -0
  58. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/harness.py +0 -0
  59. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_active_list_cost.py +0 -0
  60. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_admin_scaling.py +0 -0
  61. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_enqueue_rtt.py +0 -0
  62. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_load.py +0 -0
  63. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_promote_blocking.py +0 -0
  64. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_result_fanout.py +0 -0
  65. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_worker_concurrency.py +0 -0
  66. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_backoff.py +0 -0
  67. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_histogram.py +0 -0
  68. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_job.py +0 -0
  69. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_job_options.py +0 -0
  70. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_priority.py +0 -0
  71. {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_scheduler.py +0 -0
  72. {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/connection.py +0 -0
  73. {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/errors.py +0 -0
  74. {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/py.typed +0 -0
  75. {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/scheduler.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.3.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
@@ -71,6 +71,7 @@ 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 |
@@ -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.
@@ -44,6 +44,7 @@ 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 |
@@ -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.
@@ -9,7 +9,8 @@ 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
16
  - **pop-then-lock gap** - two workers claiming the same job: the claim pops from
@@ -88,6 +89,10 @@ due jobs into the prioritized set.
88
89
  `Worker.on(event, fn)` exposes in-process hooks. See [Concepts](concepts.md).
89
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,20 +109,23 @@ 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
 
@@ -132,8 +140,9 @@ Scripts signal outcomes with sentinels the worker decodes:
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
 
@@ -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
 
@@ -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,196 @@
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 and pulls its
112
+ failed children along instead.
113
+
114
+ ### Data model
115
+
116
+ New job state `waiting-children` (a sixth `JobState`), backed by a per-queue
117
+ ZSET (timestamp-scored, like `completed`/`failed`), surfaced in `counts()`,
118
+ `get_jobs()`, `clean()` and the dashboard.
119
+
120
+ Per parent job, three aux keys (joining `:lock` / `:logs`):
121
+
122
+ | key | type | content |
123
+ | --------------- | ---- | -------------------------------------------------- |
124
+ | `{id}:deps` | SET | ids of children not yet settled (the barrier) |
125
+ | `{id}:results` | HASH | child id → returnvalue JSON (completed children) |
126
+ | `{id}:cfail` | HASH | child id → failed reason (`on_fail="continue"` children) |
127
+
128
+ Plus two hash fields: `parentId` on every child; `children` (static JSON id
129
+ list) on every parent - the deps set shrinks, the dashboard tree needs the
130
+ full picture.
131
+
132
+ A SET rather than a counter: it's idempotent under re-delivery, inspectable
133
+ ("which children is this parent still waiting on?"), and the empty-check
134
+ (`SCARD == 0`) is the release condition. The counter-corruption bug class
135
+ (lesson 1) is the argument against counters.
136
+
137
+ ### Mechanics
138
+
139
+ - **`ADD_FLOW`** (new script): tree as JSON in ARGV, `cjson.decode`, walk
140
+ depth-first; leaves enqueue into `prioritized` (or `delayed`), interior
141
+ nodes land in `waiting-children` with their `:deps` set populated. Capped
142
+ (~1000 nodes per flow) to bound script time, like `PROMOTE_BATCH`. One
143
+ `added` increment of the node count and one announce (the root id) for
144
+ the whole tree.
145
+ - **Release** lives inside the existing finish scripts, the extension point
146
+ `_LIB` reserved ("to add markers-with-delay or grouping later, we change
147
+ only these functions"):
148
+ - `MOVE_TO_COMPLETED` of a child: `HSET parent:results`, `SREM parent:deps`;
149
+ on empty, move the parent from `waiting-children` through the shared
150
+ `enqueue()` at its stored priority.
151
+ - `MOVE_TO_FAILED` terminal branch: apply `on_fail` - either record into
152
+ `:cfail` + `SREM` (+ release if last), or fail the parent now through
153
+ `recordFinished` (so `remove_on_fail` retention applies), publish the
154
+ event, and **loop upward** while the ancestor itself has a parent with
155
+ `fail_parent`.
156
+ - **The stalled path gets identical parent bookkeeping**: `MOVE_STALLED`'s
157
+ fail-branch runs the same settle logic (lesson 1: the crash path is where
158
+ barriers historically break). One pre-existing nuance: the stall-escalated
159
+ child itself is recorded into `failed` without `recordFinished`, so its
160
+ own `remove_on_fail` retention does not apply on the stall path - true
161
+ for all stalled jobs, not just flow children.
162
+ - **Cleanup is structural** (lesson 5): `delJobs` and `REMOVE_JOB` know the
163
+ three aux keys, so every existing removal path (auto-removal
164
+ keepCount/keepAge, manual remove, `clean()`) deletes them for free.
165
+ Removing a parent removes its subtree; removing a child SREMs it from its
166
+ parent's deps (and releases the parent if it was the last). Settle writes
167
+ are guarded by a parent-exists check so a retention-trimmed parent can't
168
+ get orphan keys recreated by late siblings.
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.
175
+
176
+ ### Edge cases pinned down (each has a test)
177
+
178
+ - **Parent retries**: a released parent is a normal job; its own
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`.
182
+ - **`remove_on_complete` on children**: allowed - the result is copied into
183
+ the parent's `:results` at completion, so the child hash is free to go.
184
+ Routine `clean("completed")` likewise never touches a pending parent's
185
+ collected results.
186
+ - **Empty `children=[]`**: rejected (`ValueError`) - that's `add()`.
187
+ - **Id rules**: flow nodes get server-side ids; custom ids on flow nodes are
188
+ not supported in v1 (id reuse inside trees is a documented zombie-job
189
+ hazard elsewhere).
190
+ - **Progress display counts completions only** - a failed flow must never
191
+ read as 100% done.
192
+
193
+ ## Out of scope (v1)
194
+
195
+ Cross-queue flows, DAGs/multi-parent, dynamic children from inside a
196
+ processor, and child-retry-resurrects-failed-parent.
@@ -0,0 +1,190 @@
1
+ # Flows
2
+
3
+ A flow is a parent job enqueued atomically with its children. The children run
4
+ first (in parallel, nested arbitrarily within the 1000-node cap); the parent
5
+ is parked in the `waiting-children` state and runs only once every child has
6
+ settled. One
7
+ primitive covers fan-out/fan-in ("fetch 10 shards, then merge") and chains
8
+ ("build, then deploy").
9
+
10
+ > Why the API and the failure defaults look the way they do - including the
11
+ > failure modes other queues' dependency features taught us to avoid - is
12
+ > documented in [the design notes](flows-design.md).
13
+
14
+ ## Enqueue a flow
15
+
16
+ ```python
17
+ from toro import FlowChild as c
18
+
19
+ parent = await queue.add_flow(
20
+ "report", {"period": "2026-06"},
21
+ children=[
22
+ c("fetch", {"shard": 1}),
23
+ c("fetch", {"shard": 2}),
24
+ c("summarize", {}, children=[c("fetch", {"shard": 3})]), # nesting
25
+ ],
26
+ attempts=3, # parent options: add()'s, minus job_id/deduplication
27
+ )
28
+ result = await parent.result() # resolves when the WHOLE flow does
29
+ ```
30
+
31
+ If the flow fails, `result()` raises `JobFailedError` naming the failing
32
+ child (`"child 7 failed: ..."`). The default `timeout` is 30s - size it for
33
+ the whole flow, not one job.
34
+
35
+ `add_flow` inserts the entire tree in one atomic script - either the whole
36
+ flow exists or none of it. Leaves go straight to `wait` (or `delayed`, if they
37
+ carry a `delay`); every node with children parks in `waiting-children`.
38
+
39
+ `FlowChild` takes the same options as `Queue.add()` (`priority` - clamped to
40
+ the same range as `add()` - `attempts`, `backoff`, `delay`, auto-removal),
41
+ plus `on_fail` (below). A few options are
42
+ deliberately not valid on flow nodes: custom `job_id`s and `deduplication`
43
+ (both invite id-reuse hazards inside trees), and `delay` on a node that has
44
+ children (it runs when its children settle, not on a clock).
45
+
46
+ ## Reading children results
47
+
48
+ The parent pulls its children's results explicitly - there is no implicit
49
+ argument injection to reason backwards from:
50
+
51
+ ```python
52
+ async def process(job):
53
+ if job.name == "report":
54
+ results = await job.children_results() # {child_id: returnvalue}
55
+ failures = await job.failed_children() # {child_id: reason}
56
+ return summarize(results, failures)
57
+ ```
58
+
59
+ A child's result is copied into the parent at the moment the child completes,
60
+ so children are free to use `remove_on_complete` - the parent's copy survives,
61
+ and so does routine history cleanup (`clean("completed")`).
62
+
63
+ ## When a child fails
64
+
65
+ Each child's `on_fail` says what its *terminal* failure (after its own
66
+ retries) does to the parent:
67
+
68
+ | `on_fail` | behavior |
69
+ |---|---|
70
+ | `"fail_parent"` (default) | The parent fails **immediately** - in the same atomic step, no worker needed - and the failure walks up through ancestors that also default. |
71
+ | `"continue"` | The failure is recorded; the parent still runs once every child has settled and inspects `failed_children()`. |
72
+
73
+ There is deliberately no "wait forever" option: a flow always settles. The
74
+ crash path counts too - a child whose worker died and that exhausts the
75
+ stalled-recovery limit settles its parent the same way.
76
+
77
+ Eager parent failure does **not** cancel siblings: in-flight and still-queued
78
+ children keep running, and their results are still collected into the parent
79
+ (useful if you later retry it). To actually stop the remaining work, remove
80
+ the parent - removal cascades the subtree.
81
+
82
+ ## Retrying a failed flow
83
+
84
+ Retry is flow-aware:
85
+
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.
90
+ - Retrying a failed **child** re-joins its parked parent's barrier and clears
91
+ the stale entry from the parent's failure report.
92
+
93
+ So `retry_all_failed()` - or the dashboard's *retry all* - recovers an entire
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.
97
+
98
+ To recover *one* flow without touching the rest of the queue, use
99
+ `retry_flow(parent_id)`: it retries every failed job in that subtree, root
100
+ first, so a re-parked parent is ready before its children re-join the barrier -
101
+ order-independent, like retry-all but scoped. It returns the number of jobs
102
+ retried. The dashboard's *retry flow* button on a failed parent calls it.
103
+
104
+ ## Removing flow jobs
105
+
106
+ - Removing a **parent** removes its whole subtree - children included, even
107
+ ones currently running. This is how you cancel a flow. (A running child's
108
+ processor coroutine is not interrupted; it finishes and its commit is then
109
+ discarded by the lock-token guard.)
110
+ - Removing a pending **child** releases the parent if it was the last thing
111
+ being waited on. A removed completed child does *not* take its
112
+ already-collected result with it.
113
+ - `clean("waiting-children")` therefore cancels every parked flow outright.
114
+
115
+ All removal paths (manual, bulk, auto-removal) clean up the flow bookkeeping
116
+ keys with the job - nothing is left behind to leak.
117
+
118
+ ## Introspection
119
+
120
+ ```python
121
+ flow = await queue.get_flow(parent_id) # {"job": Job, "children": [...]}
122
+ results = await queue.children_results(parent_id)
123
+ failures = await queue.failed_children(parent_id)
124
+ ```
125
+
126
+ `get_flow` hydrates the tree breadth-first, one pipelined round trip per
127
+ level, down to `depth` levels (default 10; `depth=0` returns just the root
128
+ node) - pass a larger `depth` for deeper trees. `Job.parent_id` and
129
+ `Job.children_ids` expose flow membership on any loaded job.
130
+
131
+ For a dashboard detail view, `flow_view(parent_id)` folds those three reads
132
+ into one `FlowView`:
133
+
134
+ ```python
135
+ view = await queue.flow_view(parent_id)
136
+ view.tree # same {"job": Job, "children": [...]} shape
137
+ view.results, view.failures # collected results + tolerated failures
138
+ view.total, view.done, view.failed # fan-in counts (completions only)
139
+ view.live # True while any node is still non-terminal
140
+ ```
141
+
142
+ It costs the tree's O(depth) round trips plus one pipelined read of the
143
+ parent's result/failure hashes (not three separate calls), and `done`/`failed`
144
+ count completions only - a failed flow never reads as done. Returns `None` if
145
+ the job doesn't exist.
146
+
147
+ To show fan-in progress for a whole *page* of parents without hydrating each
148
+ tree, `flow_progress(parent_ids)` returns `{parent_id: (completed, failed)}` from
149
+ cheap pipelined `HLEN` reads (just the counts):
150
+
151
+ ```python
152
+ progress = await queue.flow_progress([p1, p2, p3]) # {p1: (2, 0), p2: (1, 1), ...}
153
+ ```
154
+
155
+ [matador](https://github.com/ilovepixelart/matador) renders a flow as its root
156
+ job moving through the normal state tabs (children hidden, shown only in the
157
+ tree), with a recursive tree and fan-in progress on the job detail.
158
+
159
+ ## Metrics and events
160
+
161
+ Enqueueing a flow increments the queue's `added` counter by the node count
162
+ and publishes a single `added` event carrying the root's id. On failures,
163
+ every terminally-failed child increments the per-job `failed` counter
164
+ (tolerated `continue` failures included), and each eagerly-failed ancestor
165
+ increments it again - so one leaf failure in a deep `fail_parent` chain
166
+ produces several `failed` increments and events. That counter is about *jobs*.
167
+
168
+ Whole flows are counted in their own right, one unit per **root** flow as it
169
+ settles (nested sub-flows don't double count):
170
+
171
+ ```python
172
+ points = await queue.flow_metrics(minutes=60) # per-minute completed/failed
173
+ pcts = await queue.flow_percentiles(minutes=60) # end-to-end p50/p95/p99 (ms)
174
+ ```
175
+
176
+ `flow_percentiles` measures the whole flow's wall clock - enqueue to the root
177
+ finishing - which the per-job duration never captures (a flow that fans out
178
+ wide finishes long after any single job's runtime). The dashboard charts this
179
+ as a throughput strip on the active tab. Both reads zero-fill and share the
180
+ 8h metrics retention.
181
+
182
+ ## Limits
183
+
184
+ - One queue per flow: steps are job names on the same queue, dispatched by
185
+ your processor. (Cross-queue flows are a possible future, not a v1 feature.)
186
+ - Trees, not DAGs: a child has exactly one parent.
187
+ - The shape is declared at enqueue time; processors can't append children to a
188
+ running flow.
189
+ - At most 1000 nodes per flow (the atomic insert bounds how long Redis is
190
+ held, same idea as the delayed-promotion batch).
@@ -4,7 +4,7 @@ Reference docs for how toro works. The [README](../README.md) is the quick start
4
4
 
5
5
  ## Pages
6
6
 
7
- - **[Concepts](concepts.md)** - the mental model: queues, workers, jobs, the five
7
+ - **[Concepts](concepts.md)** - the mental model: queues, workers, jobs, the six
8
8
  job states, and the difference between *workers* and *slots*.
9
9
  - **[Data model](data-model.md)** - the exact Redis keys a queue uses and what
10
10
  each one stores.
@@ -16,7 +16,14 @@ Reference docs for how toro works. The [README](../README.md) is the quick start
16
16
  rate limiting, and graceful shutdown.
17
17
  - **[Scheduling](scheduling.md)** - repeatable and cron jobs, and how each
18
18
  occurrence schedules the next.
19
+ - **[Flows](flows.md)** - parent/child job trees: fan-out/fan-in, failure
20
+ policies, flow-aware retry and removal.
19
21
  - **[Architecture](architecture.md)** - the atomic-Lua core and the design
20
22
  decisions behind the queue.
21
23
  - **[Security](security.md)** - what toro guarantees (JSON-only, no dynamic
22
24
  dispatch, no string-built commands) and what you own (Redis access, secrets).
25
+
26
+ ## Design notes
27
+
28
+ - **[Flows (design)](flows-design.md)** - the decisions and landscape lessons
29
+ behind the flows feature.
@@ -34,6 +34,17 @@ async def handle(job):
34
34
 
35
35
  `update_progress` takes a number or any JSON value; dashboards render it live.
36
36
 
37
+ A flow parent's processor pulls what its children produced (both helpers are
38
+ processor-only and raise `RuntimeError` elsewhere - use the `Queue`-side
39
+ equivalents outside a worker):
40
+
41
+ ```python
42
+ results = await job.children_results() # {child_id: returnvalue}
43
+ failures = await job.failed_children() # {child_id: reason} (on_fail="continue")
44
+ ```
45
+
46
+ See [Flows](flows.md) for the full model.
47
+
37
48
  ## Concurrency
38
49
 
39
50
  `concurrency=N` runs N processing loops ("slots") as `asyncio` tasks on one