django-simpletask5 0.2.6__tar.gz → 0.2.7__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 (64) hide show
  1. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/PKG-INFO +23 -11
  2. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/README.md +21 -9
  3. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/cronjob_registry.py +2 -2
  4. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/defaults.py +0 -3
  5. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/message_queue.py +38 -1
  6. django_simpletask5-0.2.7/django_simpletask5/core/reclaim.py +189 -0
  7. django_simpletask5-0.2.7/django_simpletask5/core/security.py +31 -0
  8. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/signals.py +25 -4
  9. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/dashboards.py +0 -3
  10. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/bash_script.py +3 -5
  11. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/python_script.py +3 -6
  12. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/retry_timeout.py +0 -1
  13. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/status_check.py +12 -2
  14. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/management/commands/django_simpletask_crontab.py +19 -2
  15. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/management/commands/django_simpletask_executor.py +70 -103
  16. django_simpletask5-0.2.7/django_simpletask5/management/commands/django_simpletask_queues.py +71 -0
  17. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/services/archive.py +0 -1
  18. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5.egg-info/PKG-INFO +23 -11
  19. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5.egg-info/SOURCES.txt +3 -0
  20. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5.egg-info/requires.txt +1 -1
  21. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/pyproject.toml +2 -2
  22. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/LICENSE +0 -0
  23. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/__init__.py +0 -0
  24. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/admin.py +0 -0
  25. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/apps.py +0 -0
  26. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/__init__.py +0 -0
  27. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/executor_scanner.py +0 -0
  28. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/lock.py +0 -0
  29. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/logging_utils.py +0 -0
  30. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/publisher.py +0 -0
  31. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/worker_registry.py +0 -0
  32. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/__init__.py +0 -0
  33. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/archive.py +0 -0
  34. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/base.py +0 -0
  35. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/exceptions.py +0 -0
  36. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/loader.py +0 -0
  37. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/ping_pong.py +0 -0
  38. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/simple_request.py +0 -0
  39. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  40. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.po +0 -0
  41. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/management/__init__.py +0 -0
  42. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/management/commands/__init__.py +0 -0
  43. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/management/commands/django_simpletask_archive.py +0 -0
  44. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/management/commands/django_simpletask_sync_cronjobs.py +0 -0
  45. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0001_initial.py +0 -0
  46. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0002_taskexecution_done_tasks_count_and_more.py +0 -0
  47. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0003_alter_cronjob_options_remove_cronjob_name_and_more.py +0 -0
  48. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0004_alter_cronjob_is_modified_by_user.py +0 -0
  49. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0005_alter_cronjob_is_modified_by_user_help.py +0 -0
  50. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0006_alter_cronjob_options_alter_taskexecution_created_at_and_more.py +0 -0
  51. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0007_rename_task_model_to_executor_class_in_stat.py +0 -0
  52. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0008_taskexecution_pickup_timeout_seconds.py +0 -0
  53. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0009_add_cronjob_app_label.py +0 -0
  54. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0010_taskexecution_id_alter_taskexecution_execution_id.py +0 -0
  55. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0011_taskexecutionstat_canceled_count.py +0 -0
  56. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0012_alter_taskexecution_status.py +0 -0
  57. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/__init__.py +0 -0
  58. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/models.py +0 -0
  59. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/services/__init__.py +0 -0
  60. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/static/django_simpletask5/css/dashboard.css +0 -0
  61. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/static/django_simpletask5/js/select2_executor.js +0 -0
  62. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5.egg-info/dependency_links.txt +0 -0
  63. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5.egg-info/top_level.txt +0 -0
  64. {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-simpletask5
3
- Version: 0.2.6
3
+ Version: 0.2.7
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>
@@ -23,7 +23,7 @@ Classifier: Programming Language :: Python :: 3.12
23
23
  Requires-Python: >=3.8
24
24
  Description-Content-Type: text/markdown
25
25
  License-File: LICENSE
26
- Requires-Dist: globallock>=0.1.4
26
+ Requires-Dist: globallock>=0.2.0
27
27
  Requires-Dist: django-safe-fields>=0.2.3
28
28
  Requires-Dist: django-app-requires>=0.3.6
29
29
  Requires-Dist: django-admin-dashboards>=0.1.1
@@ -143,6 +143,7 @@ DJANGO_SIMPLETASK_BROKER_URL = 'amqp://guest:guest@127.0.0.1:5672//'
143
143
  # DJANGO_SIMPLETASK_REDIS_MQ_HOST = '127.0.0.1' # MESSAGE_QUEUE_TYPE='redis' 时
144
144
  # DJANGO_SIMPLETASK_REDIS_MQ_PORT = 6379
145
145
  # DJANGO_SIMPLETASK_REDIS_MQ_DB = 0
146
+ # DJANGO_SIMPLETASK_REDIS_MQ_PASSWORD = None # Redis MQ 需认证时设置
146
147
 
147
148
  # ── 分布式锁与 Worker 注册中心(Redis)────────────────────────
148
149
  # 默认锁引擎 DjangoRedisGlobalLock 复用 CACHES['default'],需为 Redis 缓存
@@ -153,13 +154,16 @@ CACHES = {
153
154
  'OPTIONS': {'CLIENT_CLASS': 'django_redis.client.DefaultClient'},
154
155
  },
155
156
  }
156
- # 如需独立连接参数(锁与 Worker 注册中心共用同一 host/port/db):
157
+ # 如需独立连接参数(锁与 Worker 注册中心共用同一 host/port/db/password):
157
158
  # DJANGO_SIMPLETASK_LOCK_CONFIG = {
158
159
  # 'global_lock_engine_class': 'globallock.redis_global_lock.RedisGlobalLock',
159
- # 'global_lock_engine_options': {'host': '127.0.0.1', 'port': 6379, 'db': 0},
160
+ # 'global_lock_engine_options': {'host': '127.0.0.1', 'port': 6379, 'db': 0,
161
+ # 'password': '<redis 密码,无需认证可省略>'},
160
162
  # }
