django-simpletask5 0.2.6__tar.gz → 0.2.8__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 (65) hide show
  1. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/PKG-INFO +63 -14
  2. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/README.md +61 -12
  3. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/core/cronjob_registry.py +2 -2
  4. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/core/defaults.py +0 -3
  5. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/core/message_queue.py +38 -1
  6. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/core/publisher.py +12 -4
  7. django_simpletask5-0.2.8/django_simpletask5/core/reclaim.py +189 -0
  8. django_simpletask5-0.2.8/django_simpletask5/core/security.py +31 -0
  9. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/core/signals.py +25 -4
  10. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/dashboards.py +0 -3
  11. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/executors/bash_script.py +3 -5
  12. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/executors/python_script.py +3 -6
  13. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/executors/retry_timeout.py +0 -1
  14. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/executors/status_check.py +12 -2
  15. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/management/commands/django_simpletask_crontab.py +19 -2
  16. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/management/commands/django_simpletask_executor.py +70 -103
  17. django_simpletask5-0.2.8/django_simpletask5/management/commands/django_simpletask_queues.py +71 -0
  18. django_simpletask5-0.2.8/django_simpletask5/migrations/0013_alter_taskexecution_task_id.py +24 -0
  19. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/models.py +50 -9
  20. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/services/archive.py +0 -1
  21. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5.egg-info/PKG-INFO +63 -14
  22. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5.egg-info/SOURCES.txt +4 -0
  23. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5.egg-info/requires.txt +1 -1
  24. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/pyproject.toml +2 -2
  25. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/LICENSE +0 -0
  26. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/__init__.py +0 -0
  27. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/admin.py +0 -0
  28. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/apps.py +0 -0
  29. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/core/__init__.py +0 -0
  30. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/core/executor_scanner.py +0 -0
  31. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/core/lock.py +0 -0
  32. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/core/logging_utils.py +0 -0
  33. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/core/worker_registry.py +0 -0
  34. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/executors/__init__.py +0 -0
  35. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/executors/archive.py +0 -0
  36. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/executors/base.py +0 -0
  37. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/executors/exceptions.py +0 -0
  38. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/executors/loader.py +0 -0
  39. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/executors/ping_pong.py +0 -0
  40. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/executors/simple_request.py +0 -0
  41. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  42. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.po +0 -0
  43. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/management/__init__.py +0 -0
  44. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/management/commands/__init__.py +0 -0
  45. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/management/commands/django_simpletask_archive.py +0 -0
  46. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/management/commands/django_simpletask_sync_cronjobs.py +0 -0
  47. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/0001_initial.py +0 -0
  48. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/0002_taskexecution_done_tasks_count_and_more.py +0 -0
  49. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/0003_alter_cronjob_options_remove_cronjob_name_and_more.py +0 -0
  50. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/0004_alter_cronjob_is_modified_by_user.py +0 -0
  51. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/0005_alter_cronjob_is_modified_by_user_help.py +0 -0
  52. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/0006_alter_cronjob_options_alter_taskexecution_created_at_and_more.py +0 -0
  53. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/0007_rename_task_model_to_executor_class_in_stat.py +0 -0
  54. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/0008_taskexecution_pickup_timeout_seconds.py +0 -0
  55. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/0009_add_cronjob_app_label.py +0 -0
  56. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/0010_taskexecution_id_alter_taskexecution_execution_id.py +0 -0
  57. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/0011_taskexecutionstat_canceled_count.py +0 -0
  58. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/0012_alter_taskexecution_status.py +0 -0
  59. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/migrations/__init__.py +0 -0
  60. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/services/__init__.py +0 -0
  61. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/static/django_simpletask5/css/dashboard.css +0 -0
  62. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5/static/django_simpletask5/js/select2_executor.js +0 -0
  63. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5.egg-info/dependency_links.txt +0 -0
  64. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/django_simpletask5.egg-info/top_level.txt +0 -0
  65. {django_simpletask5-0.2.6 → django_simpletask5-0.2.8}/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.8
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 消费** |
@@ -276,7 +278,7 @@ class OrderTask(Task):
276
278
  ```
277
279
 
278
280
  要点:
279
- - 主键为 Django 默认自增 `id`;内置 `task_id`(UUID,`unique=True`)作为业务唯一标识,**不是**主键
281
+ - 主键为 Django 默认自增 `id`。`Task` 不再内置 `task_id` 字段,业务标识由业务模型自行定义(自定义主键或自然键均可)
280
282
  - 保存 / 删除 / 批量创建自动触发事件,详见「事件处理机制」
281
283
  - 事件上下文、update 字段判定、delete 拦截等行为的完整说明见对应章节
282
284
 
@@ -352,7 +354,7 @@ OrderTask.objects.create(order_id='ORD-001', customer_name='Test', amount=Decima
352
354
  | `update` | 字段值发生变化后保存 | `task_id`、`trigger_event`、`data`、`old_data`、`new_data`、`changed_fields` |
353
355
  | `delete` | 调用 `instance.delete()` | `task_id`、`trigger_event`、`data` |
354
356
 
355
- 执行器通过 `execution.get_context_dict()` 获取以上上下文。
357
+ 执行器通过 `execution.get_context_dict()` 获取以上上下文。其中 `task_id` 是业务对象主键的字符串表示(如 `"42"`、UUID 字符串或自定义键);业务对象反查见「模型与生命周期」。
356
358
 
357
359
  #### update 事件的字段变更判定
358
360
 
@@ -391,6 +393,8 @@ class OrderDeleteExecutor(BaseExecutor):
391
393
 
392
394
  这样实现删除操作与外部系统清理的解耦,且支持失败重试。
393
395
 
396
+ `confirm_delete()` 与 `execution.get_task()` 都会通过 `TaskExecution.task_id`(业务主键字符串)反查业务对象,反查逻辑由业务模型上的 `get_task_instance()` 提供(见「模型与生命周期」)。
397
+
394
398
  ### 事件 → 执行器映射
395
399
 
396
400
  每个事件消息最终要由一个执行器处理。`executor_class` 声明「事件名 → 执行器类路径」的分派表,可以是字符串(所有事件共用同一个执行器)或字典(按事件分派):
@@ -566,10 +570,38 @@ class OrderCreateExecutor(BaseExecutor):
566
570
 
567
571
  所有业务任务模型继承 `Task`:
568
572
 
569
- - 内置 `task_id`(UUID,`unique=True`);主键为 Django 默认自增 `id`,无需自定义
573
+ - 主键为 Django 默认自增 `id`,无需自定义;**不内置** `task_id` 字段,业务标识由业务模型按需定义
570
574
  - 提供 `trigger(event, extra_context)`、`skip_publish()`、`get_executor_class_path(event)`、`get_queue_name(event)` 等方法
571
575
  - 保存、删除、批量创建自动触发事件(见「事件处理机制」)
572
576
 
577
+ #### 业务对象反查:`get_task_instance()`
578
+
579
+ `TaskExecution.task_id` 存的是业务对象主键的字符串表示(`CharField`)。需要反查业务对象时(`execution.get_task()` / `execution.confirm_delete()`),框架先通过 `task_model` 解析出模型类,再调用该类的 `get_task_instance(task_id)` 完成查询。
580
+
581
+ 默认实现在 `Task` 基类中按主键 `pk` 查询,并通过内置的 `_coerce_task_pk()` 兼容常见主键类型:
582
+
583
+ - `AutoField` / `BigAutoField` / `SmallAutoField` — 字符串转为 `int`
584
+ - `UUIDField` — 字符串转为 `uuid.UUID`
585
+ - `CharField` / `TextField` 等字符串主键 — 原样使用
586
+
587
+ ```python
588
+ task = execution.get_task() # 返回业务对象(反查不到返回 None)
589
+ execution.confirm_delete() # 反查并真实删除业务对象
590
+ ```
591
+
592
+ 业务模型使用自定义主键或非主键自然键时,**重载 `get_task_instance()`** 即可,框架会自动改走你的实现:
593
+
594
+ ```python
595
+ class OrderTask(Task):
596
+ code = models.CharField(max_length=64, unique=True) # 非主键自然键
597
+
598
+ @classmethod
599
+ def get_task_instance(cls, task_id):
600
+ if task_id is None:
601
+ return None
602
+ return cls.objects.filter(code=task_id).first()
603
+ ```
604
+
573
605
  ### TaskExecution 状态机
574
606
 
575
607
  每条事件消息对应一条 `TaskExecution` 记录,状态流转如下:
@@ -686,6 +718,8 @@ python3 -m pytest django_simpletask5_example/tests
686
718
 
687
719
  测试套件覆盖 Redis、DB、MQ 连接中断后的自动恢复场景,详见 `django_simpletask5_example/tests/test_resilience_recovery.py`。
688
720
 
721
+ **测试输出约定**:单元测试中对预期会触发的错误(如无效 UUID 校验、毒消息丢弃、消费者断连、执行器加载失败等)所输出的错误日志,必须在测试内屏蔽,避免污染测试输出。统一使用 `django_simpletask5_example.tests.suppress_expected_errors` 上下文管理器(整体禁用日志)或 `patch.object(..., 'logger')`(断言同时校验日志)进行屏蔽。
722
+
689
723
  ## 架构
690
724
 
691
725
  ```
