123 lines
4.2 KiB
Python
123 lines
4.2 KiB
Python
"""
|
||
任务槽与认领循环:识别、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)
|