161
- DJANGO_SIMPLETASK_LOCK_TIMEOUT = 600 # 锁超时(秒)
162
- DJANGO_SIMPLETASK_LOCK_RENEW_INTERVAL = 60 # 锁续期间隔(秒)
163
+ # 注:globallock>=0.2.0 已取消按 timeout 自动释放的概念。锁的有效期由
164
+ # lockman/lock 作用域自行管理,作用域未结束前锁持续有效(内置看门狗自动续期),
165
+ # 锁在 `with` 块退出或进程消亡时自动释放。已移除 DJANGO_SIMPLETASK_LOCK_TIMEOUT
166
+ # 与 DJANGO_SIMPLETASK_LOCK_RENEW_INTERVAL,请勿再配置这两项。
163
167
 
164
168
  # ── Key 前缀与队列路由 ───────────────────────────────────────
165
169
  DJANGO_SIMPLETASK_KEY_PREFIX = 'myproject' # Redis key 前缀,默认取 ROOT_URLCONF 首段
@@ -209,11 +213,9 @@ django_app_requires_patch_all()
209
213
  | `DJANGO_SIMPLETASK_BROKER_URL` | `None` | **优先级最高**,设置后忽略下方 MQ 类型/连接参数。生产建议统一使用完整 URL,便于复用云厂商连接串与凭据管理 |
210
214
  | `DJANGO_SIMPLETASK_MESSAGE_QUEUE_TYPE` | `'memory'` | `memory` 仅用于开发调试;**生产必须改为 `rabbitmq` 或 `redis`**,否则消息不持久化、跨进程 Worker 无法消费 |
211
215
  | `DJANGO_SIMPLETASK_RABBITMQ_HOST/PORT/USER/PASSWORD` | `127.0.0.1 / 5672 / guest / guest` | 类型为 `rabbitmq` 且未设 `BROKER_URL` 时生效。生产务必更换默认 `guest` 凭据 |
212
- | `DJANGO_SIMPLETASK_REDIS_MQ_HOST/PORT/DB` | `127.0.0.1 / 6379 / 0` | 类型为 `redis` 时生效。建议与锁/缓存使用**不同 db**,避免 key 冲突与互相阻塞 |
216
+ | `DJANGO_SIMPLETASK_REDIS_MQ_HOST/PORT/DB/PASSWORD` | `127.0.0.1 / 6379 / 0 / None` | 类型为 `redis` 时生效。建议与锁/缓存使用**不同 db**,避免 key 冲突与互相阻塞。Redis 需认证时设置 `PASSWORD` |
213
217
  | `CACHES['default']` | — | 默认锁引擎 `DjangoRedisGlobalLock` 依赖它,**必须配置为 Redis 缓存** |
214
- | `DJANGO_SIMPLETASK_LOCK_CONFIG` | `None`(默认复用缓存锁) | 需要独立连接参数时显式配置。**注意**:Worker 注册中心(心跳/状态)也读取该配置的 `host/port/db`,未配置时固定连 `127.0.0.1:6379/0` |
215
- | `DJANGO_SIMPLETASK_LOCK_TIMEOUT` | `600` | 锁自动释放时间,应**大于单任务最长执行时长**;过短会导致长任务执行中被其他 Worker 抢锁 |
216
- | `DJANGO_SIMPLETASK_LOCK_RENEW_INTERVAL` | `60` | 锁续期间隔,应**远小于 `LOCK_TIMEOUT`**(默认 600 的 1/10) |
218
+ | `DJANGO_SIMPLETASK_LOCK_CONFIG` | `None`(默认复用缓存锁) | 需要独立连接参数时显式配置(`global_lock_engine_options` 支持 `host/port/db/password`)。**注意**:Worker 注册中心(心跳/状态)也读取该配置的 `host/port/db/password`,未配置时固定连 `127.0.0.1:6379/0`。globallock>=0.2.0 起锁有效期由 lockman/lock 作用域管理(内置看门狗自动续期),**不再需要配置 `LOCK_TIMEOUT` / `LOCK_RENEW_INTERVAL`** |
217
219
  | `DJANGO_SIMPLETASK_KEY_PREFIX` | 自动取 `ROOT_URLCONF` 首段 | 生产建议**显式指定**,多项目共用 Redis 时避免 key 冲突 |
218
220
  | `DJANGO_SIMPLETASK_DEFAULT_QUEUE` | `{prefix}:...:queue:default` | 默认队列名。多环境(dev/staging/prod)建议加环境后缀隔离 |
219
221
  | `DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE` | `{prefix}:...:queue:high_priority` | 高优先级队列,**需单独启动 Worker 消费** |
@@ -686,6 +688,8 @@ python3 -m pytest django_simpletask5_example/tests
686
688
 
687
689
  测试套件覆盖 Redis、DB、MQ 连接中断后的自动恢复场景,详见 `django_simpletask5_example/tests/test_resilience_recovery.py`。
688
690
 
691
+ **测试输出约定**:单元测试中对预期会触发的错误(如无效 UUID 校验、毒消息丢弃、消费者断连、执行器加载失败等)所输出的错误日志,必须在测试内屏蔽,避免污染测试输出。统一使用 `django_simpletask5_example.tests.suppress_expected_errors` 上下文管理器(整体禁用日志)或 `patch.object(..., 'logger')`(断言同时校验日志)进行屏蔽。
692
+
689
693
  ## 架构
690
694
 
