threadmill 0.3.1__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {threadmill-0.3.1 → threadmill-0.5.0}/PKG-INFO +78 -12
  2. {threadmill-0.3.1 → threadmill-0.5.0}/README.md +76 -7
  3. {threadmill-0.3.1 → threadmill-0.5.0}/pyproject.toml +2 -5
  4. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/_version.py +3 -3
  5. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/base.py +87 -42
  6. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/lua/acquire.lua +1 -1
  7. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/redis.py +95 -65
  8. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/exceptions.py +0 -2
  9. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/executor.py +17 -4
  10. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/inspector/app.py +102 -9
  11. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/inspector/inspector.scss +31 -0
  12. threadmill-0.5.0/threadmill/inspector/screens.py +52 -0
  13. threadmill-0.5.0/threadmill/retry.py +71 -0
  14. {threadmill-0.3.1 → threadmill-0.5.0}/LICENSE +0 -0
  15. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/__init__.py +0 -0
  16. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/__init__.py +0 -0
  17. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/lua/acknowledge.lua +0 -0
  18. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/lua/mover.lua +0 -0
  19. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/lua/reaper.lua +0 -0
  20. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/inspector/__init__.py +0 -0
  21. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/management/__init__.py +0 -0
  22. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/management/commands/__init__.py +0 -0
  23. {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/management/commands/threadmill.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadmill
3
- Version: 0.3.1
3
+ Version: 0.5.0
4
4
  Summary: The most reliable backend for Django's task framework.
5
5
  Keywords: Django,tasks,worker
6
6
  Author-email: Johannes Maron <johannes@maron.family>
7
- Requires-Python: >=3.12
7
+ Requires-Python: >=3.14
8
8
  Description-Content-Type: text/markdown
9
9
  Classifier: Development Status :: 4 - Beta
10
10
  Classifier: Programming Language :: Python
@@ -20,11 +20,8 @@ Classifier: Topic :: Software Development
20
20
  Classifier: Programming Language :: Python
21
21
  Classifier: Programming Language :: Python :: 3
22
22
  Classifier: Programming Language :: Python :: 3 :: Only
23
- Classifier: Programming Language :: Python :: 3.12
24
- Classifier: Programming Language :: Python :: 3.13
25
23
  Classifier: Programming Language :: Python :: 3.14
26
24
  Classifier: Framework :: Django
27
- Classifier: Framework :: Django :: 6.0
28
25
  Classifier: Framework :: Django :: 6.1
29
26
  License-File: LICENSE
30
27
  Requires-Dist: django>=6.0
@@ -59,13 +56,9 @@ Provides-Extra: redis
59
56
 
60
57
  ## Design Principles
61
58
 
62
- - **Durability** – We recover from any failures, even poorly written tasks.
63
- - **Consistency** – We never lose data, even if someone unplugs the power or network.
64
- - **Utilization** – We keep the CPU saturated with tasks, not with idle time or waiting for locks.
65
-
66
- ## Sponsors
67
-
68
- [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
59
+ - **Durability** – Recover from any failures, even poorly written tasks.
60
+ - **Consistency** – Never lose data, even if someone unplugs the power or network.
61
+ - **Utilization** – Keep the CPU saturated with tasks, not with idle time or waiting for locks.
69
62
 
70
63
  ## Setup
71
64
 
@@ -176,5 +169,78 @@ every multi-key operation — including the cross-queue acquire — runs on a si
176
169
  shard. Scale horizontally by running additional backend aliases, not by relying
177
170
  on cross-slot operations.
178
171
 
172
+ ### Retrying failed tasks
173
+
174
+ Pass a `retry` callback to `@task()` to retry failed tasks with a delay.
175
+ The callback receives a `TaskContext` — use `context.attempt` for the current
176
+ attempt count and `context.task_result.errors[-1]` for the latest error.
177
+ Return a `timedelta` to schedule the next attempt, or `None` to stop retrying.
178
+
179
+ The worker re-queues the failed task, preserving its ID and error history;
180
+ the broker promotes it back to the ready queue once the delay elapses.
181
+
182
+ #### Built-in `ExponentialBackoff`
183
+
184
+ `threadmill.retry.ExponentialBackoff` provides a serializable exponential
185
+ backoff strategy out of the box. It caps the delay at `max_delay`, stops
186
+ after `max_retries` attempts, and only retries exceptions listed in
187
+ `expected_exceptions`.
188
+
189
+ ```python
190
+ import datetime
191
+
192
+ from django.tasks import task
193
+ from requests import HTTPError
194
+
195
+ from threadmill.retry import ExponentialBackoff
196
+
197
+
198
+ @task(
199
+ retry=ExponentialBackoff(
200
+ base_delay=datetime.timedelta(seconds=1),
201
+ max_delay=datetime.timedelta(minutes=5),
202
+ factor=2.0,
203
+ max_retries=5,
204
+ expected_exceptions=(HTTPError,),
205
+ )
206
+ )
207
+ def fetch_github_api(url: str): ...
208
+ ```
209
+
210
+ #### Custom retry callbacks
211
+
212
+ For cases that need logic beyond what `ExponentialBackoff` supports,
213
+ write a callable that accepts a `TaskContext` and returns a `timedelta`
214
+ or `None`. Use `TaskError.exception_class` to filter by exception type:
215
+
216
+ ```python
217
+ import datetime
218
+
219
+ from django.tasks import task
220
+ from django.tasks.base import TaskContext
221
+ from requests import HTTPError
222
+
223
+
224
+ def retry_on_rate_limit(context: TaskContext) -> datetime.timedelta | None:
225
+ """Retry HTTP 429 responses with exponential backoff, up to 5 attempts."""
226
+ if context.attempt >= 5:
227
+ return None
228
+ error = context.task_result.errors[-1]
229
+ if not issubclass(error.exception_class, HTTPError):
230
+ return None
231
+ return min(
232
+ datetime.timedelta(seconds=2**context.attempt),
233
+ datetime.timedelta(seconds=60),
234
+ )
235
+
236
+
237
+ @task(retry=retry_on_rate_limit)
238
+ def fetch_github_api(url: str): ...
239
+ ```
240
+
241
+ ## Sponsors
242
+
243
+ [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
244
+
179
245
  [django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
180
246
 
@@ -17,13 +17,9 @@
17
17
 
18
18
  ## Design Principles
19
19
 
20
- - **Durability** – We recover from any failures, even poorly written tasks.
21
- - **Consistency** – We never lose data, even if someone unplugs the power or network.
22
- - **Utilization** – We keep the CPU saturated with tasks, not with idle time or waiting for locks.
23
-
24
- ## Sponsors
25
-
26
- [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
20
+ - **Durability** – Recover from any failures, even poorly written tasks.
21
+ - **Consistency** – Never lose data, even if someone unplugs the power or network.
22
+ - **Utilization** – Keep the CPU saturated with tasks, not with idle time or waiting for locks.
27
23
 
28
24
  ## Setup
29
25
 
@@ -134,4 +130,77 @@ every multi-key operation — including the cross-queue acquire — runs on a si
134
130
  shard. Scale horizontally by running additional backend aliases, not by relying
135
131
  on cross-slot operations.
136
132
 
133
+ ### Retrying failed tasks
134
+
135
+ Pass a `retry` callback to `@task()` to retry failed tasks with a delay.
136
+ The callback receives a `TaskContext` — use `context.attempt` for the current
137
+ attempt count and `context.task_result.errors[-1]` for the latest error.
138
+ Return a `timedelta` to schedule the next attempt, or `None` to stop retrying.
139
+
140
+ The worker re-queues the failed task, preserving its ID and error history;
141
+ the broker promotes it back to the ready queue once the delay elapses.
142
+
143
+ #### Built-in `ExponentialBackoff`
144
+
145
+ `threadmill.retry.ExponentialBackoff` provides a serializable exponential
146
+ backoff strategy out of the box. It caps the delay at `max_delay`, stops
147
+ after `max_retries` attempts, and only retries exceptions listed in
148
+ `expected_exceptions`.
149
+
150
+ ```python
151
+ import datetime
152
+
153
+ from django.tasks import task
154
+ from requests import HTTPError
155
+
156
+ from threadmill.retry import ExponentialBackoff
157
+
158
+
159
+ @task(
160
+ retry=ExponentialBackoff(
161
+ base_delay=datetime.timedelta(seconds=1),
162
+ max_delay=datetime.timedelta(minutes=5),
163
+ factor=2.0,
164
+ max_retries=5,
165
+ expected_exceptions=(HTTPError,),
166
+ )
167
+ )
168
+ def fetch_github_api(url: str): ...
169
+ ```
170
+
171
+ #### Custom retry callbacks
172
+
173
+ For cases that need logic beyond what `ExponentialBackoff` supports,
174
+ write a callable that accepts a `TaskContext` and returns a `timedelta`
175
+ or `None`. Use `TaskError.exception_class` to filter by exception type:
176
+
177
+ ```python
178
+ import datetime
179
+
180
+ from django.tasks import task
181
+ from django.tasks.base import TaskContext
182
+ from requests import HTTPError
183
+
184
+
185
+ def retry_on_rate_limit(context: TaskContext) -> datetime.timedelta | None:
186
+ """Retry HTTP 429 responses with exponential backoff, up to 5 attempts."""
187
+ if context.attempt >= 5:
188
+ return None
189
+ error = context.task_result.errors[-1]
190
+ if not issubclass(error.exception_class, HTTPError):
191
+ return None
192
+ return min(
193
+ datetime.timedelta(seconds=2**context.attempt),
194
+ datetime.timedelta(seconds=60),
195
+ )
196
+
197
+
198
+ @task(retry=retry_on_rate_limit)
199
+ def fetch_github_api(url: str): ...
200
+ ```
201
+
202
+ ## Sponsors
203
+
204
+ [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
205
+
137
206
  [django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
@@ -26,14 +26,11 @@ classifiers = [
26
26
  "Programming Language :: Python",
27
27
  "Programming Language :: Python :: 3",
28
28
  "Programming Language :: Python :: 3 :: Only",
29
- "Programming Language :: Python :: 3.12",
30
- "Programming Language :: Python :: 3.13",
31
29
  "Programming Language :: Python :: 3.14",
32
30
  "Framework :: Django",
33
- "Framework :: Django :: 6.0",
34
31
  "Framework :: Django :: 6.1",
35
32
  ]
36
- requires-python = ">=3.12"
33
+ requires-python = ">=3.14"
37
34
  dependencies = ["django>=6.0"]
38
35
 
39
36
  [project.optional-dependencies]
@@ -59,7 +56,7 @@ name = "threadmill"
59
56
  write_to = "threadmill/_version.py"
60
57
 
61
58
  [tool.pytest.ini_options]
62
- minversion = "6.0"
59
+ minversion = "6.1a1"
63
60
  addopts = "--cov --cov-report=xml --cov-report=term --tb=short -rxs --benchmark-autosave --benchmark-group-by=fullname --benchmark-min-rounds=10"
64
61
  testpaths = ["tests"]
65
62
  DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.3.1'
22
- __version_tuple__ = version_tuple = (0, 3, 1)
21
+ __version__ = version = '0.5.0'
22
+ __version_tuple__ = version_tuple = (0, 5, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g32b680b8b'
24
+ __commit_id__ = commit_id = 'g190acbe27'
@@ -1,39 +1,63 @@
1
- from __future__ import annotations
2
-
3
1
  import collections.abc
4
2
  import dataclasses
5
3
  import datetime
6
4
  import json
7
5
  import threading
6
+ import typing
8
7
  from abc import ABC
9
8
 
10
- import django
11
9
  from django.core.serializers.json import DjangoJSONEncoder
12
10
  from django.tasks import DEFAULT_TASK_QUEUE_NAME, Task, TaskResult, TaskResultStatus
13
11
  from django.tasks.backends.base import BaseTaskBackend
14
- from django.tasks.base import TaskError
12
+ from django.tasks.base import TaskContext, TaskError
13
+ from django.tasks.exceptions import InvalidTask
14
+ from django.utils.inspect import is_module_level_function
15
15
  from django.utils.module_loading import import_string
16
16
 
17
- if django.VERSION == (6, 0):
18
- # https://github.com/django/django/commit/8c8b833d32c02d3ae6f43b04bb1e45968796b402
19
- @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
20
- class Task(Task):
21
- @classmethod
22
- def _reconstruct(cls, kwargs):
23
- func_path = kwargs["func"]
24
- try:
25
- func = import_string(func_path)
26
- kwargs["func"] = func.func
27
- except (ImportError, AttributeError) as e:
28
- msg = f"Expected {func_path!r} to point to a Task instance."
29
- raise ValueError(msg) from e
30
- return cls(**kwargs)
31
17
 
32
- def __reduce__(self):
33
- kwargs = {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
34
- kwargs["func"] = self.module_path
18
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
19
+ class RetryTask(Task):
20
+ """Task with an optional retry callback for backoff scheduling.
21
+
22
+ Usage:
23
+
24
+ def retry_callback(context: TaskContext) -> datetime.timedelta | None:
25
+ if context.attempt < 3:
26
+ return datetime.timedelta(seconds=60 * (2 ** context.attempt))
27
+
28
+ @task(retry=retry_callback)
29
+ def my_task():
30
+ ...
31
+
32
+ """
35
33
 
36
- return (self.__class__._reconstruct, (kwargs,))
34
+ retry: collections.abc.Callable[[TaskContext], datetime.timedelta | None] | None = (
35
+ None
36
+ )
37
+
38
+ @property
39
+ def retry_path(self) -> tuple[str, tuple, dict[str, typing.Any]] | str | None:
40
+ """Importable dotted path of the retry callback, or None."""
41
+ if self.retry:
42
+ if hasattr(self.retry, "deconstruct"):
43
+ return self.retry.deconstruct()
44
+ return f"{self.retry.__module__}.{self.retry.__qualname__}"
45
+
46
+ @classmethod
47
+ def _reconstruct(cls, kwargs):
48
+ if retry_path := kwargs.pop("retry", None):
49
+ try:
50
+ path, pos_args, kw_args = retry_path
51
+ except ValueError:
52
+ kwargs["retry"] = import_string(retry_path)
53
+ else:
54
+ kwargs["retry"] = import_string(path)(*pos_args, **kw_args)
55
+ return super()._reconstruct(kwargs)
56
+
57
+ def __reduce__(self):
58
+ reconstructor, (kwargs,) = super().__reduce__()
59
+ kwargs["retry"] = self.retry_path
60
+ return (reconstructor, (kwargs,))
37
61
 
38
62
 
39
63
  @dataclasses.dataclass(kw_only=True, slots=True)
@@ -116,19 +140,20 @@ class TaskResultEncoder(DjangoJSONEncoder):
116
140
  field.name: getattr(o, field.name)
117
141
  for field in dataclasses.fields(type(o))
118
142
  }
119
- if isinstance(o, Task):
120
- return {
143
+ if isinstance(o, RetryTask):
144
+ data = {
121
145
  field.name: getattr(o, field.name)
122
- for field in dataclasses.fields(Task)
123
- if field.name != "func" # Exclude the function object itself
124
- } | {"func": o.module_path}
146
+ for field in dataclasses.fields(RetryTask)
147
+ if field.name not in {"func", "retry"}
148
+ } | {"func": o.module_path, "retry": o.retry_path}
149
+ return data
125
150
  return super().default(o)
126
151
 
127
152
 
128
153
  class ThreadmillTaskBackend(BaseTaskBackend, ABC):
129
154
  """Interface for task queues to be processed by the executor."""
130
155
 
131
- task_class = Task # can be removed in the future when Django 6.0 support is dropped
156
+ task_class = RetryTask
132
157
  supports_async_task = True
133
158
  supports_get_result = True
134
159
  broker_class: type[Broker] | None = None
@@ -144,18 +169,8 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
144
169
  def _object_hook(d: dict) -> dict | TaskResult:
145
170
  if "task" in d and isinstance(d["task"], dict) and "func" in d["task"]:
146
171
  task_data = d["task"]
147
- func = import_string(task_data["func"])
148
- if isinstance(func, cls.task_class):
149
- func = func.func
150
- d["task"] = cls.task_class(
151
- func=func,
152
- **{
153
- field.name: _parse_datetime(task_data[field.name])
154
- for field in dataclasses.fields(cls.task_class)
155
- if field.name not in {"func", "takes_context"}
156
- and field.name in task_data
157
- },
158
- )
172
+ task_data["run_after"] = _parse_datetime(task_data["run_after"])
173
+ d["task"] = cls.task_class._reconstruct(task_data)
159
174
  d["status"] = TaskResultStatus(d["status"])
160
175
  d["errors"] = [TaskError(**error) for error in d["errors"]]
161
176
  return_value = d.pop("_return_value", None)
@@ -168,6 +183,15 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
168
183
 
169
184
  return json.loads(data, object_hook=_object_hook)
170
185
 
186
+ def validate_task(self, task: RetryTask) -> None:
187
+ super().validate_task(task)
188
+ if task.retry is not None and not (
189
+ is_module_level_function(task.retry) or hasattr(task.retry, "deconstruct")
190
+ ):
191
+ raise InvalidTask(
192
+ "Task's retry function must be defined at a module level or be a deconstructible callable."
193
+ )
194
+
171
195
  def acquire(
172
196
  self,
173
197
  *queue_names: str,
@@ -192,15 +216,24 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
192
216
  """Remove the task from the queue and publish the result."""
193
217
  raise NotImplementedError
194
218
 
219
+ def requeue(self, task_result: TaskResult, run_after: datetime.datetime) -> None:
220
+ """Re-queue a failed task result for a retry attempt after `run_after`.
221
+
222
+ Cleans up any persisted failed result so the method works both for
223
+ in-flight retries (task still running) and inspector-driven requeues
224
+ of already-failed tasks.
225
+ """
226
+ raise NotImplementedError
227
+
195
228
  def peek(
196
229
  self,
197
230
  queue_name: str = DEFAULT_TASK_QUEUE_NAME,
198
231
  *,
199
232
  status: TaskResultStatus,
200
233
  count: int = 1,
201
- ) -> collections.abc.Generator[TaskResult, None, None]:
234
+ ) -> collections.abc.Generator[TaskResult]:
202
235
  """
203
- Yield up to ``count`` tasks from a queue in the given status segment.
236
+ Yield up to `count` tasks from a queue in the given status segment.
204
237
 
205
238
  Args:
206
239
  queue_name: The name of the queue to peek into.
@@ -218,3 +251,15 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
218
251
  interval: The time window for rolling rates.
219
252
  """
220
253
  raise NotImplementedError
254
+
255
+ def dequeue(self, task_result: TaskResult) -> None:
256
+ """Delete a single task from its current status segment."""
257
+ raise NotImplementedError
258
+
259
+ def purge(self, queue_name: str) -> None:
260
+ """Delete every task across all segments of a queue.
261
+
262
+ Args:
263
+ queue_name: The queue to purge.
264
+ """
265
+ raise NotImplementedError
@@ -7,7 +7,7 @@
7
7
  -- KEYS[4] = queue, etc.
8
8
  -- ARGV[1] -- current time in milliseconds
9
9
  -- ARGV[2] -- current time as ISO-8601 string
10
- -- ARGV[3] -- task key prefix (e.g. "threadmill:default:task:")
10
+ -- ARGV[3] -- task key prefix (e.g. "threadmill:task:")
11
11
  -- ARGV[4] -- number of queue pairs (N/2)
12
12
  -- ARGV[5] -- worker name
13
13
  -- ARGV[6] -- lease TTL in milliseconds
@@ -1,7 +1,6 @@
1
1
  """Redis-backed durable priority queue backend for Django's task framework."""
2
2
 
3
- from __future__ import annotations
4
-
3
+ import dataclasses
5
4
  import datetime
6
5
  import logging
7
6
  import queue
@@ -56,14 +55,12 @@ class RedisBroker(Broker):
56
55
  deferred_key = self.backend.DEFERRED_KEY.format(
57
56
  prefix=self.backend.key_prefix, queue_name=queue_name
58
57
  )
59
- queue_key = self.backend.QUEUE_KEY.format(
60
- prefix=self.backend.key_prefix, queue_name=queue_name
61
- )
58
+ queue_key = self.backend._segment_key(TaskResultStatus.READY, queue_name)
62
59
  self._mover_script(
63
60
  keys=[deferred_key, queue_key],
64
61
  args=[
65
62
  str(time.time() * 1000),
66
- self.backend.key_prefix + ":task:",
63
+ f"{self.backend.key_prefix}:task:",
67
64
  str(self.backend.batch_size),
68
65
  ],
69
66
  )
@@ -73,18 +70,16 @@ class RedisBroker(Broker):
73
70
  now = timezone.now()
74
71
  now_ms = now.timestamp() * 1000
75
72
  finished_at_iso = now.isoformat()
76
- running_key = self.backend.RUNNING_KEY.format(
77
- prefix=self.backend.key_prefix, queue_name=queue_name
78
- )
79
- failed_results_key = self.backend.FAILED_RESULTS_KEY.format(
80
- prefix=self.backend.key_prefix, queue_name=queue_name
73
+ running_key = self.backend._segment_key(TaskResultStatus.RUNNING, queue_name)
74
+ failed_results_key = self.backend._segment_key(
75
+ TaskResultStatus.FAILED, queue_name
81
76
  )
82
77
  self._reaper_script(
83
78
  keys=[running_key, failed_results_key],
84
79
  args=[
85
80
  str(now_ms),
86
- self.backend.key_prefix + ":task:",
87
- self.backend.key_prefix + ":result:",
81
+ f"{self.backend.key_prefix}:task:",
82
+ f"{self.backend.key_prefix}:result:",
88
83
  str(self.backend.batch_size),
89
84
  str(int(self.backend.result_ttl.total_seconds())),
90
85
  finished_at_iso,
@@ -126,20 +121,25 @@ class RedisTaskBackend(ThreadmillTaskBackend):
126
121
 
127
122
  broker_class = RedisBroker
128
123
 
129
- QUEUE_KEY = "{prefix}:queue:{queue_name}"
130
- RUNNING_KEY = "{prefix}:running:{queue_name}"
131
- DEFERRED_KEY = "{prefix}:deferred:{queue_name}"
132
124
  TASK_KEY = "{prefix}:task:{task_id}"
133
125
  RESULT_KEY = "{prefix}:result:{result_id}"
134
- SUCCESSFUL_RESULTS_KEY = "{prefix}:results:{queue_name}:successful"
135
- FAILED_RESULTS_KEY = "{prefix}:results:{queue_name}:failed"
136
- INGRESS_KEY = "{prefix}:ingress:{queue_name}:events"
126
+ SEGMENT_KEY = "{prefix}:{queue_name}:{status}"
127
+ DEFERRED_KEY = "{prefix}:{queue_name}:deferred"
128
+
129
+ INGRESS_KEY = "{prefix}:{queue_name}:ingress:events"
137
130
 
138
131
  ACQUIRE_SCRIPT = _load_lua("acquire")
139
132
  """Pop the next task from a priority queue and move it directly to the running set."""
140
133
  ACKNOWLEDGE_SCRIPT = _load_lua("acknowledge")
141
134
  """Remove from running, persist the result, and clean up."""
142
135
 
136
+ def _segment_key(self, status: TaskResultStatus, queue_name: str) -> str:
137
+ return self.SEGMENT_KEY.format(
138
+ prefix=self.key_prefix,
139
+ queue_name=queue_name,
140
+ status=status.value.lower(),
141
+ )
142
+
143
143
  def __init__(self, alias: str, params: dict) -> None:
144
144
  super().__init__(alias=alias, params=params)
145
145
 
@@ -225,9 +225,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
225
225
  run_after_ms = task.run_after.timestamp() * 1000
226
226
  pipe.zadd(deferred_key, {task_result.id: run_after_ms})
227
227
  else:
228
- queue_key = self.QUEUE_KEY.format(
229
- prefix=self.key_prefix, queue_name=task.queue_name
230
- )
228
+ queue_key = self._segment_key(TaskResultStatus.READY, task.queue_name)
231
229
  pipe.zadd(queue_key, {task_result.id: score})
232
230
 
233
231
  pipe.execute()
@@ -247,8 +245,8 @@ class RedisTaskBackend(ThreadmillTaskBackend):
247
245
  key
248
246
  for queue_name in queue_names
249
247
  for key in (
250
- self.RUNNING_KEY.format(prefix=self.key_prefix, queue_name=queue_name),
251
- self.QUEUE_KEY.format(prefix=self.key_prefix, queue_name=queue_name),
248
+ self._segment_key(TaskResultStatus.RUNNING, queue_name),
249
+ self._segment_key(TaskResultStatus.READY, queue_name),
252
250
  )
253
251
  ]
254
252
 
@@ -262,7 +260,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
262
260
  args=[
263
261
  str(now_ms),
264
262
  now_iso,
265
- self.key_prefix + ":task:",
263
+ f"{self.key_prefix}:task:",
266
264
  str(len(queue_names)),
267
265
  worker,
268
266
  str(int(self.lease_ttl.total_seconds() * 1000)),
@@ -282,18 +280,18 @@ class RedisTaskBackend(ThreadmillTaskBackend):
282
280
 
283
281
  def acknowledge(self, task_result: TaskResult) -> None:
284
282
  serialized = self.serialize_task_result(task_result)
285
- running_key = self.RUNNING_KEY.format(
286
- prefix=self.key_prefix, queue_name=task_result.task.queue_name
283
+ running_key = self._segment_key(
284
+ TaskResultStatus.RUNNING, task_result.task.queue_name
287
285
  )
288
286
  result_key = self.RESULT_KEY.format(
289
287
  prefix=self.key_prefix, result_id=task_result.id
290
288
  )
291
289
  task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
292
- successful_results_key = self.SUCCESSFUL_RESULTS_KEY.format(
293
- prefix=self.key_prefix, queue_name=task_result.task.queue_name
290
+ successful_results_key = self._segment_key(
291
+ TaskResultStatus.SUCCESSFUL, task_result.task.queue_name
294
292
  )
295
- failed_results_key = self.FAILED_RESULTS_KEY.format(
296
- prefix=self.key_prefix, queue_name=task_result.task.queue_name
293
+ failed_results_key = self._segment_key(
294
+ TaskResultStatus.FAILED, task_result.task.queue_name
297
295
  )
298
296
  finished_at = task_result.finished_at or timezone.now()
299
297
  finish_score = finished_at.timestamp() * 1000
@@ -315,6 +313,55 @@ class RedisTaskBackend(ThreadmillTaskBackend):
315
313
  ],
316
314
  )
317
315
 
316
+ def requeue(self, task_result: TaskResult, run_after: datetime.datetime) -> None:
317
+ task_result = dataclasses.replace(
318
+ task_result,
319
+ status=TaskResultStatus.READY,
320
+ started_at=None,
321
+ finished_at=None,
322
+ )
323
+ serialized = self.serialize_task_result(task_result)
324
+ running_key = self._segment_key(
325
+ TaskResultStatus.RUNNING, task_result.task.queue_name
326
+ )
327
+ deferred_key = self.DEFERRED_KEY.format(
328
+ prefix=self.key_prefix, queue_name=task_result.task.queue_name
329
+ )
330
+ failed_key = self._segment_key(
331
+ TaskResultStatus.FAILED, task_result.task.queue_name
332
+ )
333
+ task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
334
+ result_key = self.RESULT_KEY.format(
335
+ prefix=self.key_prefix, result_id=task_result.id
336
+ )
337
+ score = self._compute_score(task_result.task.priority, task_result.enqueued_at)
338
+ run_after_ms = run_after.timestamp() * 1000
339
+ task_data_ttl = int(
340
+ self.lease_ttl.total_seconds() * 3 + self.result_ttl.total_seconds()
341
+ )
342
+
343
+ pipe = self.client.pipeline()
344
+ pipe.zrem(running_key, task_result.id)
345
+ pipe.zrem(failed_key, task_result.id)
346
+ pipe.delete(result_key)
347
+ pipe.hset(task_key, mapping={"data": serialized, "score": str(score)})
348
+ pipe.expire(task_key, task_data_ttl)
349
+ pipe.zadd(deferred_key, {task_result.id: run_after_ms})
350
+ pipe.execute()
351
+
352
+ def dequeue(self, task_result: TaskResult) -> None:
353
+ self.client.zrem(
354
+ self._segment_key(task_result.status, task_result.task.queue_name),
355
+ task_result.id,
356
+ )
357
+
358
+ def purge(self, queue_name: str) -> None:
359
+ pattern = f"{self.key_prefix}:{queue_name}:*"
360
+ pipe = self.client.pipeline()
361
+ for key in self.client.scan_iter(match=pattern):
362
+ pipe.delete(key)
363
+ pipe.execute()
364
+
318
365
  def peek(
319
366
  self,
320
367
  queue_name: str = DEFAULT_TASK_QUEUE_NAME,
@@ -323,34 +370,27 @@ class RedisTaskBackend(ThreadmillTaskBackend):
323
370
  count: int = 1,
324
371
  ) -> Generator[TaskResult]:
325
372
  match status:
326
- case TaskResultStatus.READY:
373
+ case TaskResultStatus.READY | TaskResultStatus.RUNNING:
327
374
  yield from self._peek(
328
- self.QUEUE_KEY, self.TASK_KEY, queue_name, count, "data"
375
+ self._segment_key(status, queue_name),
376
+ self.TASK_KEY,
377
+ count,
378
+ "data",
329
379
  )
330
- case TaskResultStatus.RUNNING:
380
+ case TaskResultStatus.SUCCESSFUL | TaskResultStatus.FAILED:
331
381
  yield from self._peek(
332
- self.RUNNING_KEY, self.TASK_KEY, queue_name, count, "data"
333
- )
334
- case TaskResultStatus.SUCCESSFUL:
335
- yield from self._peek(
336
- self.SUCCESSFUL_RESULTS_KEY, self.RESULT_KEY, queue_name, count
337
- )
338
- case TaskResultStatus.FAILED:
339
- yield from self._peek(
340
- self.FAILED_RESULTS_KEY, self.RESULT_KEY, queue_name, count
382
+ self._segment_key(status, queue_name),
383
+ self.RESULT_KEY,
384
+ count,
341
385
  )
342
386
 
343
387
  def _peek(
344
388
  self,
345
- zset_key_template: str,
389
+ zset_key: str,
346
390
  data_key_template: str,
347
- queue_name: str,
348
391
  count: int,
349
392
  field: str | None = None,
350
393
  ) -> Generator[TaskResult]:
351
- zset_key = zset_key_template.format(
352
- prefix=self.key_prefix, queue_name=queue_name
353
- )
354
394
  pipe = self.client.pipeline()
355
395
  for member in self.client.zrange(zset_key, 0, count - 1):
356
396
  member_id = member.decode() if isinstance(member, bytes) else member
@@ -385,16 +425,12 @@ class RedisTaskBackend(ThreadmillTaskBackend):
385
425
  cutoff,
386
426
  )
387
427
  pipe.zremrangebyscore(
388
- self.SUCCESSFUL_RESULTS_KEY.format(
389
- prefix=self.key_prefix, queue_name=queue_name
390
- ),
428
+ self._segment_key(TaskResultStatus.SUCCESSFUL, queue_name),
391
429
  0,
392
430
  cutoff,
393
431
  )
394
432
  pipe.zremrangebyscore(
395
- self.FAILED_RESULTS_KEY.format(
396
- prefix=self.key_prefix, queue_name=queue_name
397
- ),
433
+ self._segment_key(TaskResultStatus.FAILED, queue_name),
398
434
  0,
399
435
  cutoff,
400
436
  )
@@ -409,21 +445,15 @@ class RedisTaskBackend(ThreadmillTaskBackend):
409
445
  retention_start_ms = now_ms - self.result_ttl.total_seconds() * 1000
410
446
  pipe = self.client.pipeline()
411
447
  for queue_name in self.queues:
412
- pipe.zcard(
413
- self.QUEUE_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
414
- )
415
- pipe.zcard(
416
- self.RUNNING_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
417
- )
448
+ pipe.zcard(self._segment_key(TaskResultStatus.READY, queue_name))
449
+ pipe.zcard(self._segment_key(TaskResultStatus.RUNNING, queue_name))
418
450
  pipe.zcard(
419
451
  self.DEFERRED_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
420
452
  )
421
- successful_results_key = self.SUCCESSFUL_RESULTS_KEY.format(
422
- prefix=self.key_prefix, queue_name=queue_name
423
- )
424
- failed_results_key = self.FAILED_RESULTS_KEY.format(
425
- prefix=self.key_prefix, queue_name=queue_name
453
+ successful_results_key = self._segment_key(
454
+ TaskResultStatus.SUCCESSFUL, queue_name
426
455
  )
456
+ failed_results_key = self._segment_key(TaskResultStatus.FAILED, queue_name)
427
457
  pipe.zcard(successful_results_key)
428
458
  pipe.zcard(failed_results_key)
429
459
  ingress_key = self.INGRESS_KEY.format(
@@ -1,7 +1,5 @@
1
1
  """Custom exceptions for the threadmill task framework."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
 
6
4
  class AcknowledgementTimeout(Exception):
7
5
  """Raised when a task's lease has expired before it could be acknowledged."""
@@ -1,7 +1,5 @@
1
1
  """Task worker executor implementation."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  import asyncio
6
4
  import dataclasses
7
5
  import datetime
@@ -205,17 +203,32 @@ class WorkerThread(threading.Thread):
205
203
  timeout=datetime.timedelta(seconds=1),
206
204
  worker=self.name,
207
205
  )
208
- except (Empty, TimeoutError):
206
+ except Empty, TimeoutError:
209
207
  if self.worker.shutdown_requested.is_set() or self.worker.exit_empty:
210
208
  return
211
209
  continue
212
210
 
213
211
  try:
214
212
  result = self.execute_task_result(task_result)
215
- self.backend.acknowledge(result)
213
+ if (
214
+ result.status is TaskResultStatus.FAILED
215
+ and (delay := self.retry_delay(result)) is not None
216
+ ):
217
+ self.backend.requeue(result, timezone.now() + delay)
218
+ else:
219
+ self.backend.acknowledge(result)
216
220
  finally:
217
221
  self.worker.record_task()
218
222
 
223
+ @staticmethod
224
+ def retry_delay(task_result: TaskResult) -> datetime.timedelta | None:
225
+ """Return the retry delay for a failed task, or None to stop retrying."""
226
+ if task_result.task.retry:
227
+ try:
228
+ return task_result.task.retry(TaskContext(task_result=task_result))
229
+ except Exception:
230
+ logger.exception("Retry callback failed for task %r", task_result.id)
231
+
219
232
  def execute_task_result(self, task_result: TaskResult) -> TaskResult:
220
233
  """Execute task from task result and update result lifecycle state."""
221
234
  logger.info("Executing task %r", task_result.id)
@@ -1,7 +1,5 @@
1
1
  """Textual app for the inspector TUI."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  import datetime
6
4
  import logging
7
5
  import math
@@ -32,6 +30,7 @@ from textual.widgets import (
32
30
  )
33
31
 
34
32
  from ..backends.base import BackendTelemetry, ThreadmillTaskBackend
33
+ from .screens import ConfirmScreen, PurgeScreen
35
34
 
36
35
  logger = logging.getLogger(__name__)
37
36
 
@@ -165,6 +164,21 @@ class TaskList(Vertical):
165
164
  counts: reactive[dict[str, int]] = reactive({})
166
165
  selected_task: reactive[TaskResult | None] = reactive(None)
167
166
 
167
+ BINDINGS = [
168
+ Binding("f5", "refresh", "Refresh"),
169
+ Binding("r", "requeue", "Requeue"),
170
+ Binding("d", "dequeue", "Drop"),
171
+ ]
172
+
173
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
174
+ """Show requeue only on the Failed tab, dequeue on non-Running tabs."""
175
+ status = self.active_status()
176
+ if action == "requeue":
177
+ return status == TaskResultStatus.FAILED
178
+ if action == "dequeue":
179
+ return status is not None and status != TaskResultStatus.RUNNING
180
+ return True
181
+
168
182
  def compose(self) -> ComposeResult:
169
183
  with TabbedContent(initial="tab-ready") as tabs:
170
184
  for label, _ in TAB_STATUSES:
@@ -214,6 +228,11 @@ class TaskList(Vertical):
214
228
  """Activate the tab with the given id."""
215
229
  self._tabs.active = tab_id
216
230
 
231
+ def action_refresh(self) -> None:
232
+ """Refresh telemetry and re-fetch the task list."""
233
+ self.app._refresh_telemetry()
234
+ self.refresh_tasks()
235
+
217
236
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
218
237
  """Notify the app when a task row is selected."""
219
238
  if isinstance(event.row_key.value, str):
@@ -229,6 +248,7 @@ class TaskList(Vertical):
229
248
  ) -> None:
230
249
  """Refresh the visible table when the user switches tabs."""
231
250
  self._refresh_data()
251
+ self.refresh_bindings()
232
252
 
233
253
  def _select_task_by_id(self, task_id: str) -> None:
234
254
  """Find the task result matching the row key in the current results."""
@@ -237,6 +257,57 @@ class TaskList(Vertical):
237
257
  None,
238
258
  )
239
259
 
260
+ def active_status(self) -> TaskResultStatus | None:
261
+ """Return the status segment of the currently active task tab."""
262
+ tab_id = self._tabs.active.removeprefix("tab-")
263
+ return next(
264
+ (status for label, status in TAB_STATUSES if label.lower() == tab_id),
265
+ None,
266
+ )
267
+
268
+ def action_requeue(self) -> None:
269
+ """Requeue the selected failed task after confirmation."""
270
+ task = self.selected_task
271
+ if task and self.active_status() == TaskResultStatus.FAILED:
272
+ self.app.push_screen(
273
+ ConfirmScreen(f"Requeue task {task.id[:8]}?"),
274
+ lambda confirmed: self._do_requeue(confirmed, task),
275
+ )
276
+
277
+ def _do_requeue(self, confirmed: bool, task: TaskResult) -> None:
278
+ if not confirmed:
279
+ return
280
+ try:
281
+ self.backend.requeue(task, datetime.datetime.now(datetime.UTC))
282
+ except Exception: # noqa: BLE001
283
+ logger.exception("Requeue failed for task %s", task.id)
284
+ self.app.notify("Requeue failed.", severity="error")
285
+ return
286
+ self.app._after_action("Task requeued.")
287
+
288
+ def action_dequeue(self) -> None:
289
+ """Remove the selected task from its current segment after confirmation."""
290
+ task = self.selected_task
291
+ if task and self.active_status() is not None:
292
+ self.app.push_screen(
293
+ ConfirmScreen(
294
+ f"Delete task {task.task.func.__name__} ({task.id[:8]})?",
295
+ danger=True,
296
+ ),
297
+ lambda confirmed: self._do_dequeue(confirmed, task),
298
+ )
299
+
300
+ def _do_dequeue(self, confirmed: bool, task: TaskResult) -> None:
301
+ if not confirmed:
302
+ return
303
+ try:
304
+ self.backend.dequeue(task)
305
+ except Exception: # noqa: BLE001
306
+ logger.exception("Delete failed for task %s", task.id)
307
+ self.app.notify("Delete failed.", severity="error")
308
+ return
309
+ self.app._after_action("Task deleted.")
310
+
240
311
  def _refresh_data(self) -> None:
241
312
  """Fetch and display tasks for the current queue and active tab."""
242
313
  backend = self.backend
@@ -293,6 +364,10 @@ class QueueList(ListView):
293
364
 
294
365
  telemetry: reactive[BackendTelemetry | None] = reactive(None)
295
366
 
367
+ BINDINGS = [
368
+ Binding("p", "purge", "Purge"),
369
+ ]
370
+
296
371
  def __init__(self, **kwargs: Any) -> None:
297
372
  super().__init__(**kwargs)
298
373
  self._items: dict[str, QueueItem] = {}
@@ -302,12 +377,10 @@ class QueueList(ListView):
302
377
  self.border_title = "Queues"
303
378
 
304
379
  def watch_telemetry(self, telemetry: BackendTelemetry | None) -> None:
305
- """Refresh queue labels when a new telemetry snapshot arrives."""
306
380
  if telemetry is not None:
307
381
  self.update_telemetry(telemetry)
308
382
 
309
383
  def update_telemetry(self, telemetry: BackendTelemetry) -> None:
310
- """Refresh queue labels from a new telemetry snapshot."""
311
384
  queues = telemetry.queues
312
385
  for queue_name, stats in sorted(queues.items()):
313
386
  theme = BUILTIN_THEMES[self.app.theme]
@@ -336,21 +409,40 @@ class QueueList(ListView):
336
409
  self._notify_selection(target)
337
410
 
338
411
  def _notify_selection(self, queue_name: str) -> None:
339
- """Tell the app which queue to display."""
340
412
  self.app._task_list.queue_name = queue_name
341
413
 
414
+ def action_purge(self) -> None:
415
+ item = self.highlighted_child
416
+ if not isinstance(item, QueueItem):
417
+ return
418
+ queue_name = item.queue_name
419
+ self.app.push_screen(
420
+ PurgeScreen(queue_name),
421
+ lambda confirmed: self._do_purge(confirmed, queue_name),
422
+ )
423
+
424
+ def _do_purge(self, confirmed: bool, queue_name: str) -> None:
425
+ if not confirmed:
426
+ return
427
+ try:
428
+ self.app.backend.purge(queue_name)
429
+ except Exception: # noqa: BLE001
430
+ logger.exception("Purge failed for %r", queue_name)
431
+ self.app.notify("Purge failed.", severity="error")
432
+ return
433
+ self.app._after_action(f"Purged queue {queue_name}.")
434
+
342
435
 
343
436
  class InspectorApp(App):
344
437
  """Threadmill TUI inspector with backend/queue/task panes."""
345
438
 
346
439
  CSS_PATH = "inspector.scss"
347
440
  BINDINGS = [
348
- Binding("q", "quit", "Quit"),
349
- Binding("f5", "refresh", "Refresh"),
350
441
  *(
351
442
  Binding(key, f"switch_tab('tab-{tab_id}')", tab_id.capitalize())
352
443
  for tab_id, key in TAB_KEYS.items()
353
444
  ),
445
+ Binding("q", "quit", "Quit"),
354
446
  ]
355
447
 
356
448
  backend: reactive[ThreadmillTaskBackend] = reactive(None)
@@ -417,8 +509,9 @@ class InspectorApp(App):
417
509
  """Exit the TUI."""
418
510
  self.exit()
419
511
 
420
- def action_refresh(self) -> None:
421
- """Refresh queue stats and re-fetch the task list on demand."""
512
+ def _after_action(self, message: str) -> None:
513
+ """Refresh telemetry and tasks after a mutating queue action."""
514
+ self.notify(message)
422
515
  self._refresh_telemetry()
423
516
  self._task_list.refresh_tasks()
424
517
 
@@ -2,6 +2,11 @@ Screen {
2
2
  background: $background;
3
3
  }
4
4
 
5
+ ConfirmScreen,
6
+ PurgeScreen {
7
+ align: center middle;
8
+ }
9
+
5
10
  * {
6
11
  scrollbar-color: $primary 10%;
7
12
  scrollbar-color-hover: $primary 80%;
@@ -104,3 +109,29 @@ TaskDetail {
104
109
  overflow: scroll;
105
110
  padding: 0 1;
106
111
  }
112
+
113
+ #dialog {
114
+ width: 60;
115
+ height: auto;
116
+ background: $surface;
117
+ padding: 1 2;
118
+ }
119
+
120
+ #dialog.warning {
121
+ border: thick $warning;
122
+ }
123
+
124
+ #dialog.danger {
125
+ border: thick $error;
126
+ }
127
+
128
+ #prompt {
129
+ text-align: center;
130
+ margin-bottom: 1;
131
+ }
132
+
133
+ #instruction {
134
+ text-align: center;
135
+ color: $text-muted;
136
+ margin-top: 1;
137
+ }
@@ -0,0 +1,52 @@
1
+ """Modal confirmation screens for inspector queue actions."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Vertical
5
+ from textual.events import Key
6
+ from textual.screen import ModalScreen
7
+ from textual.widgets import Input, Label
8
+
9
+
10
+ class ConfirmScreen(ModalScreen[bool]):
11
+ """Yes/no confirmation: Enter confirms, Esc cancels."""
12
+
13
+ def __init__(self, prompt: str, *, danger: bool = False) -> None:
14
+ super().__init__()
15
+ self._prompt = prompt
16
+ self._danger = danger
17
+
18
+ def compose(self) -> ComposeResult:
19
+ with Vertical(id="dialog", classes="danger" if self._danger else "warning"):
20
+ yield Label(self._prompt, id="prompt")
21
+ yield Label("Press Enter to confirm, Esc to cancel.", id="instruction")
22
+
23
+ def on_key(self, event: Key) -> None:
24
+ match event.key:
25
+ case "enter":
26
+ self.dismiss(True)
27
+ case "escape":
28
+ self.dismiss(False)
29
+
30
+
31
+ class PurgeScreen(ModalScreen[bool]):
32
+ """Type the queue name to confirm a purge."""
33
+
34
+ def __init__(self, queue_name: str) -> None:
35
+ super().__init__()
36
+ self._queue_name = queue_name
37
+
38
+ def compose(self) -> ComposeResult:
39
+ with Vertical(id="dialog", classes="danger"):
40
+ yield Label(f"Purge queue [bold]{self._queue_name}[/bold]?", id="prompt")
41
+ yield Label(
42
+ "Type the queue name to confirm, Esc to cancel.", id="instruction"
43
+ )
44
+ yield Input(placeholder=self._queue_name, id="confirm-input")
45
+
46
+ def on_input_submitted(self, event: Input.Submitted) -> None:
47
+ if event.value == self._queue_name:
48
+ self.dismiss(True)
49
+
50
+ def on_key(self, event: Key) -> None:
51
+ if event.key == "escape":
52
+ self.dismiss(False)
@@ -0,0 +1,71 @@
1
+ """Reusable callables for retrying tasks."""
2
+
3
+ import dataclasses
4
+ import datetime
5
+ import typing
6
+
7
+ if typing.TYPE_CHECKING:
8
+ from django.tasks import TaskContext
9
+
10
+
11
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
12
+ class ExponentialBackoff:
13
+ """Delay retries exponentially, capped at ``max_delay`` and limited to ``max_retries`` attempts.
14
+
15
+ Only retry exceptions listed in ``expected_exceptions``.
16
+
17
+ Usage:
18
+
19
+ @task(retry=ExponentialBackoff(base_delay=datetime.timedelta(seconds=1), max_delay=datetime.timedelta(minutes=5), factor=2.0, max_retries=5))
20
+ def my_task():
21
+ ...
22
+ """
23
+
24
+ base_delay: datetime.timedelta = dataclasses.field(
25
+ default=datetime.timedelta(seconds=1), doc="Base delay in seconds"
26
+ )
27
+ max_delay: datetime.timedelta = dataclasses.field(
28
+ default=datetime.timedelta(minutes=60), doc="Maximum delay in seconds"
29
+ )
30
+ factor: float = dataclasses.field(default=2.0, doc="Exponential factor for backoff")
31
+ max_retries: int = dataclasses.field(default=5, doc="Maximum number of retries")
32
+ expected_exceptions: tuple[type[Exception], ...] = dataclasses.field(
33
+ default=(Exception,), doc="Tuple of exception classes that trigger a retry"
34
+ )
35
+
36
+ def __post_init__(self) -> None:
37
+ """Resolve deserialized fields back to their original types."""
38
+ for field_name in ("base_delay", "max_delay"):
39
+ value = getattr(self, field_name)
40
+ if isinstance(value, (int, float)):
41
+ object.__setattr__(self, field_name, datetime.timedelta(seconds=value))
42
+ resolved = []
43
+ for exc in self.expected_exceptions:
44
+ if isinstance(exc, str):
45
+ from django.utils.module_loading import import_string
46
+
47
+ exc = import_string(exc)
48
+ resolved.append(exc)
49
+ object.__setattr__(self, "expected_exceptions", tuple(resolved))
50
+
51
+ def __call__(self, context: TaskContext) -> datetime.timedelta | None:
52
+ if context.attempt < self.max_retries and issubclass(
53
+ context.task_result.errors[-1].exception_class, self.expected_exceptions
54
+ ):
55
+ return min(self.base_delay * (self.factor**context.attempt), self.max_delay)
56
+
57
+ def deconstruct(self):
58
+ return (
59
+ f"{self.__class__.__module__}.{self.__class__.__qualname__}",
60
+ (),
61
+ {
62
+ "base_delay": self.base_delay.total_seconds(),
63
+ "max_delay": self.max_delay.total_seconds(),
64
+ "factor": self.factor,
65
+ "max_retries": self.max_retries,
66
+ "expected_exceptions": tuple(
67
+ f"{exc.__module__}.{exc.__qualname__}"
68
+ for exc in self.expected_exceptions
69
+ ),
70
+ },
71
+ )
File without changes