toro-queue 0.5.1__tar.gz → 0.6.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 (80) hide show
  1. {toro_queue-0.5.1 → toro_queue-0.6.0}/PKG-INFO +2 -1
  2. {toro_queue-0.5.1 → toro_queue-0.6.0}/README.md +1 -0
  3. {toro_queue-0.5.1 → toro_queue-0.6.0}/docs/architecture.md +11 -1
  4. {toro_queue-0.5.1 → toro_queue-0.6.0}/docs/concepts.md +4 -0
  5. {toro_queue-0.5.1 → toro_queue-0.6.0}/docs/data-model.md +1 -1
  6. {toro_queue-0.5.1 → toro_queue-0.6.0}/docs/processing.md +35 -5
  7. toro_queue-0.6.0/docs/specs/global-concurrency.md +130 -0
  8. {toro_queue-0.5.1 → toro_queue-0.6.0}/pyproject.toml +1 -1
  9. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_finished_retention.py +1 -1
  10. toro_queue-0.6.0/tests/integration/test_global_concurrency.py +413 -0
  11. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_invariants.py +70 -0
  12. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_reliability.py +1 -1
  13. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_workers.py +13 -0
  14. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/load/test_active_list_cost.py +1 -1
  15. toro_queue-0.6.0/tests/unit/test_worker_options.py +34 -0
  16. {toro_queue-0.5.1 → toro_queue-0.6.0}/toro/__init__.py +1 -1
  17. {toro_queue-0.5.1 → toro_queue-0.6.0}/toro/queue.py +1 -0
  18. {toro_queue-0.5.1 → toro_queue-0.6.0}/toro/scripts.py +46 -7
  19. {toro_queue-0.5.1 → toro_queue-0.6.0}/toro/worker.py +43 -5
  20. {toro_queue-0.5.1 → toro_queue-0.6.0}/uv.lock +1 -1
  21. {toro_queue-0.5.1 → toro_queue-0.6.0}/.github/workflows/pr-check.yaml +0 -0
  22. {toro_queue-0.5.1 → toro_queue-0.6.0}/.github/workflows/release.yml +0 -0
  23. {toro_queue-0.5.1 → toro_queue-0.6.0}/.gitignore +0 -0
  24. {toro_queue-0.5.1 → toro_queue-0.6.0}/.pre-commit-config.yaml +0 -0
  25. {toro_queue-0.5.1 → toro_queue-0.6.0}/.vscode/extensions.json +0 -0
  26. {toro_queue-0.5.1 → toro_queue-0.6.0}/.vscode/settings.json +0 -0
  27. {toro_queue-0.5.1 → toro_queue-0.6.0}/LICENSE +0 -0
  28. {toro_queue-0.5.1 → toro_queue-0.6.0}/bench/bench.py +0 -0
  29. {toro_queue-0.5.1 → toro_queue-0.6.0}/docs/flows-design.md +0 -0
  30. {toro_queue-0.5.1 → toro_queue-0.6.0}/docs/flows.md +0 -0
  31. {toro_queue-0.5.1 → toro_queue-0.6.0}/docs/index.md +0 -0
  32. {toro_queue-0.5.1 → toro_queue-0.6.0}/docs/producing.md +0 -0
  33. {toro_queue-0.5.1 → toro_queue-0.6.0}/docs/reliability.md +0 -0
  34. {toro_queue-0.5.1 → toro_queue-0.6.0}/docs/scheduling.md +0 -0
  35. {toro_queue-0.5.1 → toro_queue-0.6.0}/docs/security.md +0 -0
  36. {toro_queue-0.5.1 → toro_queue-0.6.0}/examples/README.md +0 -0
  37. {toro_queue-0.5.1 → toro_queue-0.6.0}/examples/basic.py +0 -0
  38. {toro_queue-0.5.1 → toro_queue-0.6.0}/examples/stalled.py +0 -0
  39. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/conftest.py +0 -0
  40. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_admin.py +0 -0
  41. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_admin_ordering.py +0 -0
  42. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_concurrency_stress.py +0 -0
  43. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_connection.py +0 -0
  44. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_fault_injection.py +0 -0
  45. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_flows.py +0 -0
  46. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_flows_edges.py +0 -0
  47. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_introspection.py +0 -0
  48. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_leaks.py +0 -0
  49. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_metrics.py +0 -0
  50. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_processing.py +0 -0
  51. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_result_dispatcher.py +0 -0
  52. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_retries.py +0 -0
  53. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_roots.py +0 -0
  54. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_scheduler.py +0 -0
  55. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_shutdown.py +0 -0
  56. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/integration/test_worker_resilience.py +0 -0
  57. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/load/harness.py +0 -0
  58. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/load/test_admin_scaling.py +0 -0
  59. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/load/test_enqueue_rtt.py +0 -0
  60. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/load/test_flows_load.py +0 -0
  61. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/load/test_load.py +0 -0
  62. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/load/test_promote_blocking.py +0 -0
  63. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/load/test_result_fanout.py +0 -0
  64. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/load/test_worker_concurrency.py +0 -0
  65. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/unit/test_backoff.py +0 -0
  66. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/unit/test_flow.py +0 -0
  67. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/unit/test_histogram.py +0 -0
  68. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/unit/test_job.py +0 -0
  69. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/unit/test_job_options.py +0 -0
  70. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/unit/test_keys.py +0 -0
  71. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/unit/test_priority.py +0 -0
  72. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/unit/test_scheduler.py +0 -0
  73. {toro_queue-0.5.1 → toro_queue-0.6.0}/tests/unit/test_worker_connection.py +0 -0
  74. {toro_queue-0.5.1 → toro_queue-0.6.0}/toro/connection.py +0 -0
  75. {toro_queue-0.5.1 → toro_queue-0.6.0}/toro/errors.py +0 -0
  76. {toro_queue-0.5.1 → toro_queue-0.6.0}/toro/flow.py +0 -0
  77. {toro_queue-0.5.1 → toro_queue-0.6.0}/toro/job.py +0 -0
  78. {toro_queue-0.5.1 → toro_queue-0.6.0}/toro/keys.py +0 -0
  79. {toro_queue-0.5.1 → toro_queue-0.6.0}/toro/py.typed +0 -0
  80. {toro_queue-0.5.1 → toro_queue-0.6.0}/toro/scheduler.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: toro-queue