691
695
  ```
@@ -698,6 +702,14 @@ Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保
698
702
 
699
703
  ## Releases
700
704
 
705
+ ### 0.2.7
706
+
707
+ - **运行中任务回收机制重构** — 取消原先按 `expire_time` 将 running 直接判为超时的语义;`StatusCheckExecutor` 仅兜底回收长期未被 Worker 获取的 pending。新增 `core/reclaim`,由 crontab 每 ~5 秒基于执行锁探测运行中任务:锁已过期(执行器异常退出)即快速重投或直接置失败,降低孤儿任务滞留时间
708
+ - **重试不再搁浅任务** — 失败重试先落库 `retry` 再发布重试消息,发布失败则 requeue 当前消息,杜绝任务永久停在无人处理的 `retry` 状态;KombuQueue 依据 AMQP `x-death` 上限丢弃毒消息,避免无限 requeue 造成的队头阻塞与空转
709
+ - **消费者重连提示** — Worker 断连重连成功后输出 `Consumer connection restored` 恢复日志,便于运维确认消费通道已恢复
710
+ - **安全与健壮性加固** — broker 账号/密码按百分号编码、脚本白名单按路径段边界匹配、signals 快照按实例隔离,覆盖相关回归测试
711
+ - **新增测试约定** — 单元测试中对预期触发(如无效 UUID 校验、毒消息丢弃、消费者断连、执行器加载失败等)产生的错误日志必须屏蔽输出,统一使用 `django_simpletask5_example.tests.suppress_expected_errors` 或替换 logger,保证测试输出干净可读;本次同步清理全部此类预期错误日志
712
+
701
713
  ### 0.2.6
702
714
 
703
715
  - **新增 `canceled` 状态** — 取消已发布但未执行的任务改为独立的终态 `canceled`(原复用 `failed`),Worker 消费到已取消消息时直接跳过;Admin 取消操作、归档/清理/统计(新增 `canceled_count`)、Dashboard 状态图均纳入该状态,取消与执行失败可清晰区分
@@ -714,7 +726,7 @@ Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保
714
726
 
715
727
  - **破坏性变更**: `TaskExecution` 主键由 `execution_id`(UUID)改为默认自增 `id`,`execution_id` 变为 `unique=True` 的唯一标识字段。已有数据库需要执行迁移 `0010_taskexecution_id_alter_taskexecution_execution_id`
716
728
  - **定期存活日志** — Worker 与 Crontab 主循环在长时间无消息/无任务时按 `DJANGO_SIMPLETASK_LOG_INTERVAL`(默认 300 秒)定期输出存活日志,并统一带时间戳格式,便于运维确认进程未死循环
717
- - **MQ 日志降噪** — MQ 消费/发布异常及 Redis 连接失败、锁续期失败改为 `error`/`warning` 级别且无堆栈(默认按间隔节流),避免刷屏;提供 `--debug` 时输出完整堆栈
729
+ - **MQ 日志降噪** — MQ 消费/发布异常及 Redis 连接失败改为 `error`/`warning` 级别且无堆栈(默认按间隔节流),避免刷屏;提供 `--debug` 时输出完整堆栈。注:此处所指的"锁续期失败"是 0.2.4 版本引入的自定义锁续期机制,自 globallock>=0.2.0 起该机制已**移除**,改由 globallock 内置看门狗在 lockman/lock 作用域内自动续期,无需(也无从)单独记录续期失败日志
718
730
  - **新增配置** — `DJANGO_SIMPLETASK_LOG_INTERVAL` 控制死循环存活日志与异常节流日志的输出间隔(秒)
719
731
 
720
732
  ### 0.2.3
@@ -103,6 +103,7 @@ DJANGO_SIMPLETASK_BROKER_URL = 'amqp://guest:guest@127.0.0.1:5672//'
103
103
  # DJANGO_SIMPLETASK_REDIS_MQ_HOST = '127.0.0.1' # MESSAGE_QUEUE_TYPE='redis' 时
104
104
  # DJANGO_SIMPLETASK_REDIS_MQ_PORT = 6379
105
105
  # DJANGO_SIMPLETASK_REDIS_MQ_DB = 0
106
+ # DJANGO_SIMPLETASK_REDIS_MQ_PASSWORD = None # Redis MQ 需认证时设置
106
107
 
107
108
  # ── 分布式锁与 Worker 注册中心(Redis)────────────────────────
108
109
  # 默认锁引擎 DjangoRedisGlobalLock 复用 CACHES['default'],需为 Redis 缓存
@@ -113,13 +114,16 @@ CACHES = {
113
114
  'OPTIONS': {'CLIENT_CLASS': 'django_redis.client.DefaultClient'},
114
115
  },
115
116
  }
116
- # 如需独立连接参数(锁与 Worker 注册中心共用同一 host/port/db):
117
+ # 如需独立连接参数(锁与 Worker 注册中心共用同一 host/port/db/password):
117
118
  # DJANGO_SIMPLETASK_LOCK_CONFIG = {
118
119
  # 'global_lock_engine_class': 'globallock.redis_global_lock.RedisGlobalLock',
119
- # 'global_lock_engine_options': {'host': '127.0.0.1', 'port': 6379, 'db': 0},
120
+ # 'global_lock_engine_options': {'host': '127.0.0.1', 'port': 6379, 'db': 0,
121
+ # 'password': '<redis 密码,无需认证可省略>'},
120
122
  # }
121
- DJANGO_SIMPLETASK_LOCK_TIMEOUT = 600 # 锁超时(秒)
122
- DJANGO_SIMPLETASK_LOCK_RENEW_INTERVAL = 60 # 锁续期间隔(秒)
123
+ # 注:globallock>=0.2.0 已取消按 timeout 自动释放的概念。锁的有效期由
124
+ # lockman/lock 作用域自行管理,作用域未结束前锁持续有效(内置看门狗自动续期),
125
+ # 锁在 `with` 块退出或进程消亡时自动释放。已移除 DJANGO_SIMPLETASK_LOCK_TIMEOUT
126
+ # 与 DJANGO_SIMPLETASK_LOCK_RENEW_INTERVAL,请勿再配置这两项。
123
127
 
124
128
  # ── Key 前缀与队列路由 ───────────────────────────────────────
125
129
  DJANGO_SIMPLETASK_KEY_PREFIX = 'myproject' # Redis key 前缀,默认取 ROOT_URLCONF 首段
@@ -169,11 +173,9 @@ django_app_requires_patch_all()
169
173
  | `DJANGO_SIMPLETASK_BROKER_URL` | `None` | **优先级最高**,设置后忽略下方 MQ 类型/连接参数。生产建议统一使用完整 URL,便于复用云厂商连接串与凭据管理 |
170
174
  | `DJANGO_SIMPLETASK_MESSAGE_QUEUE_TYPE` | `'memory'` | `memory` 仅用于开发调试;**生产必须改为 `rabbitmq` 或 `redis`**,否则消息不持久化、跨进程 Worker 无法消费 |
171
175
  | `DJANGO_SIMPLETASK_RABBITMQ_HOST/PORT/USER/PASSWORD` | `127.0.0.1 / 5672 / guest / guest` | 类型为 `rabbitmq` 且未设 `BROKER_URL` 时生效。生产务必更换默认 `guest` 凭据 |
172
- | `DJANGO_SIMPLETASK_REDIS_MQ_HOST/PORT/DB` | `127.0.0.1 / 6379 / 0` | 类型为 `redis` 时生效。建议与锁/缓存使用**不同 db**,避免 key 冲突与互相阻塞 |
176
+ | `DJANGO_SIMPLETASK_REDIS_MQ_HOST/PORT/DB/PASSWORD` | `127.0.0.1 / 6379 / 0 / None` | 类型为 `redis` 时生效。建议与锁/缓存使用**不同 db**,避免 key 冲突与互相阻塞。Redis 需认证时设置 `PASSWORD` |
173
177
  | `CACHES['default']` | — | 默认锁引擎 `DjangoRedisGlobalLock` 依赖它,**必须配置为 Redis 缓存** |
174
- | `DJANGO_SIMPLETASK_LOCK_CONFIG` | `None`(默认复用缓存锁) | 需要独立连接参数时显式配置。**注意**:Worker 注册中心(心跳/状态)也读取该配置的 `host/port/db`,未配置时固定连 `127.0.0.1:6379/0` |
175
- | `DJANGO_SIMPLETASK_LOCK_TIMEOUT` | `600` | 锁自动释放时间,应**大于单任务最长执行时长**;过短会导致长任务执行中被其他 Worker 抢锁 |
176
- | `DJANGO_SIMPLETASK_LOCK_RENEW_INTERVAL` | `60` | 锁续期间隔,应**远小于 `LOCK_TIMEOUT`**(默认 600 的 1/10) |
178
+ | `DJANGO_SIMPLETASK_LOCK_CONFIG` | `None`(默认复用缓存锁) | 需要独立连接参数时显式配置(`global_lock_engine_options` 支持 `host/port/db/password`)。**注意**:Worker 注册中心(心跳/状态)也读取该配置的 `host/port/db/password`,未配置时固定连 `127.0.0.1:6379/0`。globallock>=0.2.0 起锁有效期由 lockman/lock 作用域管理(内置看门狗自动续期),**不再需要配置 `LOCK_TIMEOUT` / `LOCK_RENEW_INTERVAL`** |
177
179
  | `DJANGO_SIMPLETASK_KEY_PREFIX` | 自动取 `ROOT_URLCONF` 首段 | 生产建议**显式指定**,多项目共用 Redis 时避免 key 冲突 |
178
180
  | `DJANGO_SIMPLETASK_DEFAULT_QUEUE` | `{prefix}:...:queue:default` | 默认队列名。多环境(dev/staging/prod)建议加环境后缀隔离 |
179
181
  | `DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE` | `{prefix}:...:queue:high_priority` | 高优先级队列,**需单独启动 Worker 消费** |
@@ -646,6 +648,8 @@ python3 -m pytest django_simpletask5_example/tests
646
648
 
647
649
  测试套件覆盖 Redis、DB、MQ 连接中断后的自动恢复场景,详见 `django_simpletask5_example/tests/test_resilience_recovery.py`。
648
650
 
651
+ **测试输出约定**:单元测试中对预期会触发的错误(如无效 UUID 校验、毒消息丢弃、消费者断连、执行器加载失败等)所输出的错误日志,必须在测试内屏蔽,避免污染测试输出。统一使用 `django_simpletask5_example.tests.suppress_expected_errors` 上下文管理器(整体禁用日志)或 `patch.object(..., 'logger')`(断言同时校验日志)进行屏蔽。
652
+
649
653
  ## 架构
650
654
 
651
655
  ```
