django-simpletask5 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.
Files changed (56) hide show
  1. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/PKG-INFO +44 -5
  2. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/README.md +43 -4
  3. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/core/defaults.py +1 -0
  4. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/core/publisher.py +12 -1
  5. django_simpletask5-0.2.1/django_simpletask5/executors/base.py +35 -0
  6. django_simpletask5-0.2.1/django_simpletask5/executors/exceptions.py +13 -0
  7. django_simpletask5-0.2.1/django_simpletask5/executors/retry_timeout.py +66 -0
  8. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/management/commands/django_simpletask_crontab.py +5 -1
  9. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/management/commands/django_simpletask_executor.py +5 -0
  10. django_simpletask5-0.2.1/django_simpletask5/migrations/0008_taskexecution_pickup_timeout_seconds.py +20 -0
  11. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/models.py +3 -1
  12. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5.egg-info/PKG-INFO +44 -5
  13. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5.egg-info/SOURCES.txt +2 -0
  14. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/pyproject.toml +1 -1
  15. django_simpletask5-0.2.0/django_simpletask5/executors/base.py +0 -20
  16. django_simpletask5-0.2.0/django_simpletask5/executors/retry_timeout.py +0 -47
  17. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/LICENSE +0 -0
  18. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/__init__.py +0 -0
  19. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/admin.py +0 -0
  20. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/apps.py +0 -0
  21. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/core/__init__.py +0 -0
  22. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/core/cronjob_registry.py +0 -0
  23. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/core/executor_scanner.py +0 -0
  24. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/core/lock.py +0 -0
  25. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/core/message_queue.py +0 -0
  26. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/core/signals.py +0 -0
  27. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/core/worker_registry.py +0 -0
  28. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/dashboards.py +0 -0
  29. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/executors/__init__.py +0 -0
  30. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/executors/archive.py +0 -0
  31. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/executors/bash_script.py +0 -0
  32. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/executors/loader.py +0 -0
  33. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/executors/ping_pong.py +0 -0
  34. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/executors/python_script.py +0 -0
  35. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/executors/simple_request.py +0 -0
  36. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/executors/status_check.py +0 -0
  37. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  38. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.po +0 -0
  39. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/management/__init__.py +0 -0
  40. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/management/commands/__init__.py +0 -0
  41. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/management/commands/django_simpletask_archive.py +0 -0
  42. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/management/commands/django_simpletask_sync_cronjobs.py +0 -0
  43. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/migrations/0001_initial.py +0 -0
  44. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/migrations/0002_taskexecution_done_tasks_count_and_more.py +0 -0
  45. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/migrations/0003_alter_cronjob_options_remove_cronjob_name_and_more.py +0 -0
  46. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/migrations/0004_alter_cronjob_is_modified_by_user.py +0 -0
  47. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/migrations/0005_alter_cronjob_is_modified_by_user_help.py +0 -0
  48. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/migrations/0006_alter_cronjob_options_alter_taskexecution_created_at_and_more.py +0 -0
  49. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/migrations/0007_rename_task_model_to_executor_class_in_stat.py +0 -0
  50. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/migrations/__init__.py +0 -0
  51. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/services/__init__.py +0 -0
  52. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5/services/archive.py +0 -0
  53. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5.egg-info/dependency_links.txt +0 -0
  54. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5.egg-info/requires.txt +0 -0
  55. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/django_simpletask5.egg-info/top_level.txt +0 -0
  56. {django_simpletask5-0.2.0 → django_simpletask5-0.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-simpletask5
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Lightweight async task execution framework for Django
5
5
  Author-email: rRR0VrFP <rrr0vrfp@qq.com>
6
6
  Maintainer-email: rRR0VrFP <rrr0vrfp@qq.com>
@@ -50,7 +50,8 @@ Dynamic: license-file
50
50
  - **自定义事件** — 通过 `task.trigger('event_name')` 触发任意事件
51
51
  - **灵活的执行器映射** — 不同事件可绑定不同的执行器类
52
52
  - **队列路由** — 不同事件可路由到不同优先级队列
53
- - **重试与超时** — 失败自动重试(指数退避),支持超时检测
53
+ - **重试与超时** — 失败自动重试(指数退避),支持执行超时与 Pickup 超时两种检测
54
+ - **拒绝回退** — 执行器可通过 `RejectException` 拒绝当前任务,消息自动重新入队等待下次调度
54
55
  - **加密字段** — 敏感数据自动加密存储
55
56
  - **Cron 调度** — 内置 crontab 守护进程,支持代码注册与数据库覆盖
56
57
  - **归档统计** — 已完成执行记录自动归档为加密 JSONL,并生成日统计
@@ -156,10 +157,15 @@ DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE = 'django_simpletask5.queue.high_priority'
156
157
  ### 执行与重试
157
158
 
158
159
  ```python
159
- DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = 3 # 默认最大重试次数
160
- DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = 600 # 默认执行超时时间(秒)
160
+ DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = 3 # 默认最大重试次数
161
+ DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = 600 # 默认执行超时(秒),任务开始执行后的超时限制
162
+ DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS = 3600 # 默认 Pickup 超时(秒),等待 Worker 消费的时间限制
161
163
  ```
162
164
 
165
+ 框架区分两种超时:
166
+ - **执行超时**(`timeout_seconds`)— 任务进入 `running` 状态后,必须在指定时间内完成,否则被 `StatusCheckExecutor` 标记超时。适用于保护实际执行不 hang 死。
167
+ - **Pickup 超时**(`pickup_timeout_seconds`)— 任务创建后等待 Worker 消费的时间上限,也用于被拒绝回退后等待重新调度的保护。通常设得比执行超时更长,因为排队等待时间不可控。
168
+
163
169
  ### 归档
164
170
 
165
171
  ```python
@@ -235,6 +241,33 @@ class OrderCreateExecutor(BaseExecutor):
235
241
  return 'ok'
236
242
  ```
237
243
 
244
+ 执行器可以通过 `executor_class` 属性覆盖超时配置:
245
+
246
+ ```python
247
+ class OrderCreateExecutor(BaseExecutor):
248
+ timeout_seconds = 300 # 执行超时 5 分钟
249
+ pickup_timeout_seconds = 1800 # Pickup 超时 30 分钟
250
+ ```
251
+
252
+ ### 拒绝回退
253
+
254
+ 执行器在执行过程中遇到临时条件不满足(如资源上限),可以抛出 `RejectException` 拒绝本次执行:
255
+
256
+ ```python
257
+ from django_simpletask5.executors.exceptions import RejectException
258
+
259
+ class MyExecutor(BaseExecutor):
260
+ def execute(self, execution: TaskExecution) -> str | None:
261
+ if not self._can_proceed():
262
+ raise RejectException('Resource limit reached, try again later')
263
+ ```
264
+
265
+ 框架捕获后会:
266
+ - 调用 `message.reject(requeue=True)` 将消息放回队列尾部,下次重新调度
267
+ - `TaskExecution` 状态恢复为 `pending`,`started_at` 清空,等待下次 Worker 消费
268
+ - **不消耗**重试次数
269
+ - 刷新 `expire_time`(使用 Pickup 超时),防止被 `StatusCheckExecutor` 误回收
270
+
238
271
  ### 3. 启动 Worker
239
272
 
240
273
  默认只监听 `default` 队列,`high_priority` 队列需要单独启动 Worker 处理。
@@ -315,11 +348,17 @@ Task 模型变更 → Django 信号 → 创建 TaskExecution 并发布到消息
315
348
 
316
349
  Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保存结果
317
350
 
318
- 失败时自动重试,完成后归档
351
+ 失败时自动重试/拒绝时重新入队,完成后归档
319
352
  ```
320
353
 
321
354
  ## Releases
322
355
 
356
+ ### 0.2.1
357
+
358
+ - **拒绝回退机制** — 新增 `RejectException`,执行器可抛出该异常拒绝当前任务,消息自动重新入队等待下次调度,不消耗重试次数
359
+ - **区分两种超时** — 引入 `pickup_timeout_seconds`(等待 Worker 消费超时)与 `timeout_seconds`(执行超时)两种超时配置,`expire_time` 改用 Pickup 超时计算,超时检测更精准
360
+ - **文档更新** — README 补充拒绝回退机制的使用说明和两种超时的详细解释
361
+
323
362
  ### 0.2.0
324
363
 
325
364
  - **破坏性变更**: 移除 `Task` 模型上的自定义主键,`task_id` 不再是主键字段,改为 `unique=True` 的唯一标识字段。所有 `Task` 子模型将自动获得 Django 默认的自增 `id` 主键。已有数据库需要迁移处理。
@@ -11,7 +11,8 @@
11
11
  - **自定义事件** — 通过 `task.trigger('event_name')` 触发任意事件
12
12
  - **灵活的执行器映射** — 不同事件可绑定不同的执行器类
13
13
  - **队列路由** — 不同事件可路由到不同优先级队列
14
- - **重试与超时** — 失败自动重试(指数退避),支持超时检测
14
+ - **重试与超时** — 失败自动重试(指数退避),支持执行超时与 Pickup 超时两种检测
15
+ - **拒绝回退** — 执行器可通过 `RejectException` 拒绝当前任务,消息自动重新入队等待下次调度
15
16
  - **加密字段** — 敏感数据自动加密存储
16
17
  - **Cron 调度** — 内置 crontab 守护进程,支持代码注册与数据库覆盖
17
18
  - **归档统计** — 已完成执行记录自动归档为加密 JSONL,并生成日统计
@@ -117,10 +118,15 @@ DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE = 'django_simpletask5.queue.high_priority'
117
118
  ### 执行与重试
118
119
 
119
120
  ```python
120
- DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = 3 # 默认最大重试次数
121
- DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = 600 # 默认执行超时时间(秒)
121
+ DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = 3 # 默认最大重试次数
122
+ DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = 600 # 默认执行超时(秒),任务开始执行后的超时限制
123
+ DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS = 3600 # 默认 Pickup 超时(秒),等待 Worker 消费的时间限制
122
124
  ```
123
125
 
126
+ 框架区分两种超时:
127
+ - **执行超时**(`timeout_seconds`)— 任务进入 `running` 状态后,必须在指定时间内完成,否则被 `StatusCheckExecutor` 标记超时。适用于保护实际执行不 hang 死。
128
+ - **Pickup 超时**(`pickup_timeout_seconds`)— 任务创建后等待 Worker 消费的时间上限,也用于被拒绝回退后等待重新调度的保护。通常设得比执行超时更长,因为排队等待时间不可控。
129
+
124
130
  ### 归档
125
131
 
126
132
  ```python
@@ -196,6 +202,33 @@ class OrderCreateExecutor(BaseExecutor):
196
202
  return 'ok'
197
203
  ```
198
204
 
205
+ 执行器可以通过 `executor_class` 属性覆盖超时配置:
206
+
207
+ ```python
208
+ class OrderCreateExecutor(BaseExecutor):
209
+ timeout_seconds = 300 # 执行超时 5 分钟
210
+ pickup_timeout_seconds = 1800 # Pickup 超时 30 分钟
211
+ ```
212
+
213
+ ### 拒绝回退
214
+
215
+ 执行器在执行过程中遇到临时条件不满足(如资源上限),可以抛出 `RejectException` 拒绝本次执行:
216
+
217
+ ```python
218
+ from django_simpletask5.executors.exceptions import RejectException
219
+
220
+ class MyExecutor(BaseExecutor):
221
+ def execute(self, execution: TaskExecution) -> str | None:
222
+ if not self._can_proceed():
223
+ raise RejectException('Resource limit reached, try again later')
224
+ ```
225
+
226
+ 框架捕获后会:
227
+ - 调用 `message.reject(requeue=True)` 将消息放回队列尾部,下次重新调度
228
+ - `TaskExecution` 状态恢复为 `pending`,`started_at` 清空,等待下次 Worker 消费
229
+ - **不消耗**重试次数
230
+ - 刷新 `expire_time`(使用 Pickup 超时),防止被 `StatusCheckExecutor` 误回收
231
+
199
232
  ### 3. 启动 Worker
200
233
 
201
234
  默认只监听 `default` 队列,`high_priority` 队列需要单独启动 Worker 处理。
@@ -276,11 +309,17 @@ Task 模型变更 → Django 信号 → 创建 TaskExecution 并发布到消息
276
309
 
277
310
  Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保存结果
278
311
 
279
- 失败时自动重试,完成后归档
312
+ 失败时自动重试/拒绝时重新入队,完成后归档
280
313
  ```
281
314
 
282
315
  ## Releases
283
316
 
317
+ ### 0.2.1
318
+
319
+ - **拒绝回退机制** — 新增 `RejectException`,执行器可抛出该异常拒绝当前任务,消息自动重新入队等待下次调度,不消耗重试次数
320
+ - **区分两种超时** — 引入 `pickup_timeout_seconds`(等待 Worker 消费超时)与 `timeout_seconds`(执行超时)两种超时配置,`expire_time` 改用 Pickup 超时计算,超时检测更精准
321
+ - **文档更新** — README 补充拒绝回退机制的使用说明和两种超时的详细解释
322
+
284
323
  ### 0.2.0
285
324
 
286
325
  - **破坏性变更**: 移除 `Task` 模型上的自定义主键,`task_id` 不再是主键字段,改为 `unique=True` 的唯一标识字段。所有 `Task` 子模型将自动获得 Django 默认的自增 `id` 主键。已有数据库需要迁移处理。
@@ -4,6 +4,7 @@ DJANGO_SIMPLETASK_LOCK_TIMEOUT = getattr(settings, 'DJANGO_SIMPLETASK_LOCK_TIMEO
4
4
 
5
5
  DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = getattr(settings, 'DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES', 3)
6
6
  DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = getattr(settings, 'DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS', 600)
7
+ DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS = getattr(settings, 'DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS', 3600)
7
8
 
8
9
  DJANGO_SIMPLETASK_ARCHIVE_PATH = getattr(settings, 'DJANGO_SIMPLETASK_ARCHIVE_PATH', 'django_simpletask5_archives')
9
10
  DJANGO_SIMPLETASK_ARCHIVE_SALT = getattr(settings, 'DJANGO_SIMPLETASK_ARCHIVE_SALT', 'django-simpletask5-archive')
@@ -6,7 +6,7 @@ from django.db import transaction
6
6
  from django.utils import timezone
7
7
 
8
8
  from django_simpletask5.models import TaskExecution
9
- from django_simpletask5.core.defaults import DJANGO_SIMPLETASK_DEFAULT_QUEUE, DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS
9
+ from django_simpletask5.core.defaults import DJANGO_SIMPLETASK_DEFAULT_QUEUE, DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS, DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS
10
10
  from django_simpletask5.executors.loader import load_executor_class
11
11
 
12
12
  logger = logging.getLogger(__name__)
@@ -22,6 +22,16 @@ def _resolve_timeout_seconds(executor_class_path: str) -> int:
22
22
  return DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS
23
23
 
24
24
 
25
+ def _resolve_pickup_timeout_seconds(executor_class_path: str) -> int:
26
+ try:
27
+ cls = load_executor_class(executor_class_path)
28
+ if cls.pickup_timeout_seconds is not None:
29
+ return cls.pickup_timeout_seconds
30
+ except Exception:
31
+ logger.warning('Failed to load executor %s for pickup timeout resolution', executor_class_path)
32
+ return DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS
33
+
34
+
25
35
  def create_and_publish(task, trigger_event, extra_context=None):
26
36
  executor_class_path = task.get_executor_class_path(event=trigger_event)
27
37
  queue_name = task.get_queue_name(event=trigger_event)
@@ -43,6 +53,7 @@ def create_and_publish(task, trigger_event, extra_context=None):
43
53
  executor_class=executor_class_path,
44
54
  status='pending',
45
55
  timeout_seconds=_resolve_timeout_seconds(executor_class_path),
56
+ pickup_timeout_seconds=_resolve_pickup_timeout_seconds(executor_class_path),
46
57
  context=json.dumps(serialized_context, default=str) if serialized_context else None,
47
58
  )
48
59
  execution.save()
@@ -0,0 +1,35 @@
1
+ from datetime import timedelta
2
+
3
+ from django.utils import timezone
4
+
5
+ from django_simpletask5.models import TaskExecution
6
+ from django_simpletask5.core.defaults import DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS
7
+
8
+
9
+ class BaseExecutor:
10
+ timeout_seconds: int | None = None
11
+ pickup_timeout_seconds: int | None = None
12
+ total_tasks_count: int | None = None
13
+ done_tasks_count: int | None = None
14
+
15
+ def get_tasks_count(self, execution: TaskExecution) -> int | None:
16
+ return 1
17
+
18
+ def get_tasks(self, execution: TaskExecution):
19
+ yield None
20
+
21
+ def execute(self, execution: TaskExecution, task=None) -> str | None:
22
+ raise NotImplementedError('Subclasses must implement execute()')
23
+
24
+ def reject(self, execution: TaskExecution, reason: str = '') -> None:
25
+ """Handle task rejection - revert to pending and refresh expire_time."""
26
+ execution.status = 'pending'
27
+ execution.started_at = None
28
+ execution.expire_time = timezone.now() + timedelta(
29
+ seconds=execution.pickup_timeout_seconds or DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS,
30
+ )
31
+ execution.save(update_fields=['status', 'started_at', 'expire_time', 'updated_at'])
32
+
33
+ @classmethod
34
+ def get_parameter_schema(cls):
35
+ return []
@@ -0,0 +1,13 @@
1
+ class RejectException(Exception):
2
+ """Raise to reject and requeue the current task execution.
3
+
4
+ Raise this inside an executor's ``execute()`` method to signal that the
5
+ task should not be consumed now. The framework will:
6
+ - Not ack the message (calls ``message.reject(requeue=True)``)
7
+ - Revert ``TaskExecution`` status to ``pending`` and clear ``started_at``
8
+ - Not consume a retry attempt
9
+ - Refresh ``expire_time`` using pickup timeout
10
+
11
+ This is useful for transient conditions like resource limits or
12
+ temporary unavailability that should be retried later.
13
+ """
@@ -0,0 +1,66 @@
1
+ import json
2
+ import logging
3
+
4
+ from django.db import models, transaction
5
+
6
+ from django_simpletask5.models import TaskExecution
7
+ from django_simpletask5.executors.base import BaseExecutor
8
+
9
+
10
+ def _publish_message_by_id(execution_id):
11
+ from django_simpletask5.core.publisher import _publish_message
12
+ try:
13
+ exec_record = TaskExecution.objects.get(pk=execution_id)
14
+ _publish_message(exec_record)
15
+ except TaskExecution.DoesNotExist:
16
+ logger.warning('Execution %s not found for retry publish', execution_id)
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class RetryTimeoutExecutor(BaseExecutor):
22
+ def execute(self, execution: TaskExecution, task=None) -> str | None:
23
+ from functools import partial
24
+ from django_simpletask5.core.publisher import _publish_message
25
+
26
+ timeout_records = TaskExecution.objects.filter(
27
+ status='timeout',
28
+ retry_count__lt=models.F('max_retries'),
29
+ )
30
+
31
+ # Pickup timeouts — task was never picked up, don't consume retry
32
+ pickup_ids = list(
33
+ timeout_records.filter(started_at__isnull=True)
34
+ .values_list('pk', flat=True)
35
+ )
36
+ if pickup_ids:
37
+ TaskExecution.objects.filter(pk__in=pickup_ids).update(
38
+ status='retry',
39
+ )
40
+ for exec_id in pickup_ids:
41
+ transaction.on_commit(
42
+ partial(_publish_message_by_id, exec_id)
43
+ )
44
+
45
+ # Execution timeouts — task was actually running, consume retry
46
+ exec_ids = list(
47
+ timeout_records.filter(started_at__isnull=False)
48
+ .values_list('pk', flat=True)
49
+ )
50
+ if exec_ids:
51
+ TaskExecution.objects.filter(pk__in=exec_ids).update(
52
+ status='retry',
53
+ retry_count=models.F('retry_count') + 1,
54
+ )
55
+ for exec_id in exec_ids:
56
+ transaction.on_commit(
57
+ partial(_publish_message_by_id, exec_id)
58
+ )
59
+
60
+ total = len(pickup_ids) + len(exec_ids)
61
+ return json.dumps({
62
+ 'retried': total,
63
+ 'pickup_timeout_retried': len(pickup_ids),
64
+ 'exec_timeout_retried': len(exec_ids),
65
+ 'success': True,
66
+ })
@@ -10,7 +10,7 @@ from django.utils import timezone
10
10
 
11
11
  from django_simpletask5.models import CronJob, TaskExecution
12
12
  from django_simpletask5.core.lock import get_lock_manager
13
- from django_simpletask5.core.defaults import DJANGO_SIMPLETASK_LOCK_TIMEOUT, DJANGO_SIMPLETASK_DEFAULT_QUEUE, DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS
13
+ from django_simpletask5.core.defaults import DJANGO_SIMPLETASK_LOCK_TIMEOUT, DJANGO_SIMPLETASK_DEFAULT_QUEUE, DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS, DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS
14
14
  from django_simpletask5.executors.loader import load_executor_class
15
15
 
16
16
  logger = logging.getLogger(__name__)
@@ -142,10 +142,13 @@ class Command(BaseCommand):
142
142
  context.update(cron_params)
143
143
 
144
144
  timeout_seconds = DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS
145
+ pickup_timeout_seconds = DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS
145
146
  try:
146
147
  cls = load_executor_class(cronjob.executor_class)
147
148
  if cls.timeout_seconds is not None:
148
149
  timeout_seconds = cls.timeout_seconds
150
+ if cls.pickup_timeout_seconds is not None:
151
+ pickup_timeout_seconds = cls.pickup_timeout_seconds
149
152
  except Exception:
150
153
  pass
151
154
 
@@ -154,6 +157,7 @@ class Command(BaseCommand):
154
157
  executor_class=cronjob.executor_class,
155
158
  status='pending',
156
159
  timeout_seconds=timeout_seconds,
160
+ pickup_timeout_seconds=pickup_timeout_seconds,
157
161
  context=json.dumps(context, default=str),
158
162
  )