@@ -698,6 +732,21 @@ Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保
698
732
 
699
733
  ## Releases
700
734
 
735
+ ### 0.2.8
736
+
737
+ - **移除 `Task` 冗余的 `task_id` 字段** — `TaskExecution.task_id` 由 UUID 改为 `CharField(max_length=255)`,直接用于存放业务对象主键字符串(`str(instance.pk)`),不再维护一份与业务主键重复的 UUID。消息上下文中的 `task_id` 字段名不变,值语义从 UUID 变为业务主键字符串(如 `"42"`)
738
+ - **业务对象反查下沉到模型层** — 新增 `Task.get_task_instance(task_id)` 类方法作为默认反查实现(按 `pk` 查询,内置 `_coerce_task_pk()` 兼容 `int` / `UUID` / 字符串主键);`TaskExecution.get_task()` / `confirm_delete()` 改为解析 `task_model` 后委托给该方法。业务模型使用自定义主键或自然键时重载 `get_task_instance()` 即可
739
+ - **新增迁移** — `django_simpletask5.0013_alter_taskexecution_task_id`(字段改 CharField)、`django_simpletask5_example.0003_remove_ordertask_task_id`
740
+ - **破坏性变更**: 依赖 `TaskExecution.task_id` 为 UUID 类型的外部消费方需适配为业务主键字符串;业务 `Task` 子类若曾使用内置 `task_id` 字段需移除
741
+
742
+ ### 0.2.7
743
+
744
+ - **运行中任务回收机制重构** — 取消原先按 `expire_time` 将 running 直接判为超时的语义;`StatusCheckExecutor` 仅兜底回收长期未被 Worker 获取的 pending。新增 `core/reclaim`,由 crontab 每 ~5 秒基于执行锁探测运行中任务:锁已过期(执行器异常退出)即快速重投或直接置失败,降低孤儿任务滞留时间
745
+ - **重试不再搁浅任务** — 失败重试先落库 `retry` 再发布重试消息,发布失败则 requeue 当前消息,杜绝任务永久停在无人处理的 `retry` 状态;KombuQueue 依据 AMQP `x-death` 上限丢弃毒消息,避免无限 requeue 造成的队头阻塞与空转
746
+ - **消费者重连提示** — Worker 断连重连成功后输出 `Consumer connection restored` 恢复日志,便于运维确认消费通道已恢复
747
+ - **安全与健壮性加固** — broker 账号/密码按百分号编码、脚本白名单按路径段边界匹配、signals 快照按实例隔离,覆盖相关回归测试
748
+ - **新增测试约定** — 单元测试中对预期触发(如无效 UUID 校验、毒消息丢弃、消费者断连、执行器加载失败等)产生的错误日志必须屏蔽输出,统一使用 `django_simpletask5_example.tests.suppress_expected_errors` 或替换 logger,保证测试输出干净可读;本次同步清理全部此类预期错误日志
749
+
701
750
  ### 0.2.6
