Files

259 lines
10 KiB
Python
Raw Permalink 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.
"""持久任务队列(交付报告 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