Files
inquiry_robot/inquiry-agent/agent/jobs/__init__.py
T

123 lines
4.2 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.
"""
任务槽与认领循环:识别、LibreOffice、Archive 等。
本包约束(硬):LibreOffice 仅 1 槽;HTTP 类 2~3 槽;inbox/outbox 不得与 soffice 串同一阻塞线。
禁止:一把大锁串行所有工单;禁止 wecom:worker:singleton。
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Callable
logger = logging.getLogger(__name__)
@dataclass
class SlotPool:
"""
进程内有限槽线程池骨架。
用途:限制同类慢活并发(尤其 LibreOffice=1)。
为何不用进程级 Redis 单例:同机只应起一份 Worker 服务;防重入靠任务认领。
"""
name: str
size: int
_sem: threading.BoundedSemaphore = field(init=False, repr=False)
def __post_init__(self) -> None:
if self.size < 1:
raise ValueError(f"槽位数必须 >=1: {self.name}")
self._sem = threading.BoundedSemaphore(self.size)
def run(self, fn: Callable[[], None], *, label: str = "") -> None:
"""
占用一个槽执行 fn;槽满则阻塞等待(调用方应在 Worker 线程,勿在 HTTP 回调线程调用)。
副作用:执行 fn;不写库(骨架)。
"""
acquired = self._sem.acquire(timeout=30)
if not acquired:
logger.warning("槽超时未获取 name=%s label=%s", self.name, label)
return
try:
fn()
finally:
self._sem.release()
@dataclass
class WorkerRuntime:
"""
Worker 运行时:持有分槽与 inbox/outbox 占位线程。
启动时机:worker_app main;停止:进程退出或 stop()。
禁止:把所有任务丢进 LibreOffice 槽。
"""
libreoffice: SlotPool
http_jobs: SlotPool
_stop: threading.Event = field(default_factory=threading.Event)
_threads: list[threading.Thread] = field(default_factory=list)
@classmethod
def from_settings(cls, libreoffice_slots: int, http_job_slots: int) -> "WorkerRuntime":
"""按配置创建槽;强制 LibreOffice 槽为 1,防止误配双 soffice。"""
lo = 1 if libreoffice_slots != 1 else libreoffice_slots
if libreoffice_slots != 1:
logger.warning(
"LibreOffice 槽必须为 1(收到 %s),已强制为 1,避免本机双 soffice 互抢",
libreoffice_slots,
)
return cls(
libreoffice=SlotPool("libreoffice", lo),
http_jobs=SlotPool("http_jobs", max(2, http_job_slots)),
)
def start(self) -> None:
"""
启动 inbox / outbox / 空转巡检线程(骨架:只心跳日志,不消费真实队列)。
副作用:创建守护线程;不连 Redis(骨架)。
"""
self._stop.clear()
self._threads = [
threading.Thread(target=self._inbox_loop, name="inbox-loop", daemon=True),
threading.Thread(target=self._outbox_loop, name="outbox-loop", daemon=True),
threading.Thread(target=self._http_slot_idle, name="http-slots-idle", daemon=True),
]
for t in self._threads:
t.start()
logger.info(
"WorkerRuntime 已启动 libreoffice_slots=%s http_job_slots=%s",
self.libreoffice.size,
self.http_jobs.size,
)
def stop(self) -> None:
"""请求停止占位循环;不保证立刻打断正在跑的槽任务(骨架无真实任务)。"""
self._stop.set()
def _inbox_loop(self) -> None:
"""inbox 独立线程:后续从 PG/队列认领入站;禁止与 soffice 共用阻塞队列。"""
while not self._stop.is_set():
time.sleep(2.0)
def _outbox_loop(self) -> None:
"""outbox 独立线程:后续发送企微;失败重试;过期丢弃不计业务成功。"""
while not self._stop.is_set():
time.sleep(2.0)
def _http_slot_idle(self) -> None:
"""证明 HTTP 类槽可并行占用;骨架不跑真实 LLM。"""
while not self._stop.is_set():
def _noop() -> None:
time.sleep(0.05)
self.http_jobs.run(_noop, label="idle-ping")
time.sleep(5.0)