702
751
 
703
752
  - **新增 `canceled` 状态** — 取消已发布但未执行的任务改为独立的终态 `canceled`(原复用 `failed`),Worker 消费到已取消消息时直接跳过;Admin 取消操作、归档/清理/统计(新增 `canceled_count`)、Dashboard 状态图均纳入该状态,取消与执行失败可清晰区分
@@ -714,7 +763,7 @@ Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保
714
763
 
715
764
  - **破坏性变更**: `TaskExecution` 主键由 `execution_id`(UUID)改为默认自增 `id`,`execution_id` 变为 `unique=True` 的唯一标识字段。已有数据库需要执行迁移 `0010_taskexecution_id_alter_taskexecution_execution_id`
716
765
  - **定期存活日志** — Worker 与 Crontab 主循环在长时间无消息/无任务时按 `DJANGO_SIMPLETASK_LOG_INTERVAL`(默认 300 秒)定期输出存活日志,并统一带时间戳格式,便于运维确认进程未死循环
717
- - **MQ 日志降噪** — MQ 消费/发布异常及 Redis 连接失败、锁续期失败改为 `error`/`warning` 级别且无堆栈(默认按间隔节流),避免刷屏;提供 `--debug` 时输出完整堆栈
766
+ - **MQ 日志降噪** — MQ 消费/发布异常及 Redis 连接失败改为 `error`/`warning` 级别且无堆栈(默认按间隔节流),避免刷屏;提供 `--debug` 时输出完整堆栈。注:此处所指的"锁续期失败"是 0.2.4 版本引入的自定义锁续期机制,自 globallock>=0.2.0 起该机制已**移除**,改由 globallock 内置看门狗在 lockman/lock 作用域内自动续期,无需(也无从)单独记录续期失败日志
718
767
  - **新增配置** — `DJANGO_SIMPLETASK_LOG_INTERVAL` 控制死循环存活日志与异常节流日志的输出间隔(秒)
