"""Bound executing runs, including detached/background runs, not HTTP streams.""" from __future__ import annotations import asyncio from contextvars import ContextVar from functools import wraps from fastapi import HTTPException _worker_ticket = ContextVar("worker_admission_ticket", default=None) class AdmissionTicket: def __init__(self, gate): if gate.active + gate.waiting >= gate.capacity + gate.queue_limit: raise HTTPException(429, "执行节点繁忙,请稍后重试", headers={"Retry-After": "3"}) self.gate = gate self.deadline = asyncio.get_running_loop().time() + gate.timeout self.waiting = True self.acquired = False self.released = False self.started = False gate.waiting += 1 async def acquire(self): try: remaining = max(0.001, self.deadline - asyncio.get_running_loop().time()) await asyncio.wait_for(self.gate.semaphore.acquire(), remaining) self.acquired = True self.gate.active += 1 except TimeoutError: raise HTTPException(429, "等待执行超时,请稍后重试") from None finally: if self.waiting: self.gate.waiting -= 1 self.waiting = False def release(self, _task=None): if self.released: return self.released = True if self.waiting: self.gate.waiting -= 1 self.waiting = False if self.acquired: self.gate.active -= 1 self.gate.semaphore.release() async def _finish_queued_run(kwargs, *, cancelled=False, message="等待执行超时,请稍后重试"): from deerflow.runtime.runs.schemas import RunStatus manager, record, bridge = kwargs["run_manager"], kwargs["record"], kwargs["bridge"] try: async with asyncio.timeout(5): await manager.set_status(record.run_id, RunStatus.interrupted if cancelled else RunStatus.error, error=None if cancelled else message) ctx = kwargs.get("ctx") store = getattr(ctx, "thread_store", None) if store is not None and not getattr(record, "superseded", False): await store.update_status(record.thread_id, "interrupted" if cancelled else "error") finally: try: if not cancelled: await bridge.publish(record.run_id, "error", {"name": "WorkerQueueTimeout", "message": message}) finally: await bridge.publish_end(record.run_id) asyncio.create_task(bridge.cleanup(record.run_id, delay=60)) async def run_with_worker_admission(runner, **kwargs): ticket = _worker_ticket.get() if ticket is None: return await runner(**kwargs) ticket.started = True record, bridge = kwargs["record"], kwargs["bridge"] # Publish a real, persisted run id before waiting for an execution slot. # The router keeps its canonical Location/reconnect headers unchanged. try: await bridge.publish(record.run_id, "metadata", {"run_id": record.run_id, "thread_id": record.thread_id, "worker_queue": {"waiting": ticket.gate.waiting, "capacity": ticket.gate.capacity}}) await ticket.acquire() except asyncio.CancelledError: await _finish_queued_run(kwargs, cancelled=True) raise except HTTPException as exc: await _finish_queued_run(kwargs, message=str(exc.detail)) return except Exception: await _finish_queued_run(kwargs, message="执行节点排队初始化失败,请查看节点日志") return return await runner(**kwargs) class Admission: def __init__(self, capacity: int, queue_limit: int = 128, timeout: float = 60): self.capacity = capacity self.active = 0 self.waiting = 0 self.queue_limit = queue_limit self.timeout = timeout self.semaphore = asyncio.Semaphore(capacity) self.lock = asyncio.Lock() async def acquire(self): async with self.lock: if self.waiting >= self.queue_limit: raise HTTPException(429, "执行节点繁忙,请稍后重试", headers={"Retry-After": "3"}) self.waiting += 1 try: await asyncio.wait_for(self.semaphore.acquire(), self.timeout) except TimeoutError: raise HTTPException(429, "等待执行超时,请稍后重试", headers={"Retry-After": "3"}) from None finally: self.waiting -= 1 self.active += 1 def release(self, _task=None): self.active -= 1 self.semaphore.release() def bounded_worker_run(function): @wraps(function) async def wrapped(body, thread_id, request, *args, **kwargs): pool = getattr(request.app.state, "worker_pool", None) fallback_to_main = bool(getattr(getattr(request, "state", None), "worker_pool_fallback", False)) if pool is not None and pool.db is not None and pool.db.execute("SELECT 1 FROM affinity WHERE thread=?", (thread_id,)).fetchone() and not fallback_to_main: raise HTTPException(409, "该会话由独立 Worker 管理,请通过会话接口提交,不能在主服务重复执行") gate = getattr(request.app.state, "worker_admission", None) if gate is None: if pool is None: return await function(body, thread_id, request, *args, **kwargs) if pool.local_threads.get(thread_id, 0): raise HTTPException(409, "该会话正在主服务执行,请等待当前回答完成") # Reserve before the first await, including start_run's preparation # phase before RunRecord.task exists. Otherwise a concurrent routed # request could assign this same thread to another process. pool.local_threads[thread_id] = pool.local_threads.get(thread_id, 0) + 1 def release_local(_task=None): remaining = pool.local_threads.get(thread_id, 1) - 1 if remaining: pool.local_threads[thread_id] = remaining else: pool.local_threads.pop(thread_id, None) try: record = await function(body, thread_id, request, *args, **kwargs) except BaseException: release_local() raise if record.task is None: release_local() else: record.task.add_done_callback(release_local) return record if getattr(request.app.state, "worker_deferred_admission", False) and getattr(request.app.state, "concurrency_gate", None) is None: ticket = AdmissionTicket(gate) token = _worker_ticket.set(ticket) try: from deerflow.runtime.thread_paths import aresolve_path_user_id await aresolve_path_user_id(thread_id) refresh = getattr(request.app.state, "worker_skill_refresh", None) if refresh is not None: await refresh.check() record = await function(body, thread_id, request, *args, **kwargs) except BaseException: ticket.release() raise finally: _worker_ticket.reset(token) if record.task is None: ticket.release() else: def finish_ticket(task): ticket.release() if task.cancelled() and not ticket.started: manager = getattr(request.app.state, "run_manager", None) bridge = getattr(request.app.state, "stream_bridge", None) if manager is not None and bridge is not None: asyncio.create_task(_finish_queued_run({"run_manager": manager, "record": record, "bridge": bridge}, cancelled=True)) record.task.add_done_callback(finish_ticket) return record await gate.acquire() try: # File/path middleware is synchronous. Resolve the persisted owner # on the database's home loop before it touches those helpers. from deerflow.runtime.thread_paths import aresolve_path_user_id await aresolve_path_user_id(thread_id) refresh = getattr(request.app.state, "worker_skill_refresh", None) if refresh is not None: await refresh.check() record = await function(body, thread_id, request, *args, **kwargs) except BaseException: gate.release() raise if record.task is None: gate.release() else: record.task.add_done_callback(gate.release) return record return wrapped