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.
- {toro_queue-0.2.0 → toro_queue-0.4.0}/.github/workflows/pr-check.yaml +2 -2
- {toro_queue-0.2.0 → toro_queue-0.4.0}/.github/workflows/release.yml +1 -1
- toro_queue-0.4.0/.pre-commit-config.yaml +26 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/PKG-INFO +20 -5
- {toro_queue-0.2.0 → toro_queue-0.4.0}/README.md +19 -4
- {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/architecture.md +29 -20
- {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/concepts.md +7 -5
- {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/data-model.md +12 -4
- toro_queue-0.4.0/docs/flows-design.md +192 -0
- toro_queue-0.4.0/docs/flows.md +189 -0
- toro_queue-0.4.0/docs/index.md +29 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/processing.md +22 -11
- {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/producing.md +17 -11
- {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/reliability.md +10 -8
- {toro_queue-0.2.0 → toro_queue-0.4.0}/docs/scheduling.md +6 -6
- toro_queue-0.4.0/docs/security.md +38 -0
- toro_queue-0.4.0/examples/README.md +19 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/examples/stalled.py +2 -2
- {toro_queue-0.2.0 → toro_queue-0.4.0}/pyproject.toml +5 -5
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/conftest.py +21 -2
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_admin.py +2 -2
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_admin_ordering.py +4 -4
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_finished_retention.py +4 -4
- toro_queue-0.4.0/tests/integration/test_flows.py +388 -0
- toro_queue-0.4.0/tests/integration/test_flows_edges.py +813 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_introspection.py +1 -1
- toro_queue-0.4.0/tests/integration/test_metrics.py +416 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_processing.py +2 -2
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_reliability.py +27 -6
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_result_dispatcher.py +3 -3
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_retries.py +1 -1
- toro_queue-0.4.0/tests/integration/test_roots.py +194 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_worker_resilience.py +1 -1
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_workers.py +3 -3
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/harness.py +8 -8
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_active_list_cost.py +18 -6
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_admin_scaling.py +4 -4
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_enqueue_rtt.py +5 -3
- toro_queue-0.4.0/tests/load/test_flows_load.py +75 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_load.py +7 -7
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_promote_blocking.py +4 -4
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_result_fanout.py +1 -1
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/load/test_worker_concurrency.py +3 -3
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/unit/test_backoff.py +1 -1
- toro_queue-0.4.0/tests/unit/test_flow.py +54 -0
- toro_queue-0.4.0/tests/unit/test_histogram.py +64 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/unit/test_job.py +2 -2
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/unit/test_job_options.py +2 -2
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/unit/test_keys.py +16 -1
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/unit/test_scheduler.py +1 -1
- {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/__init__.py +10 -3
- {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/connection.py +1 -1
- toro_queue-0.4.0/toro/flow.py +161 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/job.py +39 -2
- {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/keys.py +41 -3
- {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/queue.py +544 -70
- {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/scheduler.py +3 -3
- toro_queue-0.4.0/toro/scripts.py +797 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/worker.py +17 -13
- {toro_queue-0.2.0 → toro_queue-0.4.0}/uv.lock +1 -1
- toro_queue-0.2.0/.pre-commit-config.yaml +0 -18
- toro_queue-0.2.0/docs/index.md +0 -20
- toro_queue-0.2.0/examples/README.md +0 -19
- toro_queue-0.2.0/toro/scripts.py +0 -459
- {toro_queue-0.2.0 → toro_queue-0.4.0}/.gitignore +0 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/.vscode/extensions.json +0 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/.vscode/settings.json +0 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/LICENSE +0 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/bench/bench.py +0 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/examples/basic.py +0 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_connection.py +0 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/integration/test_scheduler.py +0 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/tests/unit/test_priority.py +0 -0
- {toro_queue-0.2.0 → toro_queue-0.4.0}/toro/errors.py +0 -0
- {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
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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`
|
|
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
|
|
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)
|
|
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
|
|
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`
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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**
|
|
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**
|
|
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`
|
|
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**
|
|
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**
|
|
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**
|
|
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**
|
|
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**
|
|
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
|
|
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__"`)
|
|
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`)
|
|
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`)
|
|
131
|
-
- `OUTCOME_FAILED` (`1`) vs `0`
|
|
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
|
-
|
|
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**
|
|
149
|
+
- **async-first** - `redis.asyncio`, `async def` processors, one event loop;
|
|
141
150
|
concurrency is N `asyncio` tasks sharing the loop.
|
|
142
|
-
- **Cluster**
|
|
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`
|
|
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
|
|
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
|
|
|
@@ -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`)
|
|
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` / `
|
|
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,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.
|