719
768
 
720
769
  ### 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 消费** |
@@ -236,7 +238,7 @@ class OrderTask(Task):
236
238
  ```
237
239
 
238
240
  要点:
239
- - 主键为 Django 默认自增 `id`;内置 `task_id`(UUID,`unique=True`)作为业务唯一标识,**不是**主键
241
+ - 主键为 Django 默认自增 `id`。`Task` 不再内置 `task_id` 字段,业务标识由业务模型自行定义(自定义主键或自然键均可)
240
242
  - 保存 / 删除 / 批量创建自动触发事件,详见「事件处理机制」
241
243
  - 事件上下文、update 字段判定、delete 拦截等行为的完整说明见对应章节
242
244
 
@@ -312,7 +314,7 @@ OrderTask.objects.create(order_id='ORD-001', customer_name='Test', amount=Decima
312
314
  | `update` | 字段值发生变化后保存 | `task_id`、`trigger_event`、`data`、`old_data`、`new_data`、`changed_fields` |
313
315
  | `delete` | 调用 `instance.delete()` | `task_id`、`trigger_event`、`data` |
314
316
 
315
- 执行器通过 `execution.get_context_dict()` 获取以上上下文。
317
+ 执行器通过 `execution.get_context_dict()` 获取以上上下文。其中 `task_id` 是业务对象主键的字符串表示(如 `"42"`、UUID 字符串或自定义键);业务对象反查见「模型与生命周期」。
316
318
 
317
319
  #### update 事件的字段变更判定
318
320
 
@@ -351,6 +353,8 @@ class OrderDeleteExecutor(BaseExecutor):
351
353
 
352
354
  这样实现删除操作与外部系统清理的解耦,且支持失败重试。
353
355
 
356
+ `confirm_delete()` 与 `execution.get_task()` 都会通过 `TaskExecution.task_id`(业务主键字符串)反查业务对象,反查逻辑由业务模型上的 `get_task_instance()` 提供(见「模型与生命周期」)。
357
+
354
358
  ### 事件 → 执行器映射
355
359
 
356
360
  每个事件消息最终要由一个执行器处理。`executor_class` 声明「事件名 → 执行器类路径」的分派表,可以是字符串(所有事件共用同一个执行器)或字典(按事件分派):
@@ -526,10 +530,38 @@ class OrderCreateExecutor(BaseExecutor):
526
530
 
527
531
  所有业务任务模型继承 `Task`:
528
532
 
529
- - 内置 `task_id`(UUID,`unique=True`);主键为 Django 默认自增 `id`,无需自定义
533
+ - 主键为 Django 默认自增 `id`,无需自定义;**不内置** `task_id` 字段,业务标识由业务模型按需定义
530
534
  - 提供 `trigger(event, extra_context)`、`skip_publish()`、`get_executor_class_path(event)`、`get_queue_name(event)` 等方法
531
535
  - 保存、删除、批量创建自动触发事件(见「事件处理机制」)
532
536
 
537
+ #### 业务对象反查:`get_task_instance()`
538
+
539
+ `TaskExecution.task_id` 存的是业务对象主键的字符串表示(`CharField`)。需要反查业务对象时(`execution.get_task()` / `execution.confirm_delete()`),框架先通过 `task_model` 解析出模型类,再调用该类的 `get_task_instance(task_id)` 完成查询。
540
+
541
+ 默认实现在 `Task` 基类中按主键 `pk` 查询,并通过内置的 `_coerce_task_pk()` 兼容常见主键类型:
542
+
543
+ - `AutoField` / `BigAutoField` / `SmallAutoField` — 字符串转为 `int`
544
+ - `UUIDField` — 字符串转为 `uuid.UUID`
545
+ - `CharField` / `TextField` 等字符串主键 — 原样使用
546
+
547
+ ```python
548
+ task = execution.get_task() # 返回业务对象(反查不到返回 None)
549
+ execution.confirm_delete() # 反查并真实删除业务对象
550
+ ```
551
+
552
+ 业务模型使用自定义主键或非主键自然键时,**重载 `get_task_instance()`** 即可,框架会自动改走你的实现:
553
+
554
+ ```python
555
+ class OrderTask(Task):
556
+ code = models.CharField(max_length=64, unique=True) # 非主键自然键
557
+
558
+ @classmethod
559
+ def get_task_instance(cls, task_id):
560
+ if task_id is None:
561
+ return None
562
+ return cls.objects.filter(code=task_id).first()
563
+ ```
564
+
533
565
  ### TaskExecution 状态机
534
566
 
535
567
  每条事件消息对应一条 `TaskExecution` 记录,状态流转如下:
@@ -646,6 +678,8 @@ python3 -m pytest django_simpletask5_example/tests
646
678
 
647
679
  测试套件覆盖 Redis、DB、MQ 连接中断后的自动恢复场景,详见 `django_simpletask5_example/tests/test_resilience_recovery.py`。
648
680
 
681
+ **测试输出约定**:单元测试中对预期会触发的错误(如无效 UUID 校验、毒消息丢弃、消费者断连、执行器加载失败等)所输出的错误日志,必须在测试内屏蔽,避免污染测试输出。统一使用 `django_simpletask5_example.tests.suppress_expected_errors` 上下文管理器(整体禁用日志)或 `patch.object(..., 'logger')`(断言同时校验日志)进行屏蔽。
682
+
649
683
  ## 架构
650
684
 
651
685
  ```
