Files
xiaoxia-saas/packages/shared/celery_orphan_guard.py
T
saas-backend-bot df99305dd6
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 49s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m19s
AI Code Review / AI Code Review (pull_request) Failing after 2m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m54s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m11s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m23s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m39s
feat(worker): celery 队列隔离 + 孤儿任务消息作废 (#1714)
问题:素材转码与视频生成共用 celery 默认队列、worker 单进程消费,
20+ 转码积压会把用户生成任务堵 40 分钟以上;孤儿清理把任务标 failed
后 Redis 队列消息未作废,消息被重投导致 failed→running 非法转换,
worker 打印 ERROR 后继续产出半成品。

队列隔离:
- 新增 packages/shared/celery_queues.py:generation/transcode/celery
  三队列与 task_routes(generate_video→generation;ingest_asset/
  classify_asset/duplication→transcode),apply_queue_settings()
- worker 入口改双进程:generation worker 独占队列并内嵌 beat
  (prefetch=1, GENERATION_CONCURRENCY 默认 2),transcode worker
  消费 transcode,celery(并发=总-2,最小 1),任一退出则整体终止
- compose/部署脚本/ps1 同步新增 GENERATION_CONCURRENCY 与健康检查

消息作废:
- 新增 packages/shared/celery_orphan_guard.py:终态守卫
  ensure_task_claimable、Redis 队列消息物理清理(JSON 信封解析,
  按业务 id + celery headers.id 双匹配,未命中 rpush 保序)、
  revoke_and_purge(control.revoke + 物理清队列双保险)
- 入队点(生成/上传/分片/重试)send_task 后持久化 celery_task_id
  到 generation_tasks/ingest_jobs(新列,067 迁移,失败仅 warning)
- generate_video/ingest_asset 执行前校验 DB 状态:终态直接 discarded
  不进业务逻辑;mark_processing 返回 False(非法转换)安全中止
- 孤儿/超时清理标 failed 时同时 revoke + 清队列消息
- pending 超时阈值 15→45 分钟,与 running 孤儿(20min)区分

测试:新增 22 个单测(路由表/真实 Redis 消息清理/终态守卫/
非法转换中止/标 failed 后消息不重投/入队持久化),全量
14301 passed;067 迁移隔离 DDL 验证 upgrade/downgrade 通过。
2026-09-05 19:07:47 +08:00

223 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""孤儿任务消息撤销与执行前状态守卫(API / Worker 共享)。
#1714 / #1710 缺陷修复:超时清理/孤儿恢复把 DB 任务标记为 failed/cancelled
后,Redis 队列里对应的 Celery 消息仍然存在;worker 重启或重新拉取时该消息
被再次执行,状态机抛「非法状态转换: failed → running」,旧实现打印 ERROR 后
继续跑,最终产出半成品。
防御两道:
1. 清理任务标 failed 时,调用 revoke_and_purge() 撤销(celery revoke 广播,
通知在线 worker 丢弃)并直接扫描 Redis 队列移除消息体(worker 下线期间
队列中的消息 revoke 广播收不到,必须物理移除);
2. 任务真正开始业务逻辑前,调用 ensure_task_claimable() 校验 DB 状态,
非 pending 的消息直接丢弃(抛 StaleTaskDiscardedtask 捕获后安全返回,
不进入渲染/转码,不产出半成品)。
"""
from __future__ import annotations
import base64
import json
import logging
from collections.abc import Callable, Iterable
from typing import Any
logger = logging.getLogger(__name__)
class StaleTaskDiscarded(Exception):
"""任务消息已作废(DB 中任务已是终态),应安全中止、丢弃消息。"""
def __init__(self, task_id: str, status: str):
self.task_id = task_id
self.status = status
super().__init__(f"任务 {task_id} 已是终态 {status},丢弃重复/作废消息")
# 终态状态值集合:处于这些状态的任务消息一律不执行
TERMINAL_STATUS_VALUES = frozenset({"failed", "cancelled", "completed"})
def ensure_task_claimable(
task_id: str,
get_status: Callable[[str], str | None],
*,
task_label: str = "任务",
) -> str:
"""执行前守卫:任务必须处于可领取状态(pending)。
Args:
task_id: 业务任务 ID
get_status: 回调,返回 DB 中任务当前状态字符串;返回 None 表示任务不存在
task_label: 日志用任务类型名
Returns:
当前状态字符串(pending);任务不存在时返回空串(由调用方处理 not found)
Raises:
StaleTaskDiscarded: 任务已是终态(failed/cancelled/completed),消息必须丢弃
"""
status = get_status(task_id)
if status is None:
return ""
if status in TERMINAL_STATUS_VALUES:
logger.warning("[%s] task_id=%s 状态已为 %s,消息作废,丢弃不执行", task_label, task_id, status)
raise StaleTaskDiscarded(task_id, status)
return status
def _extract_business_ids(raw: bytes) -> tuple[str | None, str | None]:
"""从 Redis 中的 Celery 消息提取 (celery 消息 ID, 业务任务 ID)。
Redis transport 存储格式为 JSON 信封:
{"body": base64(json), "headers": {"id": <celery id>, "task": <name>, ...}, ...}
body 解码后 Celery task 协议为 [args, kwargs, embed]
generate_video / ingest_asset 均以 args=[业务任务ID] 投递。
无法解析时返回 (None, None)(保守保留该消息,绝不误删)。
"""
try:
envelope = json.loads(raw)
celery_id = None
headers = envelope.get("headers") or {}
if isinstance(headers, dict):
celery_id = headers.get("id")
body = envelope.get("body")
if not body:
return celery_id, None
decoded = base64.b64decode(body)
payload = json.loads(decoded)
# 两种 body 形态:
# 1. 标准 Celery task 消息:[args, kwargs, embed] 三元组 → 业务 ID 在 payload[0][0]
# 2. 裸 producer 发布:body 即 args 数组 ["biz-id"] → 业务 ID 在 payload[0]
args = None
if isinstance(payload, dict):
args = payload.get("args")
elif isinstance(payload, (list, tuple)) and payload:
first = payload[0]
if isinstance(first, (list, tuple)):
args = first # 三元组:[args, kwargs, embed]
else:
args = payload # body 本身就是 args
if isinstance(args, (list, tuple)) and args and args[0] is not None:
return celery_id, str(args[0])
return celery_id, None
except Exception:
return None, None
def purge_stale_messages_from_queues(
broker_url: str,
queue_names: Iterable[str],
business_task_ids: Iterable[str] = (),
celery_task_ids: Iterable[str] = (),
) -> int:
"""扫描 Redis 队列,移除作废任务的待消费消息。
同时按业务任务 ID(消息 args[0])和 celery 消息 IDheaders.id)匹配,
任一命中即移除。未命中或无法解析的消息原样保留(保持相对顺序)。
Returns:
实际移除的消息条数
"""
biz_ids = {bid for bid in business_task_ids if bid}
msg_ids = {mid for mid in celery_task_ids if mid}
if not biz_ids and not msg_ids:
return 0
try:
import redis
except ImportError:
logger.warning("redis-py 不可用,跳过队列消息清理")
return 0
try:
client = redis.Redis.from_url(broker_url)
client.ping()
except Exception as e:
logger.warning("连接 Redis 清理作废消息失败: %s", e)
return 0
removed_total = 0
try:
for queue in queue_names:
removed_total += _purge_one_queue(client, queue, biz_ids, msg_ids)
finally:
try:
client.close()
except Exception:
pass
if removed_total:
logger.info(
"从 Redis 队列移除 %d 条作废消息(biz=%s, celery=%s",
removed_total,
sorted(biz_ids),
sorted(msg_ids),
)
return removed_total
def _purge_one_queue(client: Any, queue_name: str, biz_ids: set[str], msg_ids: set[str]) -> int:
try:
raw_messages = client.lrange(queue_name, 0, -1)
except Exception as e:
logger.warning("读取队列 %s 失败: %s", queue_name, e)
return 0
if not raw_messages:
return 0
keep: list[bytes] = []
removed = 0
for raw in raw_messages:
celery_id, biz_id = _extract_business_ids(raw)
hit = (biz_id is not None and biz_id in biz_ids) or (celery_id is not None and celery_id in msg_ids)
if hit:
removed += 1
continue
keep.append(raw)
if removed:
try:
pipe = client.pipeline()
pipe.delete(queue_name)
if keep:
pipe.rpush(queue_name, *keep)
pipe.execute()
except Exception as e:
logger.warning("重写队列 %s 失败: %s", queue_name, e)
return 0
return removed
def revoke_and_purge(
celery_app: Any,
broker_url: str,
business_task_ids: Iterable[str] = (),
celery_task_ids: Iterable[str] = (),
*,
queue_names: Iterable[str] = ("generation", "transcode", "celery"),
) -> int:
"""撤销作废任务:revoke 广播(在线 worker+ 物理清理 Redis 队列消息。
Args:
celery_app: Celery app 实例(worker 端 worker_app.celery_app.celery_app
broker_url: Redis broker URL
business_task_ids: 业务任务 IDgeneration_tasks.id / ingest_jobs.id
celery_task_ids: 入队时记录的 celery 消息 ID
queue_names: 需要扫描清理的队列名
Returns:
从队列中实际移除的消息条数
"""
for tid in celery_task_ids:
if not tid:
continue
try:
celery_app.control.revoke(tid)
except Exception as e:
logger.warning("revoke celery 消息 %s 失败: %s", tid, e)
return purge_stale_messages_from_queues(
broker_url, queue_names, business_task_ids=business_task_ids, celery_task_ids=celery_task_ids
)