159
163
  execution.save()
@@ -12,6 +12,7 @@ from django.db import close_old_connections, OperationalError, InterfaceError
12
12
  from django.utils import timezone
13
13
 
14
14
  from django_simpletask5.models import TaskExecution
15
+ from django_simpletask5.executors.exceptions import RejectException
15
16
  from django_simpletask5.executors.loader import load_executor
16
17
  from django_simpletask5.core.lock import get_lock_manager
17
18
  from django_simpletask5.core.defaults import DJANGO_SIMPLETASK_LOCK_TIMEOUT, DJANGO_SIMPLETASK_DEFAULT_QUEUE, DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS
@@ -177,6 +178,10 @@ class ExecutorWorker:
177
178
  execution.save(update_fields=['status', 'result', 'done_tasks_count', 'finished_at', 'updated_at'])
178
179
 
179
180
  logger.info('Execution %s completed with status %s', execution_id, execution.status)
181
+ except RejectException as e:
182
+ logger.debug('Execution %s rejected (%s), requeueing', execution_id, e)
183
+ executor.reject(execution, reason=str(e))
184
+ return False
180
185
  except Exception as e:
181
186
  logger.exception('Execution %s failed', execution_id)
182
187
  execution.error_message = str(e)
@@ -0,0 +1,20 @@
1
+ # Generated by Django 5.2.15 on 2026-07-11 14:54
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("django_simpletask5", "0007_rename_task_model_to_executor_class_in_stat"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name="taskexecution",
15
+ name="pickup_timeout_seconds",
16
+ field=models.IntegerField(
17
+ default=3600, verbose_name="Pickup timeout seconds"
18
+ ),
19
+ ),
20
+ ]
@@ -11,6 +11,7 @@ from django_safe_fields.fields import SafeTextField
11
11
  from django_simpletask5.core.defaults import (
12
12
  DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES,
13
13
  DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS,
14
+ DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS,
14
15
  DJANGO_SIMPLETASK_FIELD_CIPHER_CLASS,
15
16
  )
