deerflow-code/offline-backend-20260512/backend/app/gateway/roundtable_job_dispatcher.py
2026-09-07 18:24:55 +08:00

356 lines
16 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.

"""圆桌后台作业 dispatcher(Phase 3):跨 worker 领取 + 崩溃接管 + outbox 补偿。
把「谁来跑这个作业」从「发起请求的那个 worker 的进程内存」收敛到 **DB 租约**:
- **领取(claim)**:后台循环定期 ``list_claimable``(``queued`` 新作业/续跑,或 ``running``
但租约过期的僵尸),对每个候选用 ``claim_job`` 的**原子条件 UPDATE** 抢占 —— 多 worker
并发扫到同一候选时只有一个 ``rowcount==1``,其余拿到 None 自动跳过。一致性来自 DB,
不是进程内锁。
- **心跳**:领取成功后交给 ``RoundtableJobExecutor`` 跑,执行器内部定期 ``renew_lease``;
worker 崩溃则心跳停止,租约最长一个 TTL 后过期,下一轮循环在其它 worker 重新领取
(``attempt+1``)—— 崩溃可接管。
- **启动扫描**:本 worker 启动时立即跑一轮 ``dispatch_once``,尽快接走历史遗留的
queued / 租约过期作业(不用等第一个轮询周期)。
- **outbox 补偿器**:``draft_persist_pending=1`` 的作业(后台研讨结果因乐观锁冲突没能写回
草稿)在这里被重新投影到草稿 step2.runs,成功后清标记 —— 「研讨结果不丢」由 DB 标记驱动。
所有 worker 都跑同一个 dispatcher;谁先 ``claim_job`` 成功谁执行,天然幂等、互斥。
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from typing import Any
from app.gateway.roundtable_job_executor import (
_LEASE_TTL_SECONDS,
WORKER_ID,
params_from_snapshot,
)
logger = logging.getLogger(__name__)
# dispatcher 轮询间隔(秒)。路由 start/resume 成功后会 ``nudge()`` 立即唤醒,故正常路径
# 几乎无感延迟;该间隔只是「没有 nudge 时的兜底」,兼作崩溃接管的发现周期。
_DISPATCH_POLL_INTERVAL = 3.0
# outbox 补偿器轮询间隔(秒)。pending 标记是低频异常路径,慢一点无妨。
_COMPENSATE_INTERVAL = 10.0
# 单轮最多领取的作业数(防止一次扫太多把事件循环占死)。
_CLAIM_BATCH = 20
class RoundtableJobDispatcher:
"""跨 worker 的圆桌后台作业调度器(每个 worker 进程一个实例,lifespan 起停)。"""
def __init__(
self,
store,
executor,
*,
draft_store=None,
task_draft_store=None,
worker_id: str | None = None,
) -> None:
self._store = store
self._executor = executor
# outbox 补偿器投影草稿用(个人草稿 / task 草稿两个存储)。
self._draft_store = draft_store
self._task_draft_store = task_draft_store
self._worker_id = worker_id or WORKER_ID
self._wake = asyncio.Event()
self._task: asyncio.Task | None = None
self._stopping = False
self._last_compensate = datetime.now(UTC)
# ── 生命周期 ─────────────────────────────────────────────────────────────
def start(self) -> None:
"""启动后台循环(lifespan startup 调用)。幂等。"""
if self._task is not None and not self._task.done():
return
self._stopping = False
self._task = asyncio.create_task(self._loop(), name="roundtable-job-dispatcher")
logger.info("[roundtable-jobs] dispatcher started (worker=%s)", self._worker_id)
async def stop(self) -> None:
"""停止后台循环(lifespan shutdown 调用)。"""
self._stopping = True
self._wake.set()
if self._task is not None:
self._task.cancel()
with suppress(asyncio.CancelledError, Exception):
await self._task
self._task = None
def nudge(self) -> None:
"""立即唤醒一轮领取(路由 start/resume 成功后调用,降低启动延迟)。"""
self._wake.set()
# ── 主循环 ───────────────────────────────────────────────────────────────
async def _loop(self) -> None:
# 启动即扫一轮:尽快接管历史遗留 queued / 租约过期(僵尸)作业。
await self._safe_dispatch()
while not self._stopping:
try:
await asyncio.wait_for(self._wake.wait(), timeout=_DISPATCH_POLL_INTERVAL)
except TimeoutError:
pass
self._wake.clear()
if self._stopping:
break
await self._safe_dispatch()
# outbox 补偿器按自己的节奏跑(低频)。
if (datetime.now(UTC) - self._last_compensate).total_seconds() >= _COMPENSATE_INTERVAL:
self._last_compensate = datetime.now(UTC)
await self._safe_compensate()
async def _safe_dispatch(self) -> None:
try:
await self.dispatch_once()
except Exception: # noqa: BLE001 — 单轮失败不能杀死循环,下轮重试
logger.exception("[roundtable-jobs] dispatch round failed")
async def _safe_compensate(self) -> None:
try:
await self.compensate_once()
except Exception: # noqa: BLE001
logger.exception("[roundtable-jobs] compensate round failed")
# ── 领取 ─────────────────────────────────────────────────────────────────
async def dispatch_once(self) -> int:
"""扫一轮可领取作业并逐个条件抢占。返回本轮成功启动的作业数。"""
now = datetime.now(UTC)
await self._reap_cancel_requested(now)
try:
candidate_ids = await self._store.list_claimable(now=now, limit=_CLAIM_BATCH)
except Exception: # noqa: BLE001
logger.warning("[roundtable-jobs] list_claimable failed", exc_info=True)
return 0
if not candidate_ids:
return 0
started = 0
lease_until = now + timedelta(seconds=_LEASE_TTL_SECONDS)
for job_id in candidate_ids:
try:
row = await self._store.claim_job(
job_id, lease_owner=self._worker_id, lease_until=lease_until
)
except Exception: # noqa: BLE001
logger.warning("[roundtable-jobs] claim_job failed for %s", job_id, exc_info=True)
continue
if row is None:
# 别的 worker 先领了 / 状态已变 —— 条件 UPDATE rowcount=0,跳过即可。
continue
params = params_from_snapshot(
row.get("input_snapshot"), job_id=job_id, lease_owner=self._worker_id
)
if params is None:
# 快照缺失/损坏:无法重建执行参数 —— 标记 error 并释放活跃键,避免僵死占位。
logger.error(
"[roundtable-jobs] job %s claimed but snapshot missing/corrupt; marking error",
job_id,
)
with suppress(Exception):
await self._store.update_progress(
job_id,
lease_owner=self._worker_id,
status="error",
error="input_snapshot 缺失或损坏,无法在后台重建研讨",
)
continue
self._executor.start_job(params)
started += 1
logger.info(
"[roundtable-jobs] worker=%s claimed job %s (attempt=%s, resume=%s)",
self._worker_id, job_id, row.get("attempt"), params.is_resume,
)
return started
async def _reap_cancel_requested(self, now: datetime) -> int:
"""收口持租 worker 崩溃后遗留的 ``cancel_requested`` 作业。
有效租约仍在时,必须让持租 worker 的取消看门狗收口;无租约或租约过期时,已经
不存在可信的持租者,此处通过 ``finalize_cancel`` 的条件 UPDATE 安全地回收。
"""
try:
candidate_ids = await self._store.list_cancel_reapable(
now=now, limit=_CLAIM_BATCH
)
except Exception: # noqa: BLE001
logger.warning("[roundtable-jobs] list_cancel_reapable failed", exc_info=True)
return 0
finalized = 0
for job_id in candidate_ids:
try:
row = await self._store.finalize_cancel(job_id)
except Exception: # noqa: BLE001
logger.warning(
"[roundtable-jobs] finalize orphaned cancellation failed for %s",
job_id,
exc_info=True,
)
continue
if row is not None:
finalized += 1
logger.info(
"[roundtable-jobs] reaped orphaned cancellation for job %s", job_id
)
return finalized
# ── outbox 补偿器 ────────────────────────────────────────────────────────
def _draft_store_for(self, task_id: str | None):
"""task 作业 → task-draft 存储;普通作业 → 个人草稿存储(与执行器同规则)。"""
return self._task_draft_store if task_id else self._draft_store
async def compensate_once(self) -> int:
"""把 ``draft_persist_pending=1`` 的作业结果投影回草稿,成功后清标记。
投影逻辑与执行器 ``_write_run_to_draft`` 的终态写一致:读最新草稿 → 按
``run_id=job-<jobId>`` upsert 一个 run(用作业行里的 dialogues/step3/status)→
带 ``expected_version`` 乐观锁写回。写成功(或草稿已含该 run 的终态)即清标记。
返回本轮成功投影的作业数。
"""
try:
pending = await self._store.list_draft_persist_pending(limit=_CLAIM_BATCH)
except Exception: # noqa: BLE001
logger.warning("[roundtable-jobs] list_draft_persist_pending failed", exc_info=True)
return 0
if not pending:
return 0
projected = 0
for job in pending:
job_id = job.get("id")
draft_id = job.get("draft_id")
draft_store = self._draft_store_for(job.get("task_id"))
if not draft_id or draft_store is None:
# 无草稿可投影(如纯 task 作业但 task_store 未配)——直接清标记,避免死循环。
with suppress(Exception):
await self._store.clear_draft_persist_pending(job_id)
continue
try:
ok = await self._project_job_to_draft(draft_store, job)
except Exception: # noqa: BLE001
logger.warning(
"[roundtable-jobs] compensate project failed for job %s", job_id, exc_info=True
)
continue
if ok:
with suppress(Exception):
await self._store.clear_draft_persist_pending(job_id)
projected += 1
logger.info("[roundtable-jobs] compensated draft write for job %s", job_id)
return projected
async def _project_job_to_draft(self, draft_store: Any, job: dict[str, Any]) -> bool:
"""把单个作业的最新状态投影成草稿 step2.runs 里的一个 run。成功返回 True。
与执行器不同:这里没有 ``params``(作业可能跑在别的 worker / 已结束),只能用作业行
里已落库的 ``dialogues``/``step3``/``status`` 重建 run —— 这些正是「研讨结果」本体,
足够让前端加载草稿时看到完整的后台研讨记录与报告。
"""
from deerflow.persistence.roundtable_drafts.sql import DraftConcurrentWriteError
job_id = job.get("id")
draft_id = job.get("draft_id")
user_id = job.get("user_id")
run_id = f"job-{job_id}"
status = job.get("status") or "running"
dialogues = job.get("dialogues") or []
step3 = job.get("step3")
# task 草稿不分权:get/update 不传 user_id;个人草稿按 user。
is_task = bool(job.get("task_id"))
for _attempt in range(3):
try:
draft = (
await draft_store.get_draft(draft_id)
if is_task
else await draft_store.get_draft(draft_id, user_id)
)
except Exception: # noqa: BLE001
draft = None
if draft is None:
# 草稿已被删除 —— 无处投影,视作完成(清标记)。
return True
expected_version = draft.get("version")
step2 = dict(draft.get("step2") or {})
runs = list(step2.get("runs") or [])
idx = next((i for i, r in enumerate(runs) if r.get("id") == run_id), -1)
created = runs[idx].get("createdAt") if idx >= 0 else (job.get("created_at") or datetime.now(UTC).isoformat())
prev_step3 = runs[idx].get("step3") if idx >= 0 else None
effective_step3 = step3 if step3 is not None else prev_step3
# 从 dialogues 里提取席位信息(尽量还原 selectedAgents)。
agents_seen: list[dict[str, str]] = []
seen_ids: set[str] = set()
for d in dialogues:
aid = str(d.get("agentId") or "")
if aid and aid not in seen_ids and d.get("role") == "seat":
seen_ids.add(aid)
agents_seen.append({"agent_id": aid, "name": str(d.get("name") or aid)})
run = {
"id": run_id,
"title": "后台研讨",
"createdAt": created,
"source": job.get("orchestration_mode") or "recommend",
"selectedAgents": agents_seen,
"threadIds": job.get("thread_ids"),
"coordinatorName": job.get("coordinator_name"),
"step2RoundtableDialogues": dialogues,
"lastLeaderContent": next(
(d.get("content") for d in reversed(dialogues) if d.get("role") == "leader"), ""
) or "",
"hasConsensus": status == "done",
"consensusPercentage": int(job.get("consensus_percentage") or (100 if status == "done" else 0)),
"budgetLimit": 0,
"orchestrationMode": job.get("orchestration_mode") or "recommend",
"chain": job.get("chain"),
"orchestrationPlan": job.get("orchestration_plan"),
"step3": effective_step3,
"jobId": job_id,
"jobStatus": status,
}
runs = [run if i == idx else r for i, r in enumerate(runs)] if idx >= 0 else [*runs, run]
step2["runs"] = runs
step2["activeRunId"] = run_id
has_report = effective_step3 is not None
update_kwargs: dict[str, Any] = {
"step2": step2,
"furthest_step": 3 if (status == "done" and has_report) else 2,
}
if has_report:
update_kwargs["step3"] = effective_step3
try:
if is_task:
await draft_store.update_draft(
draft_id, expected_version=expected_version, **update_kwargs
)
else:
await draft_store.update_draft(
draft_id, user_id, expected_version=expected_version, **update_kwargs
)
return True
except DraftConcurrentWriteError:
# 并发写冲突 —— 重读最新草稿再合并重试(与执行器同款乐观锁重试)。
continue
except Exception: # noqa: BLE001
logger.warning(
"[roundtable-jobs] compensate update_draft failed for %s", draft_id, exc_info=True
)
return False
# 乐观锁重试耗尽:保留 pending 标记,等下一轮补偿器再试。
return False