3
- Version: 0.5.1
3
+ Version: 0.6.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
@@ -73,6 +73,7 @@ Pairs with **[matador](https://github.com/ilovepixelart/matador)**, a live web d
73
73
  | **Schedules** | repeatable **cron** and fixed-interval (`every`) jobs |
74
74
  | **Flows** | parent/child job trees: fan-out/fan-in, failure policies, flow-aware retry |
75
75
  | **Rate limiting** | queue-wide token bucket shared across all workers |
76
+ | **Global concurrency** | one cap on jobs active at once, across every worker process |
76
77
  | **Dedup** | custom (idempotent) job ids + a throttle window (`{id, ttl}`) |
77
78
  | **Auto-removal** | keep the last N and/or finished-within-age completed/failed |
78
79
  | **Reliability** | per-job locks, lock renewal, stalled-job recovery |
@@ -46,6 +46,7 @@ Pairs with **[matador](https://github.com/ilovepixelart/matador)**, a live web d
46
46
  | **Schedules** | repeatable **cron** and fixed-interval (`every`) jobs |
47
47
  | **Flows** | parent/child job trees: fan-out/fan-in, failure policies, flow-aware retry |
48
48
  | **Rate limiting** | queue-wide token bucket shared across all workers |
49
+ | **Global concurrency** | one cap on jobs active at once, across every worker process |
49
50
  | **Dedup** | custom (idempotent) job ids + a throttle window (`{id, ttl}`) |
50
51
  | **Auto-removal** | keep the last N and/or finished-within-age completed/failed |
51
52
  | **Reliability** | per-job locks, lock renewal, stalled-job recovery |
@@ -84,6 +84,14 @@ due jobs into the prioritized set.
84
84
  - **Rate limiting** - a queue-wide token bucket in Redis
85
85
  (`Worker(rate_limit={"max": N, "duration": ms})`), shared by every worker on the
86
86
  queue. An over-limit claim returns a sentinel and the worker waits out the window.
87
+ - **Global concurrency** - `Worker(global_concurrency=N)` caps jobs active at once
88
+ across every worker. The shared claim routine checks the length of the
89
+ `active` list before it pops, so there is no slot counter to leak: a crash
90
+ frees its slots through the stalled sweep. Only a fresh claim can be refused.
91
+ A finish removes its own job from `active` before it fetches the next, so that
92
+ fetch is a swap and can never raise occupancy. The re-arm after a claim is
93
+ skipped when that claim filled the last slot, and a script that omits the cap
94
+ errors before its first write: the limit cannot fail open.
87
95
  - **Events** - Redis pub/sub on an `events` channel (`added`, `progress`,
88
96
  `completed`, `failed`); `Queue.result()` awaits the terminal event and
89
97
  `Worker.on(event, fn)` exposes in-process hooks. See [Concepts](concepts.md).
@@ -106,7 +114,9 @@ The scripts share a small library of routines:
106
114
  | `priorityScore` | Packs `(PRIORITY_OFFSET - priority) * 2^32 + seq` for the prioritized ZSET. |
107
115
  | `enqueue` | Adds a job to `prioritized` at its score and arms the marker. |
108
116
  | `lockAndLoad` | Sets the lock token and loads the hash for a just-claimed id. |
109
- | `acquireNext` | Pops the top prioritized job into `active` and locks it, honoring the rate limit. |
117
+ | `acquireNext` | Pops the top prioritized job into `active` and locks it, honoring the rate limit and the global concurrency cap. |
118
+ | `requireCap` | Reads the cap argument, raising when it is missing. Called before a script's first write. |
119
+ | `wakeIfWaiting` | Arms the marker when jobs are waiting: a slot was freed without a claim. |
110
120
  | `tryRateLimit` | Token bucket: ms until a token frees, or 0 to proceed. |
111
121
  | `recordFinished` | Records a terminal job in `completed`/`failed` and applies auto-removal. |
112
122
  | `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`. |
@@ -65,6 +65,10 @@ So `live` counts workers, `slots` counts concurrent capacity. With the default
65
65
  `concurrency=1` they happen to match; bump concurrency and slots climb while the
66
66
  worker count stays put.
67
67
 
68
+ Slots are capacity, not a limit on the queue: every worker you add brings more.
69
+ To bound how many jobs run at once queue-wide, whatever the slot total, set
70
+ `global_concurrency` ([Processing](processing.md)).
71
+
68
72
  ```
69
73
  host (machine)
70
74
  └── process (pid)
@@ -46,7 +46,7 @@ Redis Cluster slot, which the multi-key Lua scripts require.
46
46
  | Key | Type | Holds |
47
47
  |---|---|---|
48
48
  | `repeat:<schedulerId>` | HASH | A scheduler's template: `name`, `every`/`cron`, `data`, `opts`. |
49
- | `worker:<workerId>` | HASH | A worker's presence record: host, pid, concurrency, current jobs, processed/failed counts, state. |
49
+ | `worker:<workerId>` | HASH | A worker's presence record: host, pid, concurrency, global concurrency cap, current jobs, processed/failed counts, state. |
50
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). |
51
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. |
52
52
  | `<jobId>:logs` | LIST | Log lines appended by `job.log(...)` from inside a processor. |
@@ -72,6 +72,7 @@ per job.
72
72
  |---|---|---|
73
73
  | `concurrency` | 1 | Parallel slots in this worker. |
74
74
  | `rate_limit` | `None` | `{"max": N, "duration": ms}` - queue-wide token bucket (below). |
75
+ | `global_concurrency` | `None` | Cap on jobs active at once across all workers on the queue (below). |
75
76
  | `block_timeout` | 5.0 s | How long an idle slot blocks waiting for a wakeup before re-checking. |
76
77
  | `lock_duration` / `lock_renew_time` / `renew_locks` | 30000 / half / `True` | The at-least-once lease - see [Reliability](reliability.md). |
77
78
  | `stalled_interval` / `max_stalled_count` | 30000 / 1 | The recovery sweep - same page. |
@@ -90,6 +91,35 @@ limit (give every worker the same config). When a claim hits the limit the job
90
91
  goes back untouched: no attempt is consumed, and the worker sleeps until a token
91
92
  frees (emitting a `rate-limited` event with the wait).
92
93
 
94
+ ## Global concurrency
95
+
96
+ ```python
97
+ worker = Worker("exports", handle, concurrency=10, global_concurrency=3)
98
+ ```
99
+
100
+ At most `global_concurrency` jobs are active at once across **all** workers on
101
+ the queue, however many processes you run (give every worker the same value).
102
+ `concurrency` sizes one worker; this caps the queue.
103
+
104
+ Use it when the constraint is *occupancy*, not arrival rate: a database pool of
105
+ N connections, an API that allows N requests in flight. A rate limit bounds job
106
+ *starts*, so with long jobs it says nothing about how many run together.
107
+
108
+ - A claim at the cap touches nothing: the job keeps its place, no attempt is
109
+ consumed, no rate-limit token is spent. The worker waits for a slot.
110
+ - There is no slot counter to leak. The cap counts the `active` list itself, so
111
+ a crashed worker's slots come back when the stalled sweep recovers its jobs
112
+ ([Reliability](reliability.md)).
113
+ - Slots stick. A busy worker claims its next job in the same round trip that
114
+ finishes the last, so while the queue stays full the workers holding the slots
115
+ keep them, and another worker can sit idle. Slots move when a holder drains,
116
+ stops, or crashes.
117
+ - Removing an active job does not hand its slot on at once: the processor may
118
+ still be running. The slot is reused when that processor ends.
119
+ - A changed value takes effect as workers restart. While a rollout mixes caps,
120
+ each worker enforces its own, and a freed slot can wait up to `block_timeout`
121
+ for a worker with room.
122
+
93
123
  ## Lifecycle events
94
124
 
95
125
  `worker.on(event, fn)` registers plain in-process callbacks (sync, fire-and-forget):
@@ -109,11 +139,11 @@ These are this worker's own hooks. Cross-process consumers (dashboards,
109
139
  ## Presence
110
140
 
111
141
  Every `heartbeat_interval` the worker flushes a presence record (host, pid,
112
- concurrency, what it's running, processed/failed counts, state). That powers the
113
- dashboard's workers view; a worker that misses heartbeats long enough is pruned
114
- and logged as a `lost` departure, while `stop()` flips it to a visible
115
- `stopping` state first and logs `stopped` - so the dashboard can tell a drain
116
- from a crash.
142
+ concurrency, global concurrency cap, what it's running, processed/failed counts,
143
+ state). That powers the dashboard's workers view; a worker that misses
144
+ heartbeats long enough is pruned and logged as a `lost` departure, while
145
+ `stop()` flips it to a visible `stopping` state first and logs `stopped` - so
146
+ the dashboard can tell a drain from a crash.
117
147
 
118
148
  ## Shutdown
119
149
 
@@ -0,0 +1,130 @@
1
+ # Global concurrency cap
2
+
3
+ ## Problem and outcome
4
+
5
+ A queue's in-flight work is unbounded across processes: each worker's
6
+ `concurrency` multiplies by the number of worker processes, and the rate limiter
7
+ bounds job *starts*, not *occupancy*. Long jobs against a connection-limited
8
+ downstream (a database pool, an API with a max-concurrent-requests limit) need a
9
+ hard cap on jobs active at once, queue-wide.
10
+
11
+ Outcome: a `global_concurrency` Worker option, enforced atomically in the claim
12
+ script. Slots free themselves on every path a job leaves `active`, including a
13
+ worker crash.
14
+
15
+ ## Design
16
+
17
+ - **Option.** `Worker(..., global_concurrency: int | None = None)`. Mirrors
18
+ `rate_limit`: a constructor option sent to Lua via ARGV, and all workers on a
19
+ queue should pass the same value. A changed cap takes effect as workers
20
+ restart.
21
+ - **Enforcement.** The shared `acquireNext` routine returns `false` when the cap
22
+ is set and `LLEN active >= cap`. All three scripts that claim pass it the cap:
23
+ `MOVE_TO_ACTIVE` and both finish scripts.
24
+ - **Only one script can be refused.** The only write that grows `active` is the
25
+ `LPUSH` in `acquireNext`. In the finish scripts that call always follows the
26
+ finisher's own successful `LREM` (or the script has already returned), so a
27
+ fetch after a finish is a swap: it cannot raise occupancy. Only
28
+ `MOVE_TO_ACTIVE` can, so the safety of the cap rests on that one path. The
29
+ finish scripts carry the cap so `acquireNext` knows when the queue is full.
30
+ - **Fails closed, before any write.** A caller that omits the cap argument gets
31
+ a script error, not a claim that ignores the limit. Every script validates
32
+ the cap before its first write: Redis does not roll a script back, so an
33
+ error raised after a finish had committed would leave that commit standing. The option is normalized with `int()`, so
34
+ an int subclass cannot reach Lua as an unreadable repr.
35
+ - **No counter.** Occupancy is read from the `active` list itself, so there is
36
+ nothing to leak: every existing exit from `active` (complete, fail, stalled
37
+ sweep, removal) frees the slot by construction.
38
+ - **A capped claim touches nothing.** The guard runs before the pop: no
39
+ pop-and-put-back, no rate-limit token spent, no attempt consumed.
40
+ - **Wakeup.** A capped worker parks on the marker exactly like a paused one.
41
+ `acquireNext` re-arms the marker after a claim only while a slot is still
42
+ free, so a claim that fills the last slot does not wake a worker just to
43
+ turn it away. A finish that re-enqueues before it fetches (an immediate
44
+ retry, a flow child releasing its parent) still arms the marker through
45
+ `enqueue`, as every `add` does: one refused wake each. Two paths
46
+ free a slot without claiming and arm the marker when jobs are waiting: a
47
+ finish with `fetch=0` (a draining worker), and a stalled job that fails
48
+ terminally. A draining worker's own parked loop can pop that wake first, so
49
+ a loop that pops a marker while shutting down hands it on before it exits.
50
+ Removal of an active job deliberately does not wake: its processor may still
51
+ be running, so an eager wake would exceed the real cap. When the processor
52
+ ends, its finish comes back lock-lost, and the worker arms the marker then:
53
+ the one moment it knows the slot is really free.
54
+ - **Visibility.** The heartbeat record and `Queue.workers()` gain
55
+ `global_concurrency` (0 when unset), which is what the dashboard needs to show
56
+ the cap and derive "waiting on cap" (`active >= cap` with jobs waiting).
57
+ - **Cost.** Up to two `LLEN` calls (O(1)) per claim, only when the cap is set.
58
+ Unset, the claim path issues no extra commands. Measured across 3 processes
59
+ with 6 jobs in flight on zero-work jobs: unset, set-but-unreached, and
60
+ saturated with 24 loops parked all run about 9,000 jobs/s at 1.0 script calls
61
+ per job. With 100 ms jobs a saturated cap of 3 drained 60 jobs in 2.09 s
62
+ against a 2.0 s optimum. An `add` while the queue is full still wakes one
63
+ parked loop that is turned away: the producer does not know the cap.
64
+ - **Slots stick.** A finisher swaps its own slot, so under a full queue the
65
+ workers holding slots keep them and others can sit idle until a holder
66
+ drains, stops, or crashes.
67
+
68
+ ## Acceptance clauses
69
+
70
+ | ID | Behavior | Check |
71
+ |---|---|---|
72
+ | GC-001 | With `global_concurrency=N`, no more than N jobs are active at any instant across several workers whose summed `concurrency` exceeds N, the high-water mark reaches N, and every job still completes. Covers the initial claim and the fetch after both complete and fail. | `tests/integration/test_global_concurrency.py::test_cap_holds_across_workers`, plus the seeded fuzzer in `test_invariants.py` asserting `active <= cap` after every step |
73
+ | GC-002 | A capped claim leaves the queue untouched: the job keeps its `prioritized` score, no attempt is consumed, no rate-limit token is spent. | `::test_capped_claim_touches_nothing` |
74
+ | GC-003 | A worker that dies holding slots frees them through the stalled sweep; the queue drains afterwards and never wedges. | `::test_crashed_worker_slots_are_recovered` (mutation-verified: fails with the sweep disabled) |
75
+ | GC-004 | A freed slot wakes a parked worker well under `block_timeout` on both non-claiming release paths: a finish with `fetch=0`, and a stalled job failing terminally. A draining worker's own parked loop hands the wake on instead of swallowing it. | `::test_freed_slot_wakes_parked_worker` (parametrized per path), `::test_draining_worker_passes_the_wake_on` |
76
+ | GC-005 | Unset (the default) leaves claim behavior and return shapes unchanged, and workers run up to their summed `concurrency`. The two release paths of GC-004 arm the marker whether or not a cap is set, which costs an uncapped queue at most one harmless wake. | `::test_unset_cap_is_unbounded` plus the existing integration suite green |
77
+ | GC-006 | A non-positive or non-integer `global_concurrency` raises `ValueError` at construction. An int subclass (an `IntEnum`) is stored as a plain int and caps like one. | `tests/unit/test_worker_options.py::test_global_concurrency_validation`, `::test_global_concurrency_is_stored_as_a_plain_int`, `tests/integration/test_global_concurrency.py::test_int_subclass_cap_is_enforced` |
78
+ | GC-007 | The heartbeat record and `Queue.workers()` expose `global_concurrency`. | `tests/integration/test_workers.py::test_presence_reports_global_concurrency` |
79
+ | GC-008 | The re-arm after a claim is skipped when that claim filled the last slot, on the initial claim and on the fetch after both complete and fail. While a slot is free and jobs wait, the marker is armed. A finish that re-enqueues before it fetches arms it regardless. | `tests/integration/test_global_concurrency.py::test_full_cap_does_not_wake_a_parked_worker` |
80
+ | GC-009 | A claim that omits the cap argument is a script error and claims nothing. A finish that fetches without it errors before its first write: nothing is committed and the lock stands. | `::test_missing_cap_argument_is_an_error`, `::test_finish_with_a_missing_cap_commits_nothing` |
81
+ | GC-010 | The slot of a removed active job is reused as soon as its processor ends, not at the next idle re-poll, and not before. | `::test_slot_of_a_removed_job_is_reused_once_its_processor_ends` |
82
+
83
+ ## Out of scope
84
+
85
+ - A per-key limit (`concurrency_key`): its own spec, next.
86
+ - A worker event when capped. It needs a new script sentinel to tell "capped"
87
+ from "empty"; the dashboard derives the condition from Redis instead.
88
+ - Changing the cap at runtime. A value stored in Redis that overrides the
89
+ option can be added later without breaking this API.
90
+ - The matador "waiting on cap" UI: a paired follow-up once GC-007 lands.
91
+
92
+ ## Risks
93
+
94
+ - **The swap invariant.** The cap is safe only while `acquireNext` stays the
95
+ sole writer to `active` and the finish scripts keep removing before they
96
+ fetch. The fuzzer check in GC-001 is the tripwire.
97
+ - **Mixed config.** Workers passing different caps each enforce their own, as
98
+ happens during any rollout that introduces or changes the cap. A finisher
99
+ refused by its own lower cap frees a slot without a wake, so a worker with
100
+ room can wait up to `block_timeout` for it. The delay is bounded by the idle
101
+ re-poll and ends with the rollout. Documented the same way `rate_limit` is.
102
+ - **False stalls.** The cap bounds claimed jobs. A job reclaimed after a false
103
+ stall can run twice, exactly as it can today, which briefly exceeds the cap
104
+ in real work while `active` stays within it.
105
+ - **Silent skips.** Integration tests skip when no Redis answers on
106
+ `localhost:6379`. Every check above must run against a live Redis and the run
107
+ must show zero skips.
108
+
109
+ ## Decisions
110
+
111
+ 1. The cap is a Worker option, not a queue-level value in Redis: the smallest
112
+ change, no new keys, and the same contract `rate_limit` already has.
113
+ 2. No "capped" worker event.
114
+ 3. A missing cap argument is a script error. A limit must not fail open.
115
+
116
+ ## Tasks
117
+
118
+ | # | Clause | Work | Files | Test strategy |
119
+ |---|---|---|---|---|
120
+ | 1 | GC-006 | Add the option, validate it, store it (not yet enforced) | `toro/worker.py` | unit, red first |
121
+ | 2 | GC-001, GC-002, GC-005 | Write the cap tests (red for the right reason: the option exists but nothing enforces it), then the `LLEN` guard and the cap ARGV | `toro/scripts.py`, `toro/worker.py`, tests | high-water mark via a shared counter; direct script calls for GC-002 |
122
+ | 3 | GC-001 | Cap invariant in the seeded fuzzer | `tests/integration/test_invariants.py` | `active <= cap` after every step |
123
+ | 4 | GC-003 | Crash recovery test | tests only | zombie worker, expired locks, sweep; mutation check with the sweep off |
124
+ | 5 | GC-004 | Arm the marker on the two non-claiming release paths | `toro/scripts.py` | large `block_timeout`, assert prompt start, red first per path |
125
+ | 6 | GC-007 | Presence field and `workers()` | `toro/worker.py`, `toro/queue.py` | integration |
126
+ | 7 | | Docs: `processing.md`, `concepts.md`, README feature row | docs | review |
127
+ | 8 | | Prove: lint, types, full suite with zero skips, a saturated-cap load run, a multi-process run of a capped fleet | | evidence captured |
128
+ | 9 | GC-006 | Normalize the option with `int()` | `toro/worker.py` | unit and integration, red first |
129
+ | 10 | GC-004 | A draining loop hands on a marker it popped | `toro/worker.py` | build the blocked-client ordering that swallows the wake, red first |
130
+ | 11 | GC-008, GC-009 | Move the cap into `acquireNext`, re-arm only while a slot is free, pass the cap from both finish scripts | `toro/scripts.py`, `toro/worker.py` | assert on the marker key, red first; re-measure the saturated load run |
@@ -2,7 +2,7 @@
2
2
  # PyPI distribution name - plain `toro` is taken (abandoned). The import package
3
3
  # stays `toro` (e.g. `pip install toro-queue` then `import toro`).
4
4
  name = "toro-queue"
5
- version = "0.5.1"
5
+ version = "0.6.0"
6
6
  description = "An async-first, Redis-backed job queue for Python."
7
7
  readme = "README.md"
8
8
  requires-python = ">=3.10"
@@ -53,7 +53,7 @@ async def test_age_trim_is_bounded_per_finish(q):
53
53
  q.keys.meta_paused,
54
54
  q.keys.limiter,
55
55
  ],
56
- args=[token, 30_000, int(time.time() * 1000), 0, 0],
56
+ args=[token, 30_000, int(time.time() * 1000), 0, 0, 0],
57
57
  )
58
58
  assert res and res[1] == job.id
59
59
  out = await complete(