16
17
 
@@ -120,6 +121,7 @@ class TaskExecution(models.Model):
120
121
  retry_count = models.IntegerField(verbose_name=_('Retry count'), default=0)
121
122
  max_retries = models.IntegerField(verbose_name=_('Max retries'), default=DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES)
122
123
  timeout_seconds = models.IntegerField(verbose_name=_('Timeout seconds'), default=DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS)
124
+ pickup_timeout_seconds = models.IntegerField(verbose_name=_('Pickup timeout seconds'), default=DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS)
123
125
  expire_time = models.DateTimeField(verbose_name=_('Expire time'), null=True, blank=True, db_index=True)
124
126
  started_at = models.DateTimeField(verbose_name=_('Started at'), null=True, blank=True)
125
127
  finished_at = models.DateTimeField(verbose_name=_('Finished at'), null=True, blank=True)
@@ -203,7 +205,7 @@ class TaskExecution(models.Model):
203
205
 
204
206
  def save(self, *args, **kwargs):
205
207
  if not self.expire_time:
206
- timeout = self.timeout_seconds or DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS
208
+ timeout = self.pickup_timeout_seconds or DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS
207
209
  self.expire_time = timezone.now() + timedelta(seconds=timeout)
208
210
  super().save(*args, **kwargs)
