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.
@@ -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 # pops one task, called by each @vtrr.task celery wrapper
25
- tests/ # empty — needs fakeredis-based tests
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 enqueue.lua modification without updating dequeue.lua** — the two scripts share assumptions about key layout and must stay consistent. Both are atomic Lua scripts; never replace them with multi-command Python.
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 four keys under `vtrr:{name}:*`. Do not rename without updating both Lua scripts and the README.
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
- All four keys are deleted/reset when the queue drains (handled in `dequeue.lua`).
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.1.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
- Since each `@vtrr.task` becomes its own Celery task, different task functions can have different retry policies and time limits.
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 four keys namespaced under `vtrr:{name}:*`.
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`; deleted when queue drains |
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 dequeue operations are atomic Lua scripts, so concurrent writers and workers are safe.
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
- Since each `@vtrr.task` becomes its own Celery task, different task functions can have different retry policies and time limits.
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 four keys namespaced under `vtrr:{name}:*`.
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`; deleted when queue drains |
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 dequeue operations are atomic Lua scripts, so concurrent writers and workers are safe.
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.1.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" }]