@@ -658,6 +692,21 @@ Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保
658
692
 
659
693
  ## Releases
660
694
 
695
+ ### 0.2.8
696
+
697
+ - **移除 `Task` 冗余的 `task_id` 字段** — `TaskExecution.task_id` 由 UUID 改为 `CharField(max_length=255)`,直接用于存放业务对象主键字符串(`str(instance.pk)`),不再维护一份与业务主键重复的 UUID。消息上下文中的 `task_id` 字段名不变,值语义从 UUID 变为业务主键字符串(如 `"42"`)
698
+ - **业务对象反查下沉到模型层** — 新增 `Task.get_task_instance(task_id)` 类方法作为默认反查实现(按 `pk` 查询,内置 `_coerce_task_pk()` 兼容 `int` / `UUID` / 字符串主键);`TaskExecution.get_task()` / `confirm_delete()` 改为解析 `task_model` 后委托给该方法。业务模型使用自定义主键或自然键时重载 `get_task_instance()` 即可
699
+ - **新增迁移** — `django_simpletask5.0013_alter_taskexecution_task_id`(字段改 CharField)、`django_simpletask5_example.0003_remove_ordertask_task_id`
700
+ - **破坏性变更**: 依赖 `TaskExecution.task_id` 为 UUID 类型的外部消费方需适配为业务主键字符串;业务 `Task` 子类若曾使用内置 `task_id` 字段需移除
701
+
702
+ ### 0.2.7
703
+
704
+ - **运行中任务回收机制重构** — 取消原先按 `expire_time` 将 running 直接判为超时的语义;`StatusCheckExecutor` 仅兜底回收长期未被 Worker 获取的 pending。新增 `core/reclaim`,由 crontab 每 ~5 秒基于执行锁探测运行中任务:锁已过期(执行器异常退出)即快速重投或直接置失败,降低孤儿任务滞留时间
705
+ - **重试不再搁浅任务** — 失败重试先落库 `retry` 再发布重试消息,发布失败则 requeue 当前消息,杜绝任务永久停在无人处理的 `retry` 状态;KombuQueue 依据 AMQP `x-death` 上限丢弃毒消息,避免无限 requeue 造成的队头阻塞与空转
706
+ - **消费者重连提示** — Worker 断连重连成功后输出 `Consumer connection restored` 恢复日志,便于运维确认消费通道已恢复
707
+ - **安全与健壮性加固** — broker 账号/密码按百分号编码、脚本白名单按路径段边界匹配、signals 快照按实例隔离,覆盖相关回归测试
708
+ - **新增测试约定** — 单元测试中对预期触发(如无效 UUID 校验、毒消息丢弃、消费者断连、执行器加载失败等)产生的错误日志必须屏蔽输出,统一使用 `django_simpletask5_example.tests.suppress_expected_errors` 或替换 logger,保证测试输出干净可读;本次同步清理全部此类预期错误日志
709
+
661
710
  ### 0.2.6