209
211
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-simpletask5
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Lightweight async task execution framework for Django
5
5
  Author-email: rRR0VrFP <rrr0vrfp@qq.com>
6
6
  Maintainer-email: rRR0VrFP <rrr0vrfp@qq.com>
@@ -50,7 +50,8 @@ Dynamic: license-file
50
50
  - **自定义事件** — 通过 `task.trigger('event_name')` 触发任意事件
51
51
  - **灵活的执行器映射** — 不同事件可绑定不同的执行器类
52
52
  - **队列路由** — 不同事件可路由到不同优先级队列
53
- - **重试与超时** — 失败自动重试(指数退避),支持超时检测
53
+ - **重试与超时** — 失败自动重试(指数退避),支持执行超时与 Pickup 超时两种检测
54
+ - **拒绝回退** — 执行器可通过 `RejectException` 拒绝当前任务,消息自动重新入队等待下次调度
54
55
  - **加密字段** — 敏感数据自动加密存储
55
56
  - **Cron 调度** — 内置 crontab 守护进程,支持代码注册与数据库覆盖
56
57
  - **归档统计** — 已完成执行记录自动归档为加密 JSONL,并生成日统计
@@ -156,10 +157,15 @@ DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE = 'django_simpletask5.queue.high_priority'
156
157
  ### 执行与重试
