vtrr-queue 0.1.0__tar.gz → 0.2.1__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.1
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,119 @@ 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
+ A task that raises an exception is not redelivered: the item is acked and finished, as in Celery's default behavior. Only a time limit or a dead worker causes redelivery (see [Retries and timeouts](#retries-and-timeouts)). 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
+ `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.
338
+
339
+ ### Retries and timeouts
340
+
341
+ | What happens in the task | Result | Counts as a timeout (`vtrr_timeouts`) |
342
+ |---|---|---|
343
+ | Returns normally | Done | No |
344
+ | Raises an exception | Done (failed); not run again | No |
345
+ | Calls `self.retry()`, or raises an exception in `autoretry_for`, with retries left | Run again after the retry delay | No |
346
+ | Raises an `autoretry_for` exception after `max_retries` is used up | Done (failed); the exception is raised | No |
347
+ | Hits `soft_time_limit` (`SoftTimeLimitExceeded`) | Put back in the queue once its lease expires | Yes |
348
+ | Hits the hard `time_limit`, or the worker dies | Put back in the queue once its lease expires | Yes |
349
+
350
+ - **Retries** (`self.retry()` and `autoretry_for`, with `retry_kwargs`, `retry_backoff`, etc.) behave as in plain Celery, and the item keeps its lease while it waits. `self.request.retries` counts them. The retry delay should stay well under `lease_seconds`: if the lease expires first, the retry is dropped and the item is run again as a timeout instead.
351
+ - **Timeouts** are what `self.request.vtrr_timeouts` counts. A timed-out item is re-enqueued with a fresh position in the queue, and the count is never reset.
352
+ - `SoftTimeLimitExceeded` is always treated as a timeout, even if it is listed in `autoretry_for`. It is never counted by `max_retries`.
353
+
354
+ ### Celery configuration and timing
355
+
356
+ 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.)
357
+
358
+ Required order:
359
+
360
+ ```
361
+ soft_time_limit < time_limit (hard) < lease_seconds
362
+ ```
363
+
364
+ For an hour-long task: soft 3600, hard 3720, lease 3900.
365
+
366
+ ```python
367
+ vtrr = VTRRQueue(..., lease_seconds=3900, reclaim_interval=600)
368
+
369
+ @vtrr.task(soft_time_limit=3600, time_limit=3720)
370
+ def process_file(self, ...):
371
+ ...
372
+ ```
373
+
374
+ - **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. A task that hits either limit is put back in the queue after `lease_seconds` and counted as a timeout.
375
+ - **`lease_seconds` > hard limit**, for the reason above.
376
+ - A recovered item waits for its lease to expire, so worst-case recovery time is about `lease_seconds + reclaim_interval`.
377
+ - 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.
378
+
379
+ ### Periodic reclaim with Celery Beat
380
+
381
+ 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.
382
+
383
+ Pass `reclaim_interval` to run it on Celery Beat:
384
+
385
+ ```python
386
+ vtrr = VTRRQueue(..., reclaim_interval=600, reclaim_queue="celery")
387
+ ```
388
+
389
+ 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.
390
+
391
+ - **`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.
392
+ - **If your app assigns `beat_schedule` wholesale after creating the `VTRRQueue`**, it overwrites the entry. Merge `vtrr.reclaim_beat_schedule()` back in:
393
+
394
+ ```python
395
+ app.conf.beat_schedule = {**my_entries, **vtrr.reclaim_beat_schedule()}
396
+ ```
397
+ - **Without Beat**, call `vtrr.reclaim_and_dispatch()` from whatever scheduler you have (cron, a management command, etc.).
398
+
399
+ ### Timeout limits
400
+
401
+ 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.
402
+
403
+ ```python
404
+ MAX_TIMEOUTS = 3
405
+
406
+ @vtrr.task(soft_time_limit=3600, time_limit=3720)
407
+ def process_file(self, ...):
408
+ if self.request.vtrr_timeouts >= MAX_TIMEOUTS:
409
+ log_poison_item(...) # your own dead-letter handling
410
+ return
411
+ ...
412
+ ```
413
+
414
+
315
415
  ## Redis keys
316
416
 
317
- Each `VTRRQueue` instance manages four keys namespaced under `vtrr:{name}:*`.
417
+ Each `VTRRQueue` instance manages these keys, namespaced under `vtrr:{name}:*`.
318
418
 
319
419
  | Key | Type | Description |
320
420
  |---|---|---|
321
421
  | `vtrr:{name}:queue` | Sorted Set | Task IDs scored by virtual time; lowest score = highest priority |
322
422
  | `vtrr:{name}:current_virtual_time` | String | Virtual time of the last dequeued task; resets to 0 when queue drains |
323
423
  | `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 |
424
+ | `vtrr:{name}:task` | Hash | `task_id → JSON payload` for queued items; deleted when queue drains |
425
+ | `vtrr:{name}:meta` | Hash | `task_id → "weight:partition_key"`, kept until ack so reclaim can rescore |
426
+ | `vtrr:{name}:inflight` | Sorted Set | Leased task IDs scored by lease expiry (Redis time) |
427
+ | `vtrr:{name}:inflight_task` | Hash | `task_id → JSON payload` for leased items |
428
+ | `vtrr:{name}:lease` | Hash | `task_id → lease token` |
429
+ | `vtrr:{name}:timeouts` | Hash | `task_id → times its lease expired and was reclaimed`; cleared on ack |
325
430
 
326
431
  Multiple `VTRRQueue` instances in the same application can safely share a Redis database as long as they have distinct `name` values.
327
432
 
328
- All enqueue and dequeue operations are atomic Lua scripts, so concurrent writers and workers are safe.
433
+ All enqueue, dequeue, ack and reclaim operations are atomic Lua scripts, so concurrent writers and workers are safe.
329
434
 
330
435
  # Development
331
436
 
@@ -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,119 @@ 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
+ A task that raises an exception is not redelivered: the item is acked and finished, as in Celery's default behavior. Only a time limit or a dead worker causes redelivery (see [Retries and timeouts](#retries-and-timeouts)). 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
+ `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.
312
+
313
+ ### Retries and timeouts
314
+
315
+ | What happens in the task | Result | Counts as a timeout (`vtrr_timeouts`) |
316
+ |---|---|---|
317
+ | Returns normally | Done | No |
318
+ | Raises an exception | Done (failed); not run again | No |
319
+ | Calls `self.retry()`, or raises an exception in `autoretry_for`, with retries left | Run again after the retry delay | No |
320
+ | Raises an `autoretry_for` exception after `max_retries` is used up | Done (failed); the exception is raised | No |
321
+ | Hits `soft_time_limit` (`SoftTimeLimitExceeded`) | Put back in the queue once its lease expires | Yes |
322
+ | Hits the hard `time_limit`, or the worker dies | Put back in the queue once its lease expires | Yes |
323
+
324
+ - **Retries** (`self.retry()` and `autoretry_for`, with `retry_kwargs`, `retry_backoff`, etc.) behave as in plain Celery, and the item keeps its lease while it waits. `self.request.retries` counts them. The retry delay should stay well under `lease_seconds`: if the lease expires first, the retry is dropped and the item is run again as a timeout instead.
325
+ - **Timeouts** are what `self.request.vtrr_timeouts` counts. A timed-out item is re-enqueued with a fresh position in the queue, and the count is never reset.
326
+ - `SoftTimeLimitExceeded` is always treated as a timeout, even if it is listed in `autoretry_for`. It is never counted by `max_retries`.
327
+
328
+ ### Celery configuration and timing
329
+
330
+ 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.)
331
+
332
+ Required order:
333
+
334
+ ```
335
+ soft_time_limit < time_limit (hard) < lease_seconds
336
+ ```
337
+
338
+ For an hour-long task: soft 3600, hard 3720, lease 3900.
339
+
340
+ ```python
341
+ vtrr = VTRRQueue(..., lease_seconds=3900, reclaim_interval=600)
342
+
343
+ @vtrr.task(soft_time_limit=3600, time_limit=3720)
344
+ def process_file(self, ...):
345
+ ...
346
+ ```
347
+
348
+ - **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. A task that hits either limit is put back in the queue after `lease_seconds` and counted as a timeout.
349
+ - **`lease_seconds` > hard limit**, for the reason above.
350
+ - A recovered item waits for its lease to expire, so worst-case recovery time is about `lease_seconds + reclaim_interval`.
351
+ - 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.
352
+
353
+ ### Periodic reclaim with Celery Beat
354
+
355
+ 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.
356
+
357
+ Pass `reclaim_interval` to run it on Celery Beat:
358
+
359
+ ```python
360
+ vtrr = VTRRQueue(..., reclaim_interval=600, reclaim_queue="celery")
361
+ ```
362
+
363
+ 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.
364
+
365
+ - **`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.
366
+ - **If your app assigns `beat_schedule` wholesale after creating the `VTRRQueue`**, it overwrites the entry. Merge `vtrr.reclaim_beat_schedule()` back in:
367
+
368
+ ```python
369
+ app.conf.beat_schedule = {**my_entries, **vtrr.reclaim_beat_schedule()}
370
+ ```
371
+ - **Without Beat**, call `vtrr.reclaim_and_dispatch()` from whatever scheduler you have (cron, a management command, etc.).
372
+
373
+ ### Timeout limits
374
+
375
+ 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.
376
+
377
+ ```python
378
+ MAX_TIMEOUTS = 3
379
+
380
+ @vtrr.task(soft_time_limit=3600, time_limit=3720)
381
+ def process_file(self, ...):
382
+ if self.request.vtrr_timeouts >= MAX_TIMEOUTS:
383
+ log_poison_item(...) # your own dead-letter handling
384
+ return
385
+ ...
386
+ ```
387
+
388
+
289
389
  ## Redis keys
290
390
 
291
- Each `VTRRQueue` instance manages four keys namespaced under `vtrr:{name}:*`.
391
+ Each `VTRRQueue` instance manages these keys, namespaced under `vtrr:{name}:*`.
292
392
 
293
393
  | Key | Type | Description |
294
394
  |---|---|---|
295
395
  | `vtrr:{name}:queue` | Sorted Set | Task IDs scored by virtual time; lowest score = highest priority |
296
396
  | `vtrr:{name}:current_virtual_time` | String | Virtual time of the last dequeued task; resets to 0 when queue drains |
297
397
  | `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 |
398
+ | `vtrr:{name}:task` | Hash | `task_id → JSON payload` for queued items; deleted when queue drains |
399
+ | `vtrr:{name}:meta` | Hash | `task_id → "weight:partition_key"`, kept until ack so reclaim can rescore |
400
+ | `vtrr:{name}:inflight` | Sorted Set | Leased task IDs scored by lease expiry (Redis time) |
401
+ | `vtrr:{name}:inflight_task` | Hash | `task_id → JSON payload` for leased items |
402
+ | `vtrr:{name}:lease` | Hash | `task_id → lease token` |
403
+ | `vtrr:{name}:timeouts` | Hash | `task_id → times its lease expired and was reclaimed`; cleared on ack |
299
404
 
300
405
  Multiple `VTRRQueue` instances in the same application can safely share a Redis database as long as they have distinct `name` values.
301
406
 
302
- All enqueue and dequeue operations are atomic Lua scripts, so concurrent writers and workers are safe.
407
+ All enqueue, dequeue, ack and reclaim operations are atomic Lua scripts, so concurrent writers and workers are safe.
303
408
 
304
409
  # Development
305
410
 
@@ -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.1"
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" }]