vtrr-queue 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vtrr-queue
3
- Version: 0.2.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
@@ -324,7 +324,7 @@ Dequeuing an item does not delete it; it **leases** it. The item moves to an in-
324
324
 
325
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
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:
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
328
 
329
329
  ```python
330
330
  task_name, args, kwargs, task_id, token, timeouts = vtrr._dequeue()
@@ -334,10 +334,23 @@ finally:
334
334
  vtrr.ack(task_id, token) # False if the lease already expired and was re-granted; harmless
335
335
  ```
336
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
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.
340
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
+
341
354
  ### Celery configuration and timing
342
355
 
343
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.)
@@ -358,7 +371,7 @@ def process_file(self, ...):
358
371
  ...
359
372
  ```
360
373
 
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.
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.
362
375
  - **`lease_seconds` > hard limit**, for the reason above.
363
376
  - A recovered item waits for its lease to expire, so worst-case recovery time is about `lease_seconds + reclaim_interval`.
364
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.
@@ -298,7 +298,7 @@ Dequeuing an item does not delete it; it **leases** it. The item moves to an in-
298
298
 
299
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
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:
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
302
 
303
303
  ```python
304
304
  task_name, args, kwargs, task_id, token, timeouts = vtrr._dequeue()
@@ -308,10 +308,23 @@ finally:
308
308
  vtrr.ack(task_id, token) # False if the lease already expired and was re-granted; harmless
309
309
  ```
310
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
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.
314
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
+
315
328
  ### Celery configuration and timing
316
329
 
317
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.)
@@ -332,7 +345,7 @@ def process_file(self, ...):
332
345
  ...
333
346
  ```
334
347
 
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.
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.
336
349
  - **`lease_seconds` > hard limit**, for the reason above.
337
350
  - A recovered item waits for its lease to expire, so worst-case recovery time is about `lease_seconds + reclaim_interval`.
338
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vtrr-queue"
7
- version = "0.2.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" }]
@@ -4,7 +4,8 @@ import uuid
4
4
  from pathlib import Path
5
5
  from typing import Any, Callable
6
6
  from celery import Task
7
- from celery.exceptions import Retry
7
+ from celery.exceptions import Retry, SoftTimeLimitExceeded
8
+ from celery.utils.time import get_exponential_backoff_interval
8
9
 
9
10
 
10
11
  import redis as redis_lib
@@ -219,6 +220,13 @@ class VTRRQueue:
219
220
  options = dict(task_options)
220
221
  options.pop("bind", None)
221
222
  base = _resolve_base(options.pop("base", None))
223
+ # Autoretry is re-implemented by celery_wrapper instead of using Celery's autoretry_for wrapper
224
+ # This is to support retries along with vtrr_queue timeouts/message leasing
225
+ autoretry_for = tuple(options.pop("autoretry_for", ()))
226
+ retry_backoff = float(options.pop("retry_backoff", False))
227
+ retry_backoff_max = int(options.pop("retry_backoff_max", 600))
228
+ retry_jitter = options.pop("retry_jitter", True)
229
+ retry_kwargs = options.pop("retry_kwargs", {})
222
230
  task_name = f"{f.__module__}.{f.__name__}"
223
231
 
224
232
  @self._celery_app.task(name=task_name, bind=True, base=base, **options)
@@ -281,11 +289,35 @@ class VTRRQueue:
281
289
  registered.name if registered else None,
282
290
  )
283
291
  return
284
- registered._fn(celery_task, *args, **kwargs)
292
+ try:
293
+ registered._fn(celery_task, *args, **kwargs)
294
+ except Retry:
295
+ # This exception is only raised by celery if self.retry()
296
+ # was already called by the inner function, so assume the retry task is already scheduled.
297
+ raise
298
+ except SoftTimeLimitExceeded:
299
+ # Treated as a vtrr timeout. Re-raise so terminated=True and message goes through the lease lifecycle.
300
+ raise
301
+ except autoretry_for as exc:
302
+ # Raises Retry (lease kept), or re-raises exc once retries are exhausted.
303
+ auto_retry_kwargs = dict(retry_kwargs)
304
+ if retry_backoff:
305
+ auto_retry_kwargs["countdown"] = (
306
+ get_exponential_backoff_interval(
307
+ factor=int(max(1.0, retry_backoff)),
308
+ retries=celery_task.request.retries,
309
+ maximum=retry_backoff_max,
310
+ full_jitter=retry_jitter,
311
+ )
312
+ )
313
+ celery_task.retry(exc=exc, **auto_retry_kwargs)
285
314
  except Retry:
286
315
  # Keep the lease: the retry run re-leases it (renew) and acks when done.
287
316
  retrying = True
288
317
  raise
318
+ except SoftTimeLimitExceeded:
319
+ terminated = True
320
+ raise
289
321
  except BaseException as err:
290
322
  # A non-Exception (SystemExit, KeyboardInterrupt, ...) means the process is being
291
323
  # terminated, e.g. billiard's SIGTERM at the hard time limit (it raises SystemExit
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock
5
5
 
6
6
  import fakeredis
7
7
  import pytest
8
+ from celery.exceptions import SoftTimeLimitExceeded
8
9
 
9
10
  import vtrr_queue.queue as vtrr_module
10
11
  from vtrr_queue.queue import VTRRQueue
@@ -578,6 +579,23 @@ class TestLeases:
578
579
  assert vtrr.reclaim() == ["t1"]
579
580
  assert vtrr._redis.hget(vtrr._timeouts_key, "t1") == b"1"
580
581
 
582
+ def test_wrapper_does_not_ack_on_soft_time_limit(self, vtrr, monkeypatch):
583
+ """SoftTimeLimitExceeded is treated like the hard limit: the lease is kept, so it
584
+ expires, the item is reclaimed, and the reclaim counts as a timeout."""
585
+
586
+ @vtrr.task
587
+ def work(self):
588
+ raise SoftTimeLimitExceeded
589
+
590
+ monkeypatch.setattr(vtrr, "_schedule_workers", lambda *a, **k: None)
591
+ work.queue(partition_key="A", tasks=[{"id": "t1"}])
592
+ with pytest.raises(SoftTimeLimitExceeded):
593
+ kick(work)
594
+ assert vtrr._redis.zscore(vtrr._inflight_key, "t1") is not None # still leased
595
+ expire(vtrr, "t1")
596
+ assert vtrr.reclaim() == ["t1"]
597
+ assert vtrr._redis.hget(vtrr._timeouts_key, "t1") == b"1"
598
+
581
599
  def test_timeouts_is_visible_to_task(self, vtrr, monkeypatch):
582
600
  """A task run from a first dequeue sees self.request.vtrr_timeouts == 0."""
583
601
  seen = []
@@ -632,6 +650,74 @@ class TestRetryReleasesLease:
632
650
  assert in_flight == [1, 1] # leased during the original run and the retry
633
651
  assert vtrr._redis.zcard(vtrr._inflight_key) == 0 # acked once, at the end
634
652
 
653
+ def test_autoretry_for_holds_lease_and_acks_at_end(self, vtrr, monkeypatch):
654
+ """An exception in autoretry_for keeps the lease through the retry, like self.retry()."""
655
+ in_flight = []
656
+
657
+ @vtrr.task(
658
+ autoretry_for=(ConnectionRefusedError,),
659
+ max_retries=2,
660
+ retry_kwargs={"countdown": 0},
661
+ )
662
+ def work(self):
663
+ in_flight.append(vtrr._redis.zcard(vtrr._inflight_key))
664
+ if len(in_flight) == 1:
665
+ raise ConnectionRefusedError
666
+
667
+ monkeypatch.setattr(vtrr, "_schedule_workers", lambda *a, **k: None)
668
+ work.queue(partition_key="A", tasks=[{"id": "t1"}])
669
+ work._celery_task.apply(kwargs={"is_start": True}, throw=False)
670
+ # Work able to run twice and so it was able to re-acquire the lease after the exception.
671
+ assert in_flight == [1, 1]
672
+ assert vtrr._redis.zcard(vtrr._inflight_key) == 0
673
+
674
+ def test_autoretry_for_acks_when_retries_exhausted(self, vtrr, monkeypatch):
675
+ """Once max_retries is used up the original exception propagates and the item is acked."""
676
+ runs = []
677
+
678
+ @vtrr.task(
679
+ autoretry_for=(ConnectionRefusedError,),
680
+ max_retries=1,
681
+ retry_kwargs={"countdown": 0},
682
+ )
683
+ def work(self):
684
+ runs.append(1)
685
+ raise ConnectionRefusedError
686
+
687
+ monkeypatch.setattr(vtrr, "_schedule_workers", lambda *a, **k: None)
688
+ work.queue(partition_key="A", tasks=[{"id": "t1"}])
689
+ # eager mode runs the retry inline, so the final exception surfaces through apply()
690
+ with pytest.raises(ConnectionRefusedError):
691
+ work._celery_task.apply(kwargs={"is_start": True}, throw=False)
692
+ assert len(runs) == 2 # original + 1 retry
693
+ assert vtrr._redis.zcard(vtrr._inflight_key) == 0
694
+ assert vtrr._redis.hget(vtrr._timeouts_key, "t1") is None
695
+
696
+ def test_soft_time_limit_in_autoretry_for_goes_through_lease_expiry(
697
+ self, vtrr, monkeypatch
698
+ ):
699
+ """SoftTimeLimitExceeded listed in autoretry_for is not Celery-retried: the lease is
700
+ kept until it expires, then the reclaim counts it as a timeout."""
701
+ runs = []
702
+
703
+ @vtrr.task(
704
+ autoretry_for=(SoftTimeLimitExceeded,),
705
+ max_retries=3,
706
+ retry_kwargs={"countdown": 0},
707
+ )
708
+ def work(self):
709
+ runs.append(1)
710
+ raise SoftTimeLimitExceeded
711
+
712
+ monkeypatch.setattr(vtrr, "_schedule_workers", lambda *a, **k: None)
713
+ work.queue(partition_key="A", tasks=[{"id": "t1"}])
714
+ work._celery_task.apply(kwargs={"is_start": True}, throw=False)
715
+ assert len(runs) == 1
716
+ assert vtrr._redis.zscore(vtrr._inflight_key, "t1") is not None
717
+ expire(vtrr, "t1")
718
+ assert vtrr.reclaim() == ["t1"]
719
+ assert vtrr._redis.hget(vtrr._timeouts_key, "t1") == b"1"
720
+
635
721
  def test_retry_dropped_when_lease_was_lost(self, vtrr):
636
722
  """A retry run whose token no longer matches any lease (reclaimed, re-granted or
637
723
  acked) is dropped without."""
File without changes
File without changes
File without changes
File without changes
File without changes