157
158
 
158
159
  ```python
159
- DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = 3 # 默认最大重试次数
160
- DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = 600 # 默认执行超时时间(秒)
160
+ DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = 3 # 默认最大重试次数
161
+ DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = 600 # 默认执行超时(秒),任务开始执行后的超时限制
162
+ DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS = 3600 # 默认 Pickup 超时(秒),等待 Worker 消费的时间限制
161
163
  ```
162
164
 
165
+ 框架区分两种超时:
166
+ - **执行超时**(`timeout_seconds`)— 任务进入 `running` 状态后,必须在指定时间内完成,否则被 `StatusCheckExecutor` 标记超时。适用于保护实际执行不 hang 死。
167
+ - **Pickup 超时**(`pickup_timeout_seconds`)— 任务创建后等待 Worker 消费的时间上限,也用于被拒绝回退后等待重新调度的保护。通常设得比执行超时更长,因为排队等待时间不可控。
168
+
163
169
  ### 归档
164
170
 
165
171
  ```python
@@ -235,6 +241,33 @@ class OrderCreateExecutor(BaseExecutor):
235
241
  return 'ok'
236
242
  ```
237
243
 
244
+ 执行器可以通过 `executor_class` 属性覆盖超时配置:
245
+
246
+ ```python
247
+ class OrderCreateExecutor(BaseExecutor):
248
+ timeout_seconds = 300 # 执行超时 5 分钟
249
+ pickup_timeout_seconds = 1800 # Pickup 超时 30 分钟
250
+ ```
251
+
252
+ ### 拒绝回退
253
+
254
+ 执行器在执行过程中遇到临时条件不满足(如资源上限),可以抛出 `RejectException` 拒绝本次执行:
255
+
256
+ ```python
257
+ from django_simpletask5.executors.exceptions import RejectException
258
+
259
+ class MyExecutor(BaseExecutor):
260
+ def execute(self, execution: TaskExecution) -> str | None:
261
+ if not self._can_proceed():
262
+ raise RejectException('Resource limit reached, try again later')
263
+ ```
264
+
265
+ 框架捕获后会:
266
+ - 调用 `message.reject(requeue=True)` 将消息放回队列尾部,下次重新调度
267
+ - `TaskExecution` 状态恢复为 `pending`,`started_at` 清空,等待下次 Worker 消费
268
+ - **不消耗**重试次数
269
+ - 刷新 `expire_time`(使用 Pickup 超时),防止被 `StatusCheckExecutor` 误回收
270
+
238
271
  ### 3. 启动 Worker
239
272
 
240
273
  默认只监听 `default` 队列,`high_priority` 队列需要单独启动 Worker 处理。
@@ -315,11 +348,17 @@ Task 模型变更 → Django 信号 → 创建 TaskExecution 并发布到消息
315
348
 
316
349
  Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保存结果
317
350
 
318
- 失败时自动重试,完成后归档
351
+ 失败时自动重试/拒绝时重新入队,完成后归档
319
352
  ```
320
353
 
321
354
  ## Releases
322
355
 
356
+ ### 0.2.1
357
+
358
+ - **拒绝回退机制** — 新增 `RejectException`,执行器可抛出该异常拒绝当前任务,消息自动重新入队等待下次调度,不消耗重试次数
359
+ - **区分两种超时** — 引入 `pickup_timeout_seconds`(等待 Worker 消费超时)与 `timeout_seconds`(执行超时)两种超时配置,`expire_time` 改用 Pickup 超时计算,超时检测更精准
360
+ - **文档更新** — README 补充拒绝回退机制的使用说明和两种超时的详细解释
361
+
323
362
  ### 0.2.0
324
363
 
325
364
  - **破坏性变更**: 移除 `Task` 模型上的自定义主键,`task_id` 不再是主键字段,改为 `unique=True` 的唯一标识字段。所有 `Task` 子模型将自动获得 Django 默认的自增 `id` 主键。已有数据库需要迁移处理。
@@ -24,6 +24,7 @@ django_simpletask5/executors/__init__.py
24
24
  django_simpletask5/executors/archive.py
25
25
  django_simpletask5/executors/base.py
26
26
  django_simpletask5/executors/bash_script.py
27
+ django_simpletask5/executors/exceptions.py
27
28
  django_simpletask5/executors/loader.py
28
29
  django_simpletask5/executors/ping_pong.py
29
30
  django_simpletask5/executors/python_script.py
@@ -45,6 +46,7 @@ django_simpletask5/migrations/0004_alter_cronjob_is_modified_by_user.py
45
46
  django_simpletask5/migrations/0005_alter_cronjob_is_modified_by_user_help.py
46
47
  django_simpletask5/migrations/0006_alter_cronjob_options_alter_taskexecution_created_at_and_more.py
47
48
  django_simpletask5/migrations/0007_rename_task_model_to_executor_class_in_stat.py
49
+ django_simpletask5/migrations/0008_taskexecution_pickup_timeout_seconds.py
48
50
  django_simpletask5/migrations/__init__.py
49
51
  django_simpletask5/services/__init__.py
50
52
  django_simpletask5/services/archive.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "django-simpletask5"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Lightweight async task execution framework for Django"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,20 +0,0 @@
1
- from django_simpletask5.models import TaskExecution
2
-
3
-
4
- class BaseExecutor:
5
- timeout_seconds: int | None = None
6
- total_tasks_count: int | None = None
7
- done_tasks_count: int | None = None
8
-
9
- def get_tasks_count(self, execution: TaskExecution) -> int | None:
10
- return 1
11
-
12
- def get_tasks(self, execution: TaskExecution):
13
- yield None
14
-
15
- def execute(self, execution: TaskExecution, task=None) -> str | None:
16
- raise NotImplementedError('Subclasses must implement execute()')
17
-
18
- @classmethod
19
- def get_parameter_schema(cls):
20
- return []
@@ -1,47 +0,0 @@
1
- import json
2
- import logging
3
-
4
- from django.db import models, transaction
5
-
6
- from django_simpletask5.models import TaskExecution
7
- from django_simpletask5.executors.base import BaseExecutor
8
-
9
-
10
- def _publish_message_by_id(execution_id):
11
- from django_simpletask5.core.publisher import _publish_message
12
- try:
13
- exec_record = TaskExecution.objects.get(pk=execution_id)
14
- _publish_message(exec_record)
15
- except TaskExecution.DoesNotExist:
16
- logger.warning('Execution %s not found for retry publish', execution_id)
17
-
18
- logger = logging.getLogger(__name__)
19
-
20
-
21
- class RetryTimeoutExecutor(BaseExecutor):
22
- def execute(self, execution: TaskExecution, task=None) -> str | None:
23
- retryable = TaskExecution.objects.filter(
24
- status='timeout',
25
- retry_count__lt=models.F('max_retries'),
26
- )
27
- ids = list(retryable.values_list('pk', flat=True))
28
-
29
- if not ids:
30
- return json.dumps({'retried': 0, 'success': True})
31
-
32
- TaskExecution.objects.filter(pk__in=ids).update(
33
- status='retry',
34
- retry_count=models.F('retry_count') + 1,
35
- )
36
-
37
- from django_simpletask5.core.publisher import _publish_message
38
- from functools import partial
39
- for exec_id in ids:
40
- transaction.on_commit(
41
- partial(_publish_message_by_id, exec_id)
42
- )
43
-
44
- return json.dumps({
45
- 'retried': len(ids),
46
- 'success': True,
47
- })