@@ -658,6 +662,14 @@ Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保
658
662
 
659
663
  ## Releases
660
664
 
665
+ ### 0.2.7
666
+
667
+ - **运行中任务回收机制重构** — 取消原先按 `expire_time` 将 running 直接判为超时的语义;`StatusCheckExecutor` 仅兜底回收长期未被 Worker 获取的 pending。新增 `core/reclaim`,由 crontab 每 ~5 秒基于执行锁探测运行中任务:锁已过期(执行器异常退出)即快速重投或直接置失败,降低孤儿任务滞留时间
668
+ - **重试不再搁浅任务** — 失败重试先落库 `retry` 再发布重试消息,发布失败则 requeue 当前消息,杜绝任务永久停在无人处理的 `retry` 状态;KombuQueue 依据 AMQP `x-death` 上限丢弃毒消息,避免无限 requeue 造成的队头阻塞与空转
669
+ - **消费者重连提示** — Worker 断连重连成功后输出 `Consumer connection restored` 恢复日志,便于运维确认消费通道已恢复
670
+ - **安全与健壮性加固** — broker 账号/密码按百分号编码、脚本白名单按路径段边界匹配、signals 快照按实例隔离,覆盖相关回归测试
671
+ - **新增测试约定** — 单元测试中对预期触发(如无效 UUID 校验、毒消息丢弃、消费者断连、执行器加载失败等)产生的错误日志必须屏蔽输出,统一使用 `django_simpletask5_example.tests.suppress_expected_errors` 或替换 logger,保证测试输出干净可读;本次同步清理全部此类预期错误日志
672
+
661
673
  ### 0.2.6
662
674
 
663
675
  - **新增 `canceled` 状态** — 取消已发布但未执行的任务改为独立的终态 `canceled`(原复用 `failed`),Worker 消费到已取消消息时直接跳过;Admin 取消操作、归档/清理/统计(新增 `canceled_count`)、Dashboard 状态图均纳入该状态,取消与执行失败可清晰区分
@@ -674,7 +686,7 @@ Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保
674
686
 
