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.
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/PKG-INFO +23 -11
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/README.md +21 -9
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/cronjob_registry.py +2 -2
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/defaults.py +0 -3
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/message_queue.py +38 -1
- django_simpletask5-0.2.7/django_simpletask5/core/reclaim.py +189 -0
- django_simpletask5-0.2.7/django_simpletask5/core/security.py +31 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/signals.py +25 -4
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/dashboards.py +0 -3
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/bash_script.py +3 -5
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/python_script.py +3 -6
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/retry_timeout.py +0 -1
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/status_check.py +12 -2
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/management/commands/django_simpletask_crontab.py +19 -2
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/management/commands/django_simpletask_executor.py +70 -103
- django_simpletask5-0.2.7/django_simpletask5/management/commands/django_simpletask_queues.py +71 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/services/archive.py +0 -1
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5.egg-info/PKG-INFO +23 -11
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5.egg-info/SOURCES.txt +3 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5.egg-info/requires.txt +1 -1
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/pyproject.toml +2 -2
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/LICENSE +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/__init__.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/admin.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/apps.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/__init__.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/executor_scanner.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/lock.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/logging_utils.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/publisher.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/worker_registry.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/__init__.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/archive.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/base.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/exceptions.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/loader.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/ping_pong.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/simple_request.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.po +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/management/__init__.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/management/commands/__init__.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/management/commands/django_simpletask_archive.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/management/commands/django_simpletask_sync_cronjobs.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0001_initial.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0002_taskexecution_done_tasks_count_and_more.py +0 -0
- {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
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0004_alter_cronjob_is_modified_by_user.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0005_alter_cronjob_is_modified_by_user_help.py +0 -0
- {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
- {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
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0008_taskexecution_pickup_timeout_seconds.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0009_add_cronjob_app_label.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0010_taskexecution_id_alter_taskexecution_execution_id.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0011_taskexecutionstat_canceled_count.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/0012_alter_taskexecution_status.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/migrations/__init__.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/models.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/services/__init__.py +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/static/django_simpletask5/css/dashboard.css +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/static/django_simpletask5/js/select2_executor.js +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5.egg-info/dependency_links.txt +0 -0
- {django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
162
|
-
|
|
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`(默认复用缓存锁) |
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
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`(默认复用缓存锁) |
|
|
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
|
|
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
|
{django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/cronjob_registry.py
RENAMED
|
@@ -94,10 +94,10 @@ def register_builtin_cronjobs():
|
|
|
94
94
|
|
|
95
95
|
register_cronjob(
|
|
96
96
|
uid='simpletask_status_check',
|
|
97
|
-
display_name=_('
|
|
97
|
+
display_name=_('Pickup Timeout Detection'),
|
|
98
98
|
cron_expression='* * * * *',
|
|
99
99
|
executor_class='django_simpletask5.executors.status_check.StatusCheckExecutor',
|
|
100
|
-
description='Scan
|
|
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)
|
{django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/core/message_queue.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
+
snapshot = _model_to_dict(old)
|
|
27
47
|
except sender.DoesNotExist:
|
|
28
|
-
|
|
48
|
+
snapshot = None
|
|
29
49
|
else:
|
|
30
|
-
|
|
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 =
|
|
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:
|
{django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/bash_script.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
{django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/python_script.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
{django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/retry_timeout.py
RENAMED
|
@@ -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',
|
{django_simpletask5-0.2.6 → django_simpletask5-0.2.7}/django_simpletask5/executors/status_check.py
RENAMED
|
@@ -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
|
-
|
|
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': '
|
|
33
|
+
'reason': 'Pickup timeout',
|
|
24
34
|
})
|
|
25
35
|
exec_record.save(update_fields=['status', 'error_message', 'updated_at'])
|
|
26
36
|
count += 1
|