259 lines
10 KiB
Python
259 lines
10 KiB
Python
"""持久任务队列(交付报告 P0-3)。
|
||||
|
|
|
|||
|
|
用 SQLite 表实现可靠队列语义,替代纯内存线程调度:
|
|||
|
|
- 租约 lease:worker 领取任务时写入 lease_until,超时未续约视为 worker 死亡,任务可被其他 worker 重新领取;
|
|||
|
|
- 重试退避:失败后按 attempt 计算退避(复用 hermes_core.jittered_backoff),next_run_at 之前不可领取;
|
|||
|
|
- 死信队列:超过最大重试次数进入 dead 状态,运营可查看和手动重放;
|
|||
|
|
- 心跳:worker 定期刷新 lease_until;服务重启时孤儿任务自动回收。
|
|||
|
|
|
|||
|
|
与现有 _ACTIVE_RUNS 内存调度共存:队列表是事实源,内存线程只是执行器。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
from datetime import datetime, timedelta, timezone
|
|||
|
|
|
|||
|
|
from sqlmodel import Session, select, update
|
|||
|
|
|
|||
|
|
from .hermes_core import jittered_backoff
|
|||
|
|
from .models import now
|
|||
|
|
|
|||
|
|
logger = logging.getLogger("yawen.queue")
|
|||
|
|
|
|||
|
|
LEASE_SECONDS = 300 # 租约时长:5 分钟无心跳即视为 worker 死亡
|
|||
|
|
MAX_ATTEMPTS = 3 # 含首次执行在内最多尝试次数
|
|||
|
|
QUEUE_TABLE = "taskqueue"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _utcnow() -> datetime:
|
|||
|
|
"""统一使用无时区本地时间(与 models.now() 一致,队列内部时间戳互相比较)。"""
|
|||
|
|
return datetime.now()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse(value: str) -> datetime | None:
|
|||
|
|
try:
|
|||
|
|
parsed = datetime.fromisoformat(value)
|
|||
|
|
except (ValueError, TypeError):
|
|||
|
|
return None
|
|||
|
|
if parsed.tzinfo is not None:
|
|||
|
|
parsed = parsed.replace(tzinfo=None)
|
|||
|
|
return parsed
|
|||
|
|
|
|||
|
|
|
|||
|
|
def enqueue(session: Session, queue_model, *, task_key: str, payload: dict,
|
|||
|
|
update_existing: bool = False) -> int:
|
|||
|
|
"""入队(幂等:同 task_key 的活跃任务不重复入队)。返回队列行 id。
|
|||
|
|
|
|||
|
|
update_existing 用于 parse/compose/redo 这类确定性 campaign 任务:如果任务
|
|||
|
|
仍在 pending,用户刚修改了参数,则刷新 payload;running 中的任务不能被改。
|
|||
|
|
"""
|
|||
|
|
existing = session.exec(select(queue_model).where(
|
|||
|
|
queue_model.task_key == task_key,
|
|||
|
|
queue_model.status.in_(["pending", "leased", "running"]),
|
|||
|
|
)).first()
|
|||
|
|
if existing:
|
|||
|
|
if update_existing and existing.status == "pending":
|
|||
|
|
existing.payload = payload
|
|||
|
|
existing.error = ""
|
|||
|
|
existing.next_run_at = now()
|
|||
|
|
existing.updated_at = now()
|
|||
|
|
session.add(existing)
|
|||
|
|
session.commit()
|
|||
|
|
return existing.id
|
|||
|
|
row = queue_model(task_key=task_key, payload=payload, status="pending",
|
|||
|
|
attempt=0, max_attempts=MAX_ATTEMPTS, next_run_at=now())
|
|||
|
|
session.add(row)
|
|||
|
|
session.commit()
|
|||
|
|
session.refresh(row)
|
|||
|
|
return row.id
|
|||
|
|
|
|||
|
|
|
|||
|
|
def lease_next(session: Session, queue_model, worker_id: str,
|
|||
|
|
task_key_prefix: str = "") -> object | None:
|
|||
|
|
"""领取一个到期任务,并以条件 UPDATE 原子认领。
|
|||
|
|
|
|||
|
|
旧实现先读取候选行、再提交修改;两个 worker 在读取与提交之间会
|
|||
|
|
同时看到同一行,导致重复执行。这里仍保留轻量候选查询以维持优先级,
|
|||
|
|
但真正认领通过 ``UPDATE ... WHERE`` 校验状态和租约,失败时重试下一个
|
|||
|
|
候选。SQLite 的单条 UPDATE 在写入时持有数据库写锁,因此不会产生双重
|
|||
|
|
认领。
|
|||
|
|
"""
|
|||
|
|
for _ in range(8):
|
|||
|
|
current = now()
|
|||
|
|
# 仅取少量候选,避免队列很大时把全部 payload 读进内存。时间戳使用
|
|||
|
|
# 固定的 YYYY-MM-DD HH:MM:SS 格式,SQLite 文本比较与 _parse 语义一致。
|
|||
|
|
query = select(queue_model).where(
|
|||
|
|
queue_model.status.in_(["pending", "running"]),
|
|||
|
|
)
|
|||
|
|
# Worker 可按 task_key 前缀领取自己的任务类型。DSH 内容生产执行器
|
|||
|
|
# 只处理 campaign-run:*,避免误领取训练导图等其他后台任务。
|
|||
|
|
if task_key_prefix:
|
|||
|
|
query = query.where(
|
|||
|
|
queue_model.task_key.like(task_key_prefix.rstrip("%") + "%")
|
|||
|
|
)
|
|||
|
|
rows = session.exec(query.order_by(queue_model.id).limit(16)).all()
|
|||
|
|
candidate = None
|
|||
|
|
for row in rows:
|
|||
|
|
if row.status == "pending":
|
|||
|
|
next_at = _parse(row.next_run_at)
|
|||
|
|
if next_at and next_at > _parse(current):
|
|||
|
|
continue
|
|||
|
|
else: # running:租约超时才能抢(worker 死亡的孤儿任务)
|
|||
|
|
lease_until = _parse(row.lease_until or "")
|
|||
|
|
if lease_until and lease_until > _parse(current):
|
|||
|
|
continue
|
|||
|
|
candidate = row
|
|||
|
|
break
|
|||
|
|
if candidate is None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
deadline = _lease_deadline()
|
|||
|
|
# 把候选的当前状态/租约再次放入 WHERE,防止另一个 worker 已经抢走。
|
|||
|
|
# 对 pending 同时检查 next_run_at,避免失败重试被提前领取。
|
|||
|
|
# Match the exact timestamp observed in the candidate row instead of
|
|||
|
|
# comparing strings in SQL. Tests and older rows may include fractional
|
|||
|
|
# seconds while ``now()`` uses second precision; Python's _parse above
|
|||
|
|
# handles both formats correctly.
|
|||
|
|
pending_due = (
|
|||
|
|
(queue_model.status == "pending")
|
|||
|
|
& (queue_model.next_run_at == candidate.next_run_at)
|
|||
|
|
)
|
|||
|
|
orphan_due = (
|
|||
|
|
(queue_model.status == "running")
|
|||
|
|
& (queue_model.lease_until == candidate.lease_until)
|
|||
|
|
)
|
|||
|
|
statement = update(queue_model).where(
|
|||
|
|
queue_model.id == candidate.id,
|
|||
|
|
pending_due | orphan_due,
|
|||
|
|
).values(
|
|||
|
|
status="running",
|
|||
|
|
attempt=(queue_model.attempt + 1),
|
|||
|
|
worker_id=worker_id,
|
|||
|
|
lease_until=deadline,
|
|||
|
|
updated_at=current,
|
|||
|
|
)
|
|||
|
|
result = session.exec(statement)
|
|||
|
|
# SQLAlchemy's rowcount is available for SQLite UPDATE statements. A
|
|||
|
|
# zero count means another worker won the race; rollback and retry.
|
|||
|
|
if result.rowcount != 1:
|
|||
|
|
session.rollback()
|
|||
|
|
continue
|
|||
|
|
session.commit()
|
|||
|
|
claimed = session.get(queue_model, candidate.id)
|
|||
|
|
if claimed is not None:
|
|||
|
|
return claimed
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _lease_deadline() -> str:
|
|||
|
|
return (_utcnow() + timedelta(seconds=LEASE_SECONDS)).isoformat()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def heartbeat(session: Session, queue_model, task_id: int, worker_id: str) -> bool:
|
|||
|
|
"""worker 心跳续租;任务已不在本 worker 名下时返回 False。"""
|
|||
|
|
row = session.get(queue_model, task_id)
|
|||
|
|
if not row or row.status != "running" or row.worker_id != worker_id:
|
|||
|
|
return False
|
|||
|
|
row.lease_until = _lease_deadline()
|
|||
|
|
row.updated_at = now()
|
|||
|
|
session.add(row)
|
|||
|
|
session.commit()
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def complete(session: Session, queue_model, task_id: int, worker_id: str) -> bool:
|
|||
|
|
row = session.get(queue_model, task_id)
|
|||
|
|
if not row or row.status != "running" or row.worker_id != worker_id:
|
|||
|
|
return False
|
|||
|
|
row.status = "done"
|
|||
|
|
row.error = ""
|
|||
|
|
row.lease_until = ""
|
|||
|
|
row.updated_at = now()
|
|||
|
|
session.add(row)
|
|||
|
|
session.commit()
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def fail(session: Session, queue_model, task_id: int, worker_id: str,
|
|||
|
|
error: str) -> str:
|
|||
|
|
"""任务失败:可重试则退避后重新排队;超过次数进死信。返回最终状态。"""
|
|||
|
|
row = session.get(queue_model, task_id)
|
|||
|
|
if not row or row.worker_id != worker_id:
|
|||
|
|
return "unknown"
|
|||
|
|
row.error = str(error)[:500]
|
|||
|
|
if (row.attempt or 0) >= (row.max_attempts or MAX_ATTEMPTS):
|
|||
|
|
row.status = "dead"
|
|||
|
|
logger.error("任务 %s 进入死信队列:%s", row.task_key, row.error[:120])
|
|||
|
|
else:
|
|||
|
|
delay = min(24.0, jittered_backoff(row.attempt))
|
|||
|
|
retry_at = (_utcnow() + timedelta(seconds=delay)).isoformat()
|
|||
|
|
row.status = "pending"
|
|||
|
|
row.next_run_at = retry_at
|
|||
|
|
row.lease_until = ""
|
|||
|
|
logger.warning("任务 %s 第 %s 次失败,%.1f 秒后重试:%s",
|
|||
|
|
row.task_key, row.attempt, delay, row.error[:120])
|
|||
|
|
row.updated_at = now()
|
|||
|
|
session.add(row)
|
|||
|
|
session.commit()
|
|||
|
|
return row.status
|
|||
|
|
|
|||
|
|
|
|||
|
|
def requeue_dead(session: Session, queue_model, task_id: int) -> bool:
|
|||
|
|
"""手动重放死信任务。"""
|
|||
|
|
row = session.get(queue_model, task_id)
|
|||
|
|
if not row or row.status != "dead":
|
|||
|
|
return False
|
|||
|
|
row.status = "pending"
|
|||
|
|
row.attempt = 0
|
|||
|
|
row.next_run_at = now()
|
|||
|
|
row.error = ""
|
|||
|
|
row.updated_at = now()
|
|||
|
|
session.add(row)
|
|||
|
|
session.commit()
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def queue_stats(session: Session, queue_model) -> dict:
|
|||
|
|
rows = session.exec(select(queue_model)).all()
|
|||
|
|
by_status: dict[str, int] = {"pending": 0, "running": 0, "done": 0, "dead": 0}
|
|||
|
|
for row in rows:
|
|||
|
|
by_status[row.status] = by_status.get(row.status, 0) + 1
|
|||
|
|
return {"total": len(rows), "by_status": by_status,
|
|||
|
|
"done": by_status.get("done", 0),
|
|||
|
|
"dead": by_status.get("dead", 0),
|
|||
|
|
"pending": by_status.get("pending", 0),
|
|||
|
|
"running": by_status.get("running", 0)}
|
|||
|
|
|
|||
|
|
|
|||
|
|
__all__ = ["enqueue", "lease_next", "claim_by_key", "heartbeat", "complete", "fail",
|
|||
|
|
"requeue_dead", "queue_stats", "LEASE_SECONDS", "MAX_ATTEMPTS"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def claim_by_key(session: Session, queue_model, task_key: str, worker_id: str) -> object | None:
|
|||
|
|
"""Claim a specific durable job for the inline API worker.
|
|||
|
|
|
|||
|
|
A running row is only reused by the same owner or stolen after its lease
|
|||
|
|
expires; otherwise another live executor owns it.
|
|||
|
|
"""
|
|||
|
|
row = session.exec(select(queue_model).where(queue_model.task_key == task_key).order_by(queue_model.id.desc())).first()
|
|||
|
|
if row is None:
|
|||
|
|
return None
|
|||
|
|
current = now()
|
|||
|
|
expired = False
|
|||
|
|
if row.status == "running":
|
|||
|
|
deadline = _parse(row.lease_until or "")
|
|||
|
|
expired = bool(deadline and deadline <= _parse(current))
|
|||
|
|
if row.status == "pending" or (row.status == "running" and expired):
|
|||
|
|
row.status = "running"
|
|||
|
|
row.attempt = (row.attempt or 0) + 1
|
|||
|
|
row.worker_id = worker_id
|
|||
|
|
row.lease_until = _lease_deadline()
|
|||
|
|
row.updated_at = current
|
|||
|
|
session.add(row)
|
|||
|
|
session.commit()
|
|||
|
|
session.refresh(row)
|
|||
|
|
return row
|
|||
|
|
if row.status == "running" and row.worker_id == worker_id:
|
|||
|
|
return row
|
|||
|
|
return None
|