675
687
  - **破坏性变更**: `TaskExecution` 主键由 `execution_id`(UUID)改为默认自增 `id`,`execution_id` 变为 `unique=True` 的唯一标识字段。已有数据库需要执行迁移 `0010_taskexecution_id_alter_taskexecution_execution_id`
676
688
  - **定期存活日志** — Worker 与 Crontab 主循环在长时间无消息/无任务时按 `DJANGO_SIMPLETASK_LOG_INTERVAL`(默认 300 秒)定期输出存活日志,并统一带时间戳格式,便于运维确认进程未死循环
677
- - **MQ 日志降噪** — MQ 消费/发布异常及 Redis 连接失败、锁续期失败改为 `error`/`warning` 级别且无堆栈(默认按间隔节流),避免刷屏;提供 `--debug` 时输出完整堆栈
689
+ - **MQ 日志降噪** — MQ 消费/发布异常及 Redis 连接失败改为 `error`/`warning` 级别且无堆栈(默认按间隔节流),避免刷屏;提供 `--debug` 时输出完整堆栈。注:此处所指的"锁续期失败"是 0.2.4 版本引入的自定义锁续期机制,自 globallock>=0.2.0 起该机制已**移除**,改由 globallock 内置看门狗在 lockman/lock 作用域内自动续期,无需(也无从)单独记录续期失败日志
678
690
  - **新增配置** — `DJANGO_SIMPLETASK_LOG_INTERVAL` 控制死循环存活日志与异常节流日志的输出间隔(秒)
679
691
 
680
692
  ### 0.2.3
@@ -94,10 +94,10 @@ def register_builtin_cronjobs():
94
94
 
95
95
  register_cronjob(
96
96
  uid='simpletask_status_check',
97
- display_name=_('Task Timeout Detection'),
97
+ display_name=_('Pickup Timeout Detection'),
98
98
  cron_expression='* * * * *',
99
99
  executor_class='django_simpletask5.executors.status_check.StatusCheckExecutor',
100
- description='Scan long-running pending/running tasks and mark them as timeout',
100
+ description='Scan pending tasks that were never picked up and mark them as timeout for retry. Running tasks are not time-limited: orphans are reclaimed by the scheduler based on execution-lock expiry',
101
101
  )
102
102
  register_cronjob(
103
103
  uid='simpletask_retry_timeout',
@@ -13,9 +13,6 @@ def _compute_key_prefix() -> str:
13
13
 
14
14
  DJANGO_SIMPLETASK_KEY_PREFIX = _compute_key_prefix()
15
15
 
16
- DJANGO_SIMPLETASK_LOCK_TIMEOUT = getattr(settings, 'DJANGO_SIMPLETASK_LOCK_TIMEOUT', 600)
17
- DJANGO_SIMPLETASK_LOCK_RENEW_INTERVAL = getattr(settings, 'DJANGO_SIMPLETASK_LOCK_RENEW_INTERVAL', 60)
18
-
19
16
  DJANGO_SIMPLETASK_SHUTDOWN_TIMEOUT = getattr(settings, 'DJANGO_SIMPLETASK_SHUTDOWN_TIMEOUT', 30)
20
17
  DJANGO_SIMPLETASK_LOG_INTERVAL = getattr(settings, 'DJANGO_SIMPLETASK_LOG_INTERVAL', 300)
21
18
  DJANGO_SIMPLETASK_WORKER_HEARTBEAT_INTERVAL = getattr(settings, 'DJANGO_SIMPLETASK_WORKER_HEARTBEAT_INTERVAL', 60)
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
  import socket
3
3
  import time
4
+ from urllib.parse import quote
4
5
 
5
6
  from django.conf import settings
6
7
  from kombu import Connection, Exchange, Queue, Producer, Consumer
@@ -10,6 +11,11 @@ from django_simpletask5.core.defaults import DJANGO_SIMPLETASK_LOG_INTERVAL
10
11
  logger = logging.getLogger(__name__)
11
12
 
12
13
 
14
+ def _quote_url_component(value):
15
+ """URL 编码账号/密码,避免密码含 @ : / % 等保留字符破坏连接串解析。"""
16
+ return quote(str(value), safe='')
17
+
18
+
13
19
  def _get_broker_url():
14
20
  url = getattr(settings, 'DJANGO_SIMPLETASK_BROKER_URL', None)
15
21
  if url:
@@ -20,12 +26,16 @@ def _get_broker_url():
20
26
  port = getattr(settings, 'DJANGO_SIMPLETASK_RABBITMQ_PORT', 5672)
21
27
  user = getattr(settings, 'DJANGO_SIMPLETASK_RABBITMQ_USER', 'guest')
22
28
  pw = getattr(settings, 'DJANGO_SIMPLETASK_RABBITMQ_PASSWORD', 'guest')
29
+ user = _quote_url_component(user)
30
+ pw = _quote_url_component(pw)
23
31
  return f'amqp://{user}:{pw}@{host}:{port}//'
24
32
  if transport == 'redis':
25
33
  host = getattr(settings, 'DJANGO_SIMPLETASK_REDIS_MQ_HOST', '127.0.0.1')
26
34
  port = getattr(settings, 'DJANGO_SIMPLETASK_REDIS_MQ_PORT', 6379)
27
35
  db = getattr(settings, 'DJANGO_SIMPLETASK_REDIS_MQ_DB', 0)
28
- return f'redis://{host}:{port}/{db}'
36
+ pw = getattr(settings, 'DJANGO_SIMPLETASK_REDIS_MQ_PASSWORD', None)
37
+ auth = f':{_quote_url_component(pw)}@' if pw else ''
38
+ return f'redis://{auth}{host}:{port}/{db}'
29
39
  return 'memory://'
30
40
 
31
41
 
@@ -89,15 +99,42 @@ class KombuQueue:
89
99
  try:
90
100
  result = callback(body)
91
101
  if result is False:
102
+ if KombuQueue._exceeds_max_redeliveries(message):
103
+ logger.error(
104
+ 'Dropping message after too many redeliveries (possible poison message): %s',
105
+ body,
106
+ )
107
+ message.ack()
108
+ return
92
109
  message.reject(requeue=True)
93
110
  return
94
111
  except Exception as e:
95
112
  logger.error('Error in message callback: %s', e)
113
+ if KombuQueue._exceeds_max_redeliveries(message):
114
+ logger.error('Dropping message after too many redeliveries (possible poison message): %s', body)
115
+ message.ack()
116
+ return
96
117
  message.reject(requeue=True)
97
118
  return
98
119
  if auto_ack:
99
120
  message.ack()
100
121
 
122
+ @staticmethod
123
+ def _exceeds_max_redeliveries(message):
124
+ """判断一条消息是否已被反复重投过多次(AMQP x-death 计数)。
125
+
126
+ 防止“无法服务/暂时性失败”的消息被无限 requeue 造成队头阻塞与 CPU 空转。
127
+ 非 AMQP 传输(memory / redis)不提供 x-death 头,返回 False 保持原行为。
128
+ """
129
+ limit = getattr(settings, 'DJANGO_SIMPLETASK_MAX_REQUEUE_DELIVERIES', 100)
130
+ headers = getattr(message, 'headers', None) or {}
131
+ x_death = headers.get('x-death') or []
132
+ total = 0
133
+ for entry in x_death:
134
+ if isinstance(entry, dict):
135
+ total += int(entry.get('count', 0) or 0)
136
+ return total >= limit
137
+
101
138
  def stop(self):
102
139
  self._stopped = True
103
140
 
@@ -0,0 +1,189 @@
1
+ """基于执行锁的孤儿任务快速回收。
2
+
3
+ 设计语义(globallock>=0.2.0 起):
4
+ - 任务被 worker 获取(status='running')后,worker 持有该执行对应的分布式锁,
5
+ 并由 globallock 的看门狗线程自动续期。只要执行器进程存活,锁就一直有效,
6
+ 任务可以无限期运行,不受任何“任务超时”限制。
7
+ - 只有当执行器异常退出(进程被杀/崩溃)时,锁才会在 TTL 后自动过期。
8
+ 锁过期即代表执行器已异常退出,此时任务需要被回收:
9
+ - 仍有重试次数 -> 状态置为 retry、retry_count+1 并立即重新投递
10
+ - 重试耗尽 -> 状态置为 failed
11
+ - 回收在 crontab 调度进程的每次 tick(约 5 秒)内直接执行,保证快速回收,
12
+ 不依赖分钟级的 cron 执行器。
13
+ """
14
+
15
+ import logging
16
+ from datetime import timedelta
17
+
18
+ from django.conf import settings
19
+ from django.utils import timezone
20
+
21
+ from django_simpletask5.models import TaskExecution
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # 仅回收“开始运行已超过该时长”的任务,避免对刚被 worker 获取的任务做无谓探测
26
+ RECLAIM_STARTED_AGE_SECONDS = 35
27
+ # 单次 tick 最多探测的 running 任务数,防止异常情况下拖慢调度
28
+ RECLAIM_SCAN_LIMIT = 200
29
+ # globallock 默认锁 key 前缀(与 globallock.constants 保持一致,允许被配置覆盖)
30
+ _DEFAULT_LOCK_KEY_PREFIX = '_glocks:'
31
+
32
+
33
+ def _lock_config():
34
+ config = getattr(settings, 'DJANGO_SIMPLETASK_LOCK_CONFIG', None)
35
+ if not config:
36
+ from django_simpletask5.core.lock import _default_lock_config
37
+ config = _default_lock_config()
38
+ return config
39
+
40
+
41
+ def _execution_lock_name(execution_id):
42
+ from django_simpletask5.core.defaults import DJANGO_SIMPLETASK_KEY_PREFIX
43
+ return f'{DJANGO_SIMPLETASK_KEY_PREFIX}:django-simpletask5:execution:{execution_id}'
44
+
45
+
46
+ def _lock_key_prefix(config):
47
+ return config.get('global_lock_key_prefix', _DEFAULT_LOCK_KEY_PREFIX)
48
+
49
+
50
+ def _get_probe_client():
51
+ """返回用于探测执行锁的 redis 客户端。
52
+
53
+ 仅支持 redis 家族的锁引擎(DjangoRedisGlobalLock / RedisGlobalLock),
54
+ 其它引擎(etcd / zookeeper 等)无法低成本探测,返回 None 表示跳过回收。
55
+ """
56
+ config = _lock_config()
57
+ engine = config.get('global_lock_engine_class', '')
58
+ options = config.get('global_lock_engine_options', {}) or {}
59
+ try:
60
+ if engine.endswith('.django_redis_global_lock.DjangoRedisGlobalLock'):
61
+ from django_redis import get_redis_connection
62
+ return get_redis_connection(options.get('redis-cache-name', 'default'))
63
+ if engine.endswith('.redis_global_lock.RedisGlobalLock'):
64
+ import redis as _redis
65
+ return _redis.Redis(
66
+ host=options.get('host', '127.0.0.1'),
67
+ port=options.get('port', 6379),
68
+ db=options.get('db', 0),
69
+ password=options.get('password', None) or None,
70
+ decode_responses=True,
71
+ socket_connect_timeout=3,
72
+ )
73
+ except Exception:
74
+ logger.exception('Failed to build execution-lock probe client')
75
+ return None
76
+
77
+
78
+ def _execution_lock_exists(client, execution_id):
79
+ key = _lock_key_prefix(_lock_config()) + _execution_lock_name(execution_id)
80
+ return bool(client.exists(key))
81
+
82
+
83
+ def _publish_requeue(execution):
84
+ """向默认队列重新投递一次该 execution 的消息。
85
+
86
+ 单次尝试即可:crontab 每 ~5s 会再次进入回收流程,失败的任务下一轮会重试。
87
+ """
88
+ from django_simpletask5.core.defaults import DJANGO_SIMPLETASK_DEFAULT_QUEUE
89
+ from django_simpletask5.core.message_queue import get_message_queue
90
+
91
+ message = {
92
+ 'execution_id': str(execution.execution_id),
93
+ 'task_id': str(execution.task_id) if execution.task_id else None,
94
+ 'trigger_event': execution.trigger_event,
95
+ 'executor_class': execution.executor_class,
96
+ }
97
+ try:
98
+ get_message_queue().publish(
99
+ routing_key=DJANGO_SIMPLETASK_DEFAULT_QUEUE,
100
+ message=message,
101
+ )
102
+ return True
103
+ except Exception as e:
104
+ logger.warning('Reclaim: failed to republish execution %s: %s', execution.execution_id, e)
105
+ return False
106
+
107
+
108
+ def reclaim_orphaned_executions(publish=None):
109
+ """扫描 status='running' 且执行锁已过期(执行器异常退出)的任务并回收。
110
+
111
+ publish 参数可注入,便于测试;默认使用 _publish_requeue。
112
+
113
+ 返回统计 dict:{scanned, requeued, failed, skipped, publish_failures, supported}
114
+ """
115
+ from django.db import close_old_connections
116
+
117
+ close_old_connections()
118
+ stats = {
119
+ 'scanned': 0,
120
+ 'requeued': 0,
121
+ 'failed': 0,
122
+ 'skipped': 0,
123
+ 'publish_failures': 0,
124
+ 'supported': False,
125
+ }
126
+ if publish is None:
127
+ publish = _publish_requeue
128
+
129
+ client = _get_probe_client()
130
+ if client is None:
131
+ return stats
132
+ stats['supported'] = True
133
+
134
+ cutoff = timezone.now() - timedelta(seconds=RECLAIM_STARTED_AGE_SECONDS)
135
+ try:
136
+ running = list(
137
+ TaskExecution.objects.filter(
138
+ status='running',
139
+ started_at__isnull=False,
140
+ started_at__lte=cutoff,
141
+ ).order_by('started_at')[:RECLAIM_SCAN_LIMIT]
142
+ )
143
+ except Exception:
144
+ logger.exception('Reclaim: failed to query running executions')
145
+ return stats
146
+
147
+ for record in running:
148
+ stats['scanned'] += 1
149
+ try:
150
+ locked = _execution_lock_exists(client, record.execution_id)
151
+ except Exception:
152
+ logger.warning('Reclaim: lock probe failed for execution %s, will retry next tick', record.execution_id)
153
+ continue
154
+ if locked:
155
+ stats['skipped'] += 1
156
+ continue
157
+
158
+ try:
159
+ record = TaskExecution.objects.get(pk=record.pk)
160
+ except TaskExecution.DoesNotExist:
161
+ continue
162
+ if record.status != 'running':
163
+ stats['skipped'] += 1
164
+ continue
165
+
166
+ reason = 'Executor lost its execution lock (abnormal exit), reclaimed by scheduler'
167
+
168
+ if record.retry_count < record.max_retries:
169
+ if not publish(record):
170
+ stats['publish_failures'] += 1
171
+ continue
172
+ record.status = 'retry'
173
+ record.retry_count += 1
174
+ record.error_message = reason
175
+ record.save(update_fields=['status', 'retry_count', 'error_message', 'updated_at'])
176
+ stats['requeued'] += 1
177
+ logger.info('Reclaim: requeued orphaned running execution %s (attempt %d/%d)',
178
+ record.execution_id, record.retry_count, record.max_retries)
179
+ else:
180
+ record.status = 'failed'
181
+ record.error_message = reason + ', no retry attempts remaining'
182
+ record.finished_at = timezone.now()
183
+ record.save(update_fields=['status', 'error_message', 'finished_at', 'updated_at'])
184
+ stats['failed'] += 1
185
+ logger.warning('Reclaim: failed orphaned running execution %s (retries exhausted)',
186
+ record.execution_id)
187
+
188
+ close_old_connections()
189
+ return stats
@@ -0,0 +1,31 @@
1
+ """脚本白名单的路径判定工具。
2
+
3
+ 按“路径段”边界匹配:只有白名单条目本身或其**目录**下(不含前缀同名兄弟目录)
4
+ 的脚本才被允许,避免 ``/safe/scripts`` 误放行 ``/safe/scripts_evil/run.sh`` 之类。
5
+ """
6
+
7
+ import os
8
+
9
+
10
+ def is_allowed_script_path(script_path, whitelist):
11
+ """判断脚本路径是否位于白名单目录内或与白名单文件一致。
12
+
13
+ - 白名单为空 -> 不限制(True)。
14
+ - 白名单条目可以是目录或文件:
15
+ - 目录:允许该目录及其全部子目录下的脚本;
16
+ - 文件:仅允许与该文件路径完全一致的脚本。
17
+ """
18
+ if not whitelist:
19
+ return True
20
+ if not script_path:
21
+ return False
22
+ abs_path = os.path.abspath(script_path)
23
+ for entry in whitelist:
24
+ base = os.path.abspath(entry)
25
+ if abs_path == base:
26
+ return True
27
+ if base == os.sep:
28
+ return True
29
+ if abs_path.startswith(base.rstrip(os.sep) + os.sep):
30
+ return True
31
+ return False
@@ -16,6 +16,26 @@ class _DeleteIntercepted(Exception):
16
16
  _pre_save_data = threading.local()
17
17
 
18
18
 
19
+ def _init_snapshot_store():
20
+ if not hasattr(_pre_save_data, 'snapshots'):
21
+ _pre_save_data.snapshots = {}
22
+
23
+
24
+ def _store_snapshot(instance, snapshot):
25
+ """按实例保存 pre_save 快照。
26
+
27
+ 以 id(instance) 为键,使得同一线程内嵌套/连续的多次保存互不串扰,
28
+ 每个实例在 post_save 时只取回属于它自己的快照。
29
+ """
30
+ _init_snapshot_store()
31
+ _pre_save_data.snapshots[id(instance)] = snapshot
32
+
33
+
34
+ def _pop_snapshot(instance):
35
+ _init_snapshot_store()
36
+ return _pre_save_data.snapshots.pop(id(instance), None)
37
+
38
+
19
39
  @receiver(pre_save)
20
40
  def handle_task_pre_save(sender, instance, **kwargs):
21
41
  if not _is_task_subclass(sender):
@@ -23,11 +43,12 @@ def handle_task_pre_save(sender, instance, **kwargs):
23
43
  if instance.pk and not getattr(instance, '_skip_pre_save_snapshot', False):
24
44
  try:
25
45
  old = sender.objects.get(pk=instance.pk)
26
- _pre_save_data.data = _model_to_dict(old)
46
+ snapshot = _model_to_dict(old)
27
47
  except sender.DoesNotExist:
28
- _pre_save_data.data = None
48
+ snapshot = None
29
49
  else:
30
- _pre_save_data.data = None
50
+ snapshot = None
51
+ _store_snapshot(instance, snapshot)
31
52
 
32
53
 
33
54
  @receiver(post_save)
@@ -40,7 +61,7 @@ def handle_task_post_save(sender, instance, created, **kwargs):
40
61
  trigger_event='create',
41
62
  )
42
63
  else:
43
- old_data = getattr(_pre_save_data, 'data', None)
64
+ old_data = _pop_snapshot(instance)
44
65
  new_data = _model_to_dict(instance)
45
66
  if old_data:
46
67
  changed_fields = _get_changed_fields(old_data, new_data)
@@ -45,7 +45,6 @@ class TaskDashboard(Dashboard):
45
45
  pending = TaskExecution.objects.filter(status="pending").count()
46
46
  running = TaskExecution.objects.filter(status="running").count()
47
47
  active_cronjobs = CronJob.objects.filter(is_active=True).count()
48
- total_cronjobs = CronJob.objects.count()
49
48
  archives_count = TaskExecutionArchive.objects.count()
50
49
  failed_today = TaskExecution.objects.filter(
51
50
  created_at__date=today, status="failed"
@@ -54,8 +53,6 @@ class TaskDashboard(Dashboard):
54
53
  worker_stats = _get_worker_stats()
55
54
  worker_redis_available = worker_stats["redis_available"]
56
55
  worker_total = worker_stats["total"] if worker_redis_available else None
57
- worker_idle = worker_stats["by_status"].get("idle", 0) if worker_redis_available else None
58
- worker_running_count = worker_stats["by_status"].get("running", 0) if worker_redis_available else None
59
56
 
60
57
  success_rate = None
61
58
  if total_today > 0:
@@ -34,12 +34,10 @@ class BashScriptExecutor(BaseExecutor):
34
34
  env = params.get('env', {})
35
35
 
36
36
  if script_path:
37
+ from django_simpletask5.core.security import is_allowed_script_path
37
38
  script_whitelist = getattr(settings, 'DJANGO_SIMPLETASK_SCRIPT_WHITELIST', [])
38
- if script_whitelist:
39
- abs_path = os.path.abspath(script_path)
40
- allowed = any(abs_path.startswith(os.path.abspath(p)) for p in script_whitelist)
41
- if not allowed:
42
- raise RuntimeError('Script path not in whitelist')
39
+ if not is_allowed_script_path(script_path, script_whitelist):
40
+ raise RuntimeError('Script path not in whitelist')
43
41
  with open(script_path, 'r') as f:
44
42
  script = f.read()
45
43
 
@@ -1,6 +1,5 @@
1
1
  import json
2
2
  import logging
3
- import os
4
3
 
5
4
  from django.conf import settings
6
5
 
@@ -31,12 +30,10 @@ class PythonScriptExecutor(BaseExecutor):
31
30
  args = params.get('args', [])
32
31
 
33
32
  if script_path:
33
+ from django_simpletask5.core.security import is_allowed_script_path
34
34
  script_whitelist = getattr(settings, 'DJANGO_SIMPLETASK_SCRIPT_WHITELIST', [])
35
- if script_whitelist:
36
- abs_path = os.path.abspath(script_path)
37
- allowed = any(abs_path.startswith(os.path.abspath(p)) for p in script_whitelist)
38
- if not allowed:
39
- raise RuntimeError('Script path not in whitelist')
35
+ if not is_allowed_script_path(script_path, script_whitelist):
36
+ raise RuntimeError('Script path not in whitelist')
40
37
  with open(script_path, 'r') as f:
41
38
  script = f.read()
42
39
 
@@ -21,7 +21,6 @@ logger = logging.getLogger(__name__)
21
21
  class RetryTimeoutExecutor(BaseExecutor):
22
22
  def execute(self, execution: TaskExecution, task=None) -> str | None:
23
23
  from functools import partial
24
- from django_simpletask5.core.publisher import _publish_message
25
24
 
26
25
  timeout_records = TaskExecution.objects.filter(
27
26
  status='timeout',
@@ -10,9 +10,19 @@ logger = logging.getLogger(__name__)
10
10
 
11
11
 
12
12
  class StatusCheckExecutor(BaseExecutor):
13
+ """Pickup-timeout detection for tasks that were never acquired.
14
+
15
+ 语义(globallock>=0.2.0 起):
16
+ - 从未被 worker 获取的 pending 任务超过 pickup 期限(expire_time)后,视为
17
+ 消息丢失的兜底,标记为 timeout,由 RetryTimeoutExecutor 重新投递。
18
+ - 已被获取(running)的任务**不再**依据 expire_time 判超时:只要执行器进程
19
+ 存活(执行锁被自动续期),任务即可无限期运行;执行器异常退出导致的孤儿
20
+ running 任务由 core.reclaim(crontab 高频回收)基于执行锁过期来回收。
21
+ """
22
+
13
23
  def execute(self, execution: TaskExecution, task=None) -> str | None:
14
24
  overdue = TaskExecution.objects.filter(
15
- status__in=['pending', 'running'],
25
+ status='pending',
16
26
  expire_time__lt=Now(),
17
27
  )
18
28
 
@@ -20,7 +30,7 @@ class StatusCheckExecutor(BaseExecutor):
20
30
  for exec_record in overdue:
21
31
  exec_record.status = 'timeout'
22
32
  exec_record.error_message = json.dumps({
23
- 'reason': 'Status check timeout',
33
+ 'reason': 'Pickup timeout',
24
34
  })
25
35
  exec_record.save(update_fields=['status', 'error_message', 'updated_at'])
26
36
  count += 1