vtrr-queue 0.1.0__tar.gz → 0.2.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.
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/CLAUDE.md +14 -5
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/PKG-INFO +97 -5
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/README.md +96 -4
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/pyproject.toml +1 -1
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/src/vtrr_queue/queue.py +196 -18
- vtrr_queue-0.2.0/src/vtrr_queue/scripts/ack.lua +15 -0
- vtrr_queue-0.2.0/src/vtrr_queue/scripts/common.lua +54 -0
- vtrr_queue-0.2.0/src/vtrr_queue/scripts/dequeue.lua +35 -0
- vtrr_queue-0.2.0/src/vtrr_queue/scripts/enqueue.lua +10 -0
- vtrr_queue-0.2.0/src/vtrr_queue/scripts/reclaim.lua +2 -0
- vtrr_queue-0.2.0/src/vtrr_queue/scripts/renew.lua +8 -0
- vtrr_queue-0.2.0/tests/test_vtrr.py +772 -0
- vtrr_queue-0.1.0/src/vtrr_queue/scripts/dequeue.lua +0 -24
- vtrr_queue-0.1.0/src/vtrr_queue/scripts/enqueue.lua +0 -26
- vtrr_queue-0.1.0/tests/test_vtrr.py +0 -416
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/.github/workflows/python-app.yml +0 -0
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/.github/workflows/python-publish.yml +0 -0
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/.gitignore +0 -0
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/LICENSE +0 -0
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/poetry.lock +0 -0
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/src/vtrr_queue/__init__.py +0 -0
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/src/vtrr_queue/py.typed +0 -0
- {vtrr_queue-0.1.0 → vtrr_queue-0.2.0}/tests/conftest.py +0 -0
|
@@ -20,22 +20,27 @@ src/vtrr_queue/
|
|
|
20
20
|
queue.py # all logic lives here — single file by design
|
|
21
21
|
py.typed # PEP 561 marker
|
|
22
22
|
scripts/
|
|
23
|
+
common.lua # prelude prepended to every script: vtrr_enqueue (scoring) + vtrr_reclaim
|
|
23
24
|
enqueue.lua # bulk enqueue, called by VTRRTask.queue()
|
|
24
|
-
dequeue.lua #
|
|
25
|
-
|
|
25
|
+
dequeue.lua # reclaims, then leases one task; called by each @vtrr.task celery wrapper
|
|
26
|
+
reclaim.lua # public VTRRQueue.reclaim()
|
|
27
|
+
ack.lua # fenced ack of a lease, VTRRQueue.ack()
|
|
28
|
+
renew.lua # fenced re-lease used by retry runs, VTRRQueue._renew_lease()
|
|
29
|
+
tests/ # fakeredis-based tests
|
|
26
30
|
```
|
|
27
31
|
|
|
28
32
|
## Key design constraints
|
|
29
33
|
|
|
30
34
|
- **Single source file** (`queue.py`) — do not split into multiple modules unless the file becomes unmanageable.
|
|
31
35
|
- **No Django dependency** — the library must work with plain Celery + redis-py. Django integration is the consumer's responsibility (`get_redis_connection`, settings wiring, etc.).
|
|
32
|
-
- **No
|
|
36
|
+
- **No script modification without checking the others** — the scripts share `common.lua` and assumptions about key layout and must stay consistent. Both are atomic Lua scripts; never replace them with multi-command Python.
|
|
33
37
|
- **`@vtrr.task` IS the Celery task** — the decorator wraps the user function in a Celery task whose body calls `vtrr._dequeue()` then invokes the function directly. One broker hop total; no intermediate dispatcher task.
|
|
38
|
+
- **One task per `VTRRQueue`** — `@vtrr.task` raises `ValueError` for a second function. Triggers are interchangeable and run whatever item they dequeue, so a mixed queue would run items under the wrong task's Celery options. `vtrr._task` is the single registered task; the item payload's `task_name` is checked against it.
|
|
34
39
|
- **`celery_queue` is passed explicitly** in every `apply_async` call, bypassing the consumer's `task_routes`.
|
|
35
40
|
|
|
36
41
|
## Redis key contract
|
|
37
42
|
|
|
38
|
-
Each `VTRRQueue` instance namespaces its
|
|
43
|
+
Each `VTRRQueue` instance namespaces its keys under `vtrr:{name}:*`. Do not rename without updating the Lua scripts and the README. Every script receives the same `KEYS` list (order defined in `common.lua`).
|
|
39
44
|
|
|
40
45
|
| Key | Structure |
|
|
41
46
|
|---|---|
|
|
@@ -46,7 +51,7 @@ Each `VTRRQueue` instance namespaces its four keys under `vtrr:{name}:*`. Do not
|
|
|
46
51
|
|
|
47
52
|
JSON payload shape: `{"task_name": str, "args": list, "kwargs": dict}`
|
|
48
53
|
|
|
49
|
-
|
|
54
|
+
The first four keys are deleted/reset when the queue drains (handled in `dequeue.lua`). Lease state (`meta`, `inflight`, `inflight_task`, `lease`, `timeouts`; see README) is untouched by a drain. Dequeue grants a lease rather than deleting the item; reclaim rescores expired items through the same `vtrr_enqueue` function a fresh enqueue uses — never duplicate that logic.
|
|
50
55
|
|
|
51
56
|
## Task weights
|
|
52
57
|
|
|
@@ -56,6 +61,10 @@ Each task dict passed to `.queue()` may include a `"weight"` key (int, default `
|
|
|
56
61
|
|
|
57
62
|
`_schedule_workers(num_tasks)` schedules `min(max_concurrency - workers_scheduled, num_tasks)` Celery tasks after every `.queue()` call. `_get_workers_scheduled()` counts broker list lengths across Celery's four Redis priority suffixes (`""`, `":3"`, `":6"`, `":9"`). It returns `0` on any error so scheduling may over-fire slightly — this is intentional and safe.
|
|
58
63
|
|
|
64
|
+
## Periodic reclaim
|
|
65
|
+
|
|
66
|
+
Recovery of work from dead workers must not depend on Celery's `acks_late` / `reject_on_worker_lost` (they stay allowed task options but the library never requires them). Trigger messages carry no payload, so a lost trigger loses nothing; `reclaim_and_dispatch()` (reclaim expired leases, then `_schedule_workers(zcard(queue))` using any registered task as the trigger) is what wakes workers back up. With `reclaim_interval` set, the constructor registers the `vtrr_queue.reclaim.{name}` Celery task and merges its entry into `celery_app.conf.beat_schedule`; `reclaim_beat_schedule()` returns the entry for apps that assign the schedule wholesale.
|
|
67
|
+
|
|
59
68
|
## Development setup
|
|
60
69
|
|
|
61
70
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vtrr-queue
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Virtual-time round-robin fair queue on Redis
|
|
5
5
|
Author-email: Nicolas Johnson <nicolas.johnson@c4ads.org>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -36,6 +36,7 @@ Integrates with an existing Celery + Redis setup. The library owns the dequeue l
|
|
|
36
36
|
- Python >= 3.12
|
|
37
37
|
- Redis (used for both the VTRR queue state and as the Celery broker)
|
|
38
38
|
- Celery >= 5.0
|
|
39
|
+
- Optional but recommended: Celery Beat (or any scheduler that can call `vtrr.reclaim_and_dispatch()`). Queueing and running tasks work without it, but recovering work from a dead worker when the queue is otherwise idle does not. See [Periodic reclaim](#periodic-reclaim-with-celery-beat)
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
## Installation
|
|
@@ -107,6 +108,9 @@ Use a dedicated Redis DB index for the VTRR queue so its keys don't collide with
|
|
|
107
108
|
| `name` | `str` | required | Unique name for this queue instance; namespaces all Redis keys as `vtrr:{name}:*` |
|
|
108
109
|
| `celery_queue` | `str` | `"vtrr_queue"` | Celery queue name for the dispatcher task |
|
|
109
110
|
| `max_concurrency` | `int` | `4` | Max dequeue workers held in the broker at once |
|
|
111
|
+
| `lease_seconds` | `int` | `3900` | How long a dequeued item stays leased before it is reclaimed. **Must exceed the task's hard `time_limit`.** See [Leasing](#leasing-and-at-least-once-delivery) |
|
|
112
|
+
| `reclaim_interval` | `float \| None` | `None` | Seconds between periodic reclaims run by Celery Beat. `None` disables it. See [Periodic reclaim](#periodic-reclaim-with-celery-beat) |
|
|
113
|
+
| `reclaim_queue` | `str \| None` | `None` | Celery queue the reclaim task is sent to. `None` uses your default queue. See [Periodic reclaim](#periodic-reclaim-with-celery-beat) |
|
|
110
114
|
|
|
111
115
|
|
|
112
116
|
### 2. Register a dedicated Celery worker
|
|
@@ -213,8 +217,10 @@ Each task dict may also include an `"id"` key to supply a stable task ID; otherw
|
|
|
213
217
|
| `retry_kwargs` | Dict of kwargs passed to `self.retry()`, e.g. `{"countdown": 10}` |
|
|
214
218
|
| `soft_time_limit` | Seconds before a `SoftTimeLimitExceeded` is raised in the worker |
|
|
215
219
|
| `time_limit` | Hard kill timeout in seconds |
|
|
220
|
+
| `acks_late` | Ack the Celery trigger after the task finishes. Optional: leasing does not depend on it |
|
|
221
|
+
| `reject_on_worker_lost` | Requeue the trigger if the worker process is killed mid-task. Optional: leasing does not depend on it |
|
|
216
222
|
|
|
217
|
-
|
|
223
|
+
A `VTRRQueue` runs exactly one task: decorating a second function on the same instance raises `ValueError`. Every Celery trigger on a queue dequeues whatever item is next, so the Celery options (time limits, retries, `base`) must belong to the one task that every item runs. For several task types, create one `VTRRQueue` per task, each with its own `name` and `celery_queue`.
|
|
218
224
|
|
|
219
225
|
|
|
220
226
|
## Django-specific setup
|
|
@@ -312,20 +318,106 @@ class UploadView(View):
|
|
|
312
318
|
```
|
|
313
319
|
|
|
314
320
|
|
|
321
|
+
## Leasing and at-least-once delivery
|
|
322
|
+
|
|
323
|
+
Dequeuing an item does not delete it; it **leases** it. The item moves to an in-flight set with an expiry of `redis_time + lease_seconds` (Redis `TIME`, so worker clock skew is irrelevant) and its lease token is stored. The `@vtrr.task` wrapper acks the lease when your function returns. If the worker dies first, the lease expires and the next dequeue (or a call to `vtrr.reclaim()`, or the [periodic reclaim](#periodic-reclaim-with-celery-beat)) puts the item back in the queue, **rescored** under VTRR as if freshly enqueued; it does not keep its old position. Each reclaim adds one to the item's **timeouts** count, which is never reset.
|
|
324
|
+
|
|
325
|
+
Delivery is therefore **at-least-once: task bodies must be idempotent.** Inside a task, `self.request.vtrr_timeouts` is how many times this item's lease has expired and been reclaimed: 0 on the first run, +1 each time it is run again after a timeout. The library does not cap timeouts; your task decides when to give up (see [Timeout limits](#timeout-limits)).
|
|
326
|
+
|
|
327
|
+
The wrapper acks in a `finally`, so a Python exception does not redeliver forever; only process death or lease expiry does. This matches Celery's default `task_acks_on_failure_or_timeout=True`. A non-`Exception` such as `SystemExit` is treated as process death and is *not* acked: billiard raises `SystemExit` in the task when the hard time limit sends SIGTERM (SIGKILL follows ~0.1s later), so a task killed by its hard `time_limit` keeps its lease, is reclaimed, and counts as a timeout. If you use the low-level API directly:
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
task_name, args, kwargs, task_id, token, timeouts = vtrr._dequeue()
|
|
331
|
+
try:
|
|
332
|
+
...
|
|
333
|
+
finally:
|
|
334
|
+
vtrr.ack(task_id, token) # False if the lease already expired and was re-granted; harmless
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
`self.retry()` / `autoretry_for` keep the lease: the wrapper does not ack when a task retries, and the retry run re-leases the item (expiry reset to `redis_time + lease_seconds`) and acks when it finishes. If the lease was already reclaimed during the retry delay (for example a `countdown` longer than `lease_seconds`), the item belongs to a newer delivery and the retry run is dropped instead of executing twice.
|
|
338
|
+
|
|
339
|
+
`ack` is fenced: it only succeeds when the token matches the current lease, so a slow worker whose lease was reclaimed cannot ack the new holder's lease.
|
|
340
|
+
|
|
341
|
+
### Celery configuration and timing
|
|
342
|
+
|
|
343
|
+
Recovery does not depend on Celery's delivery guarantees. The Celery message that wakes a worker (the "trigger") carries no payload; the work item lives in Redis under its lease. If a worker dies, the item comes back when its lease expires, and [periodic reclaim](#periodic-reclaim-with-celery-beat) wakes a worker for it. You do not need `acks_late`, `reject_on_worker_lost`, or a raised `visibility_timeout` for this. (They remain available task options. `reject_on_worker_lost` in particular does not speed recovery: the requeued trigger runs while the lease is still held, so it cannot reclaim the item.)
|
|
344
|
+
|
|
345
|
+
Required order:
|
|
346
|
+
|
|
347
|
+
```
|
|
348
|
+
soft_time_limit < time_limit (hard) < lease_seconds
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
For an hour-long task: soft 3600, hard 3720, lease 3900.
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
vtrr = VTRRQueue(..., lease_seconds=3900, reclaim_interval=600)
|
|
355
|
+
|
|
356
|
+
@vtrr.task(soft_time_limit=3600, time_limit=3720)
|
|
357
|
+
def process_file(self, ...):
|
|
358
|
+
...
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
- **Hard `time_limit` is required.** `soft_time_limit` only raises an exception, which blocking C/IO code can delay or swallow. The hard limit kills the process, so by the time a lease expires the original holder is gone and reclaim rarely causes concurrent executions. Hitting the hard limit is itself recovered the same way: the lease is not acked, so the item is reclaimed after `lease_seconds` and its `timeouts` count goes up.
|
|
362
|
+
- **`lease_seconds` > hard limit**, for the reason above.
|
|
363
|
+
- A recovered item waits for its lease to expire, so worst-case recovery time is about `lease_seconds + reclaim_interval`.
|
|
364
|
+
- If you do enable `acks_late`, the Redis transport's `visibility_timeout` (default 3600s, broker-wide) applies to it: a trigger held unacked longer is redelivered while still running. The lease token fences the duplicate, but keep `visibility_timeout` above your longest task.
|
|
365
|
+
|
|
366
|
+
### Periodic reclaim with Celery Beat
|
|
367
|
+
|
|
368
|
+
Reclaim also runs at the start of every dequeue, but that is not enough on its own: if the queue is idle, or every trigger for waiting items has been lost (a worker died between receiving its trigger and dequeuing), no dequeue happens and the item sits there. `vtrr.reclaim_and_dispatch()` reclaims expired leases and then schedules dequeue workers for whatever is waiting (up to `max_concurrency` minus the triggers already in the broker). It is cheap: when nothing has expired and the queue is empty it is one Redis call that returns immediately.
|
|
369
|
+
|
|
370
|
+
Pass `reclaim_interval` to run it on Celery Beat:
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
vtrr = VTRRQueue(..., reclaim_interval=600, reclaim_queue="celery")
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
At construction the library registers a task named `vtrr_queue.reclaim.{name}` and merges its entry into `celery_app.conf.beat_schedule`. You still have to run Beat (`celery -A myapp beat`), and the process that executes the task must have imported your `@vtrr.task` functions, which it uses as triggers.
|
|
377
|
+
|
|
378
|
+
- **`reclaim_queue`**: leave it unset to use your default Celery queue, or name a queue that some worker consumes. Don't send it to `celery_queue` if those workers are usually busy with long tasks; it would wait behind them.
|
|
379
|
+
- **If your app assigns `beat_schedule` wholesale after creating the `VTRRQueue`**, it overwrites the entry. Merge `vtrr.reclaim_beat_schedule()` back in:
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
app.conf.beat_schedule = {**my_entries, **vtrr.reclaim_beat_schedule()}
|
|
383
|
+
```
|
|
384
|
+
- **Without Beat**, call `vtrr.reclaim_and_dispatch()` from whatever scheduler you have (cron, a management command, etc.).
|
|
385
|
+
|
|
386
|
+
### Timeout limits
|
|
387
|
+
|
|
388
|
+
The library never gives up on an item: every expired lease is reclaimed and re-enqueued. To cap redeliveries, check the timeouts count in your task and return early; the wrapper then acks the item and it is dropped.
|
|
389
|
+
|
|
390
|
+
```python
|
|
391
|
+
MAX_TIMEOUTS = 3
|
|
392
|
+
|
|
393
|
+
@vtrr.task(soft_time_limit=3600, time_limit=3720)
|
|
394
|
+
def process_file(self, ...):
|
|
395
|
+
if self.request.vtrr_timeouts >= MAX_TIMEOUTS:
|
|
396
|
+
log_poison_item(...) # your own dead-letter handling
|
|
397
|
+
return
|
|
398
|
+
...
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
|
|
315
402
|
## Redis keys
|
|
316
403
|
|
|
317
|
-
Each `VTRRQueue` instance manages
|
|
404
|
+
Each `VTRRQueue` instance manages these keys, namespaced under `vtrr:{name}:*`.
|
|
318
405
|
|
|
319
406
|
| Key | Type | Description |
|
|
320
407
|
|---|---|---|
|
|
321
408
|
| `vtrr:{name}:queue` | Sorted Set | Task IDs scored by virtual time; lowest score = highest priority |
|
|
322
409
|
| `vtrr:{name}:current_virtual_time` | String | Virtual time of the last dequeued task; resets to 0 when queue drains |
|
|
323
410
|
| `vtrr:{name}:partition_virtual_time` | Hash | Per-partition virtual time counter; deleted when queue drains |
|
|
324
|
-
| `vtrr:{name}:task` | Hash | `task_id → JSON payload
|
|
411
|
+
| `vtrr:{name}:task` | Hash | `task_id → JSON payload` for queued items; deleted when queue drains |
|
|
412
|
+
| `vtrr:{name}:meta` | Hash | `task_id → "weight:partition_key"`, kept until ack so reclaim can rescore |
|
|
413
|
+
| `vtrr:{name}:inflight` | Sorted Set | Leased task IDs scored by lease expiry (Redis time) |
|
|
414
|
+
| `vtrr:{name}:inflight_task` | Hash | `task_id → JSON payload` for leased items |
|
|
415
|
+
| `vtrr:{name}:lease` | Hash | `task_id → lease token` |
|
|
416
|
+
| `vtrr:{name}:timeouts` | Hash | `task_id → times its lease expired and was reclaimed`; cleared on ack |
|
|
325
417
|
|
|
326
418
|
Multiple `VTRRQueue` instances in the same application can safely share a Redis database as long as they have distinct `name` values.
|
|
327
419
|
|
|
328
|
-
All enqueue and
|
|
420
|
+
All enqueue, dequeue, ack and reclaim operations are atomic Lua scripts, so concurrent writers and workers are safe.
|
|
329
421
|
|
|
330
422
|
# Development
|
|
331
423
|
|
|
@@ -10,6 +10,7 @@ Integrates with an existing Celery + Redis setup. The library owns the dequeue l
|
|
|
10
10
|
- Python >= 3.12
|
|
11
11
|
- Redis (used for both the VTRR queue state and as the Celery broker)
|
|
12
12
|
- Celery >= 5.0
|
|
13
|
+
- Optional but recommended: Celery Beat (or any scheduler that can call `vtrr.reclaim_and_dispatch()`). Queueing and running tasks work without it, but recovering work from a dead worker when the queue is otherwise idle does not. See [Periodic reclaim](#periodic-reclaim-with-celery-beat)
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
## Installation
|
|
@@ -81,6 +82,9 @@ Use a dedicated Redis DB index for the VTRR queue so its keys don't collide with
|
|
|
81
82
|
| `name` | `str` | required | Unique name for this queue instance; namespaces all Redis keys as `vtrr:{name}:*` |
|
|
82
83
|
| `celery_queue` | `str` | `"vtrr_queue"` | Celery queue name for the dispatcher task |
|
|
83
84
|
| `max_concurrency` | `int` | `4` | Max dequeue workers held in the broker at once |
|
|
85
|
+
| `lease_seconds` | `int` | `3900` | How long a dequeued item stays leased before it is reclaimed. **Must exceed the task's hard `time_limit`.** See [Leasing](#leasing-and-at-least-once-delivery) |
|
|
86
|
+
| `reclaim_interval` | `float \| None` | `None` | Seconds between periodic reclaims run by Celery Beat. `None` disables it. See [Periodic reclaim](#periodic-reclaim-with-celery-beat) |
|
|
87
|
+
| `reclaim_queue` | `str \| None` | `None` | Celery queue the reclaim task is sent to. `None` uses your default queue. See [Periodic reclaim](#periodic-reclaim-with-celery-beat) |
|
|
84
88
|
|
|
85
89
|
|
|
86
90
|
### 2. Register a dedicated Celery worker
|
|
@@ -187,8 +191,10 @@ Each task dict may also include an `"id"` key to supply a stable task ID; otherw
|
|
|
187
191
|
| `retry_kwargs` | Dict of kwargs passed to `self.retry()`, e.g. `{"countdown": 10}` |
|
|
188
192
|
| `soft_time_limit` | Seconds before a `SoftTimeLimitExceeded` is raised in the worker |
|
|
189
193
|
| `time_limit` | Hard kill timeout in seconds |
|
|
194
|
+
| `acks_late` | Ack the Celery trigger after the task finishes. Optional: leasing does not depend on it |
|
|
195
|
+
| `reject_on_worker_lost` | Requeue the trigger if the worker process is killed mid-task. Optional: leasing does not depend on it |
|
|
190
196
|
|
|
191
|
-
|
|
197
|
+
A `VTRRQueue` runs exactly one task: decorating a second function on the same instance raises `ValueError`. Every Celery trigger on a queue dequeues whatever item is next, so the Celery options (time limits, retries, `base`) must belong to the one task that every item runs. For several task types, create one `VTRRQueue` per task, each with its own `name` and `celery_queue`.
|
|
192
198
|
|
|
193
199
|
|
|
194
200
|
## Django-specific setup
|
|
@@ -286,20 +292,106 @@ class UploadView(View):
|
|
|
286
292
|
```
|
|
287
293
|
|
|
288
294
|
|
|
295
|
+
## Leasing and at-least-once delivery
|
|
296
|
+
|
|
297
|
+
Dequeuing an item does not delete it; it **leases** it. The item moves to an in-flight set with an expiry of `redis_time + lease_seconds` (Redis `TIME`, so worker clock skew is irrelevant) and its lease token is stored. The `@vtrr.task` wrapper acks the lease when your function returns. If the worker dies first, the lease expires and the next dequeue (or a call to `vtrr.reclaim()`, or the [periodic reclaim](#periodic-reclaim-with-celery-beat)) puts the item back in the queue, **rescored** under VTRR as if freshly enqueued; it does not keep its old position. Each reclaim adds one to the item's **timeouts** count, which is never reset.
|
|
298
|
+
|
|
299
|
+
Delivery is therefore **at-least-once: task bodies must be idempotent.** Inside a task, `self.request.vtrr_timeouts` is how many times this item's lease has expired and been reclaimed: 0 on the first run, +1 each time it is run again after a timeout. The library does not cap timeouts; your task decides when to give up (see [Timeout limits](#timeout-limits)).
|
|
300
|
+
|
|
301
|
+
The wrapper acks in a `finally`, so a Python exception does not redeliver forever; only process death or lease expiry does. This matches Celery's default `task_acks_on_failure_or_timeout=True`. A non-`Exception` such as `SystemExit` is treated as process death and is *not* acked: billiard raises `SystemExit` in the task when the hard time limit sends SIGTERM (SIGKILL follows ~0.1s later), so a task killed by its hard `time_limit` keeps its lease, is reclaimed, and counts as a timeout. If you use the low-level API directly:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
task_name, args, kwargs, task_id, token, timeouts = vtrr._dequeue()
|
|
305
|
+
try:
|
|
306
|
+
...
|
|
307
|
+
finally:
|
|
308
|
+
vtrr.ack(task_id, token) # False if the lease already expired and was re-granted; harmless
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
`self.retry()` / `autoretry_for` keep the lease: the wrapper does not ack when a task retries, and the retry run re-leases the item (expiry reset to `redis_time + lease_seconds`) and acks when it finishes. If the lease was already reclaimed during the retry delay (for example a `countdown` longer than `lease_seconds`), the item belongs to a newer delivery and the retry run is dropped instead of executing twice.
|
|
312
|
+
|
|
313
|
+
`ack` is fenced: it only succeeds when the token matches the current lease, so a slow worker whose lease was reclaimed cannot ack the new holder's lease.
|
|
314
|
+
|
|
315
|
+
### Celery configuration and timing
|
|
316
|
+
|
|
317
|
+
Recovery does not depend on Celery's delivery guarantees. The Celery message that wakes a worker (the "trigger") carries no payload; the work item lives in Redis under its lease. If a worker dies, the item comes back when its lease expires, and [periodic reclaim](#periodic-reclaim-with-celery-beat) wakes a worker for it. You do not need `acks_late`, `reject_on_worker_lost`, or a raised `visibility_timeout` for this. (They remain available task options. `reject_on_worker_lost` in particular does not speed recovery: the requeued trigger runs while the lease is still held, so it cannot reclaim the item.)
|
|
318
|
+
|
|
319
|
+
Required order:
|
|
320
|
+
|
|
321
|
+
```
|
|
322
|
+
soft_time_limit < time_limit (hard) < lease_seconds
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
For an hour-long task: soft 3600, hard 3720, lease 3900.
|
|
326
|
+
|
|
327
|
+
```python
|
|
328
|
+
vtrr = VTRRQueue(..., lease_seconds=3900, reclaim_interval=600)
|
|
329
|
+
|
|
330
|
+
@vtrr.task(soft_time_limit=3600, time_limit=3720)
|
|
331
|
+
def process_file(self, ...):
|
|
332
|
+
...
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
- **Hard `time_limit` is required.** `soft_time_limit` only raises an exception, which blocking C/IO code can delay or swallow. The hard limit kills the process, so by the time a lease expires the original holder is gone and reclaim rarely causes concurrent executions. Hitting the hard limit is itself recovered the same way: the lease is not acked, so the item is reclaimed after `lease_seconds` and its `timeouts` count goes up.
|
|
336
|
+
- **`lease_seconds` > hard limit**, for the reason above.
|
|
337
|
+
- A recovered item waits for its lease to expire, so worst-case recovery time is about `lease_seconds + reclaim_interval`.
|
|
338
|
+
- If you do enable `acks_late`, the Redis transport's `visibility_timeout` (default 3600s, broker-wide) applies to it: a trigger held unacked longer is redelivered while still running. The lease token fences the duplicate, but keep `visibility_timeout` above your longest task.
|
|
339
|
+
|
|
340
|
+
### Periodic reclaim with Celery Beat
|
|
341
|
+
|
|
342
|
+
Reclaim also runs at the start of every dequeue, but that is not enough on its own: if the queue is idle, or every trigger for waiting items has been lost (a worker died between receiving its trigger and dequeuing), no dequeue happens and the item sits there. `vtrr.reclaim_and_dispatch()` reclaims expired leases and then schedules dequeue workers for whatever is waiting (up to `max_concurrency` minus the triggers already in the broker). It is cheap: when nothing has expired and the queue is empty it is one Redis call that returns immediately.
|
|
343
|
+
|
|
344
|
+
Pass `reclaim_interval` to run it on Celery Beat:
|
|
345
|
+
|
|
346
|
+
```python
|
|
347
|
+
vtrr = VTRRQueue(..., reclaim_interval=600, reclaim_queue="celery")
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
At construction the library registers a task named `vtrr_queue.reclaim.{name}` and merges its entry into `celery_app.conf.beat_schedule`. You still have to run Beat (`celery -A myapp beat`), and the process that executes the task must have imported your `@vtrr.task` functions, which it uses as triggers.
|
|
351
|
+
|
|
352
|
+
- **`reclaim_queue`**: leave it unset to use your default Celery queue, or name a queue that some worker consumes. Don't send it to `celery_queue` if those workers are usually busy with long tasks; it would wait behind them.
|
|
353
|
+
- **If your app assigns `beat_schedule` wholesale after creating the `VTRRQueue`**, it overwrites the entry. Merge `vtrr.reclaim_beat_schedule()` back in:
|
|
354
|
+
|
|
355
|
+
```python
|
|
356
|
+
app.conf.beat_schedule = {**my_entries, **vtrr.reclaim_beat_schedule()}
|
|
357
|
+
```
|
|
358
|
+
- **Without Beat**, call `vtrr.reclaim_and_dispatch()` from whatever scheduler you have (cron, a management command, etc.).
|
|
359
|
+
|
|
360
|
+
### Timeout limits
|
|
361
|
+
|
|
362
|
+
The library never gives up on an item: every expired lease is reclaimed and re-enqueued. To cap redeliveries, check the timeouts count in your task and return early; the wrapper then acks the item and it is dropped.
|
|
363
|
+
|
|
364
|
+
```python
|
|
365
|
+
MAX_TIMEOUTS = 3
|
|
366
|
+
|
|
367
|
+
@vtrr.task(soft_time_limit=3600, time_limit=3720)
|
|
368
|
+
def process_file(self, ...):
|
|
369
|
+
if self.request.vtrr_timeouts >= MAX_TIMEOUTS:
|
|
370
|
+
log_poison_item(...) # your own dead-letter handling
|
|
371
|
+
return
|
|
372
|
+
...
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
|
|
289
376
|
## Redis keys
|
|
290
377
|
|
|
291
|
-
Each `VTRRQueue` instance manages
|
|
378
|
+
Each `VTRRQueue` instance manages these keys, namespaced under `vtrr:{name}:*`.
|
|
292
379
|
|
|
293
380
|
| Key | Type | Description |
|
|
294
381
|
|---|---|---|
|
|
295
382
|
| `vtrr:{name}:queue` | Sorted Set | Task IDs scored by virtual time; lowest score = highest priority |
|
|
296
383
|
| `vtrr:{name}:current_virtual_time` | String | Virtual time of the last dequeued task; resets to 0 when queue drains |
|
|
297
384
|
| `vtrr:{name}:partition_virtual_time` | Hash | Per-partition virtual time counter; deleted when queue drains |
|
|
298
|
-
| `vtrr:{name}:task` | Hash | `task_id → JSON payload
|
|
385
|
+
| `vtrr:{name}:task` | Hash | `task_id → JSON payload` for queued items; deleted when queue drains |
|
|
386
|
+
| `vtrr:{name}:meta` | Hash | `task_id → "weight:partition_key"`, kept until ack so reclaim can rescore |
|
|
387
|
+
| `vtrr:{name}:inflight` | Sorted Set | Leased task IDs scored by lease expiry (Redis time) |
|
|
388
|
+
| `vtrr:{name}:inflight_task` | Hash | `task_id → JSON payload` for leased items |
|
|
389
|
+
| `vtrr:{name}:lease` | Hash | `task_id → lease token` |
|
|
390
|
+
| `vtrr:{name}:timeouts` | Hash | `task_id → times its lease expired and was reclaimed`; cleared on ack |
|
|
299
391
|
|
|
300
392
|
Multiple `VTRRQueue` instances in the same application can safely share a Redis database as long as they have distinct `name` values.
|
|
301
393
|
|
|
302
|
-
All enqueue and
|
|
394
|
+
All enqueue, dequeue, ack and reclaim operations are atomic Lua scripts, so concurrent writers and workers are safe.
|
|
303
395
|
|
|
304
396
|
# Development
|
|
305
397
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vtrr-queue"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Virtual-time round-robin fair queue on Redis"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "Nicolas Johnson", email = "nicolas.johnson@c4ads.org" }]
|