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.
- {toro_queue-0.3.0 → toro_queue-0.5.0}/.gitignore +2 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/PKG-INFO +16 -1
- {toro_queue-0.3.0 → toro_queue-0.5.0}/README.md +15 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/architecture.md +15 -6
- {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/concepts.md +5 -3
- {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/data-model.md +12 -4
- toro_queue-0.5.0/docs/flows-design.md +196 -0
- toro_queue-0.5.0/docs/flows.md +190 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/index.md +8 -1
- {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/processing.md +11 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/producing.md +6 -3
- {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/reliability.md +3 -1
- {toro_queue-0.3.0 → toro_queue-0.5.0}/pyproject.toml +1 -1
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/conftest.py +19 -0
- toro_queue-0.5.0/tests/integration/test_concurrency_stress.py +99 -0
- toro_queue-0.5.0/tests/integration/test_fault_injection.py +163 -0
- toro_queue-0.5.0/tests/integration/test_flows.py +388 -0
- toro_queue-0.5.0/tests/integration/test_flows_edges.py +836 -0
- toro_queue-0.5.0/tests/integration/test_invariants.py +197 -0
- toro_queue-0.5.0/tests/integration/test_leaks.py +151 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_metrics.py +93 -0
- toro_queue-0.5.0/tests/integration/test_roots.py +194 -0
- toro_queue-0.5.0/tests/load/test_flows_load.py +75 -0
- toro_queue-0.5.0/tests/unit/test_flow.py +54 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_keys.py +15 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/__init__.py +7 -2
- toro_queue-0.5.0/toro/flow.py +161 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/job.py +38 -1
- {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/keys.py +32 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/queue.py +398 -80
- {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/scripts.py +304 -25
- {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/worker.py +2 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/uv.lock +1 -1
- {toro_queue-0.3.0 → toro_queue-0.5.0}/.github/workflows/pr-check.yaml +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/.github/workflows/release.yml +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/.pre-commit-config.yaml +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/.vscode/extensions.json +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/.vscode/settings.json +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/LICENSE +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/bench/bench.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/scheduling.md +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/docs/security.md +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/examples/README.md +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/examples/basic.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/examples/stalled.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_admin.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_admin_ordering.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_connection.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_finished_retention.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_introspection.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_processing.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_reliability.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_result_dispatcher.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_retries.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_scheduler.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_worker_resilience.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/integration/test_workers.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/harness.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_active_list_cost.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_admin_scaling.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_enqueue_rtt.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_load.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_promote_blocking.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_result_fanout.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/load/test_worker_concurrency.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_backoff.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_histogram.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_job.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_job_options.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_priority.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/tests/unit/test_scheduler.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/connection.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/errors.py +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/py.typed +0 -0
- {toro_queue-0.3.0 → toro_queue-0.5.0}/toro/scheduler.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: toro-queue
|
|
3
|
-
Version: 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
|
|
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
|
|
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
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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` / `
|
|
58
|
-
as its state changes; the move and the hash update happen
|
|
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
|
|
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
|