662
711
 
663
712
  - **新增 `canceled` 状态** — 取消已发布但未执行的任务改为独立的终态 `canceled`(原复用 `failed`),Worker 消费到已取消消息时直接跳过;Admin 取消操作、归档/清理/统计(新增 `canceled_count`)、Dashboard 状态图均纳入该状态,取消与执行失败可清晰区分
@@ -674,7 +723,7 @@ Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保
674
723
 
675
724
  - **破坏性变更**: `TaskExecution` 主键由 `execution_id`(UUID)改为默认自增 `id`,`execution_id` 变为 `unique=True` 的唯一标识字段。已有数据库需要执行迁移 `0010_taskexecution_id_alter_taskexecution_execution_id`
676
725
  - **定期存活日志** — Worker 与 Crontab 主循环在长时间无消息/无任务时按 `DJANGO_SIMPLETASK_LOG_INTERVAL`(默认 300 秒)定期输出存活日志,并统一带时间戳格式,便于运维确认进程未死循环
677
- - **MQ 日志降噪** — MQ 消费/发布异常及 Redis 连接失败、锁续期失败改为 `error`/`warning` 级别且无堆栈(默认按间隔节流),避免刷屏;提供 `--debug` 时输出完整堆栈
726
+ - **MQ 日志降噪** — MQ 消费/发布异常及 Redis 连接失败改为 `error`/`warning` 级别且无堆栈(默认按间隔节流),避免刷屏;提供 `--debug` 时输出完整堆栈。注:此处所指的"锁续期失败"是 0.2.4 版本引入的自定义锁续期机制,自 globallock>=0.2.0 起该机制已**移除**,改由 globallock 内置看门狗在 lockman/lock 作用域内自动续期,无需(也无从)单独记录续期失败日志
678
727
  - **新增配置** — `DJANGO_SIMPLETASK_LOG_INTERVAL` 控制死循环存活日志与异常节流日志的输出间隔(秒)
679
728
 
680
729
  ### 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
 
@@ -39,7 +39,7 @@ def create_and_publish(task, trigger_event, extra_context=None):
39
39
  queue_name = task.get_queue_name(event=trigger_event)
40
40
 
41
41
  context = {
42
- 'task_id': str(task.task_id),
42
+ 'task_id': _task_identity(task),
43
43
  'trigger_event': trigger_event,
44
44
  'data': _model_to_dict(task),
45
45
  }
@@ -49,7 +49,7 @@ def create_and_publish(task, trigger_event, extra_context=None):
49
49
  serialized_context = _serialize_context(context)
50
50
 
51
51
  execution = TaskExecution(
52
- task_id=task.task_id,
52
+ task_id=_task_identity(task),
53
53
  task_model=task._meta.label,
54
54
  trigger_event=trigger_event,
55
55
  executor_class=executor_class_path,
@@ -74,7 +74,7 @@ def bulk_create_and_publish(tasks, trigger_event, extra_context=None):
74
74
  queue_name = task.get_queue_name(event=trigger_event)
75
75
 
76
76
  context = {
77
- 'task_id': str(task.task_id),
77
+ 'task_id': _task_identity(task),
78
78
  'trigger_event': trigger_event,
79
79
  'data': _model_to_dict(task),
80
80
  }
@@ -84,7 +84,7 @@ def bulk_create_and_publish(tasks, trigger_event, extra_context=None):
84
84
  serialized_context = _serialize_context(context)
85
85
 
86
86
  execution = TaskExecution(
87
- task_id=task.task_id,
87
+ task_id=_task_identity(task),
88
88
  task_model=task._meta.label,
89
89
  trigger_event=trigger_event,
90
90
  executor_class=executor_class_path,
@@ -165,6 +165,14 @@ def _serialize_value(value):
165
165
  return str(value)
166
166
 
167
167
 
168
+ def _task_identity(instance):
169
+ """返回业务对象唯一标识字符串,用作 TaskExecution.task_id。
170
+
171
+ 业务实例唯一性由其真实主键承载,而非额外的 task_id UUID。
172
+ """
173
+ return str(instance.pk) if instance.pk is not None else None
174
+
175
+
168
176
  def _model_to_dict(instance):
169
177
  data = {}
170
178
  for field in instance._meta.fields: