"""Small isolated RAG process pool with bounded admission and wall-clock deadlines.""" from __future__ import annotations import atexit import json import math import os from pathlib import Path import queue import selectors import subprocess import sys import threading import time class RagWorkerError(RuntimeError): pass class RagWorkerBusy(RagWorkerError): pass class RagWorkerTimeout(RagWorkerError): pass def _number(name: str, default: float, minimum: float, maximum: float) -> float: try: value = float(os.environ.get(name, default)) return min(maximum, max(minimum, value)) if math.isfinite(value) else default except (TypeError, ValueError): return default class _Worker: def __init__(self, command: list[str], stderr_path: Path | None): self.command = command self.stderr_path = stderr_path self.process: subprocess.Popen | None = None self.last_start_ms = 0.0 self.last_reused = False def start(self): if self.process is not None and self.process.poll() is None: self.last_start_ms = 0.0 self.last_reused = True return self.process self.close() started = time.perf_counter() stderr = subprocess.DEVNULL if self.stderr_path: self.stderr_path.parent.mkdir(parents=True, exist_ok=True) stderr = self.stderr_path.open('ab') try: self.process = subprocess.Popen( self.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr, bufsize=0) finally: if hasattr(stderr, 'close'): stderr.close() os.set_blocking(self.process.stdin.fileno(), False) os.set_blocking(self.process.stdout.fileno(), False) self.last_start_ms = round((time.perf_counter() - started) * 1000, 2) self.last_reused = False return self.process def close(self): process, self.process = self.process, None if process is None: return try: if process.poll() is None: process.kill() process.wait(timeout=0.2) except (OSError, subprocess.TimeoutExpired): pass finally: for pipe in (process.stdin, process.stdout): if pipe: pipe.close() def request(self, payload: dict, deadline: float) -> dict: if time.monotonic() >= deadline: raise RagWorkerTimeout('知识库检索超时,请缩小资料范围后重试。') process = self.start() encoded = memoryview((json.dumps(payload, ensure_ascii=False) + '\n').encode('utf-8')) buffer = bytearray() with selectors.DefaultSelector() as selector: selector.register(process.stdin, selectors.EVENT_WRITE) selector.register(process.stdout, selectors.EVENT_READ) while True: remaining = deadline - time.monotonic() if remaining <= 0: raise RagWorkerTimeout('知识库检索超时,请缩小资料范围后重试。') events = selector.select(remaining) if not events: raise RagWorkerTimeout('知识库检索超时,请缩小资料范围后重试。') for key, _ in events: if key.fileobj is process.stdin: try: sent = os.write(key.fd, encoded[:65536]) except BlockingIOError: continue encoded = encoded[sent:] if not encoded: selector.unregister(process.stdin) else: try: part = os.read(key.fd, 65536) except BlockingIOError: continue if not part: raise RagWorkerError('知识库检索进程意外退出,请重试。') buffer.extend(part) if len(buffer) > 32 * 1024 * 1024: raise RagWorkerError('知识库检索返回内容过大,请减少检索片段数。') if b'\n' in buffer: line, trailing = buffer.split(b'\n', 1) if trailing.strip(): raise RagWorkerError('知识库检索进程返回格式异常,请重试。') try: response = json.loads(line) except (ValueError, UnicodeDecodeError) as exc: raise RagWorkerError('知识库检索进程返回格式异常,请重试。') from exc if not isinstance(response, dict): raise RagWorkerError('知识库检索进程返回格式异常,请重试。') return response class RagWorkerPool: """At most three active workers, a small waiting room, no in-process fallback.""" def __init__(self, *, size: int = 2, total_timeout: float = 45, queue_timeout: float = 3, max_pending: int | None = None, command: list[str] | None = None, stderr_path: Path | None = None): self.size = min(3, max(1, int(size))) self.total_timeout = max(0.01, float(total_timeout)) self.queue_timeout = max(0.0, min(float(queue_timeout), self.total_timeout)) self._closed = False self._workers = [_Worker(command or [sys.executable, str(Path(__file__).with_name('rag_worker.py'))], stderr_path) for _ in range(self.size)] self._available = queue.Queue(maxsize=self.size) for worker in self._workers: self._available.put_nowait(worker) self._admission = threading.BoundedSemaphore(self.size + (max_pending if max_pending is not None else self.size * 2)) def request(self, payload: dict) -> dict: started = time.monotonic() deadline = started + self.total_timeout if self._closed: raise RagWorkerError('知识库检索服务正在关闭,请稍后重试。') if not self._admission.acquire(blocking=False): raise RagWorkerBusy('知识库检索任务较多,等待队列已满,请稍后重试。') worker = None try: try: worker = self._available.get(timeout=min(self.queue_timeout, max(0, deadline - time.monotonic()))) except queue.Empty as exc: raise RagWorkerBusy('知识库检索排队超时,请稍后重试。') from exc queue_ms = round((time.monotonic() - started) * 1000, 2) try: response = worker.request(payload, deadline) if response.get('ok') is not True: from ..system_log import redact detail = redact(response.get('error') or '未知错误', 400) error = RagWorkerError(f'知识库检索失败:{detail}') setattr(error, 'retrieval_metric', response.get('retrieval_metric') or {"error": detail}) raise error if payload.get('op') == 'warmup': return response if not isinstance(response.get('results'), list): raise RagWorkerError('知识库检索返回格式异常,请重试。') except Exception as exc: # A timed-out response may never be consumed by a subsequent request. worker.close() if isinstance(exc, RagWorkerError): raise raise RagWorkerError('知识库检索进程通信失败,请重试。') from exc response['transport_timings'] = { 'queue_ms': queue_ms, 'total_ms': round((time.monotonic() - started) * 1000, 2), 'worker_ms': round((time.monotonic() - started) * 1000 - queue_ms, 2), 'process_start_ms': worker.last_start_ms, 'process_reused': worker.last_reused, 'worker_pid': int(worker.process.pid) if worker.process else 0, } # Child search timing is reported in the response envelope. The # residual after spawn and search is the parent/child pipe and JSON # transport overhead, useful for spotting IPC regressions. child_ms = float((response.get('worker_timings') or {}).get('search_ms') or 0.0) response['transport_timings']['ipc_ms'] = round(max( 0.0, response['transport_timings']['worker_ms'] - child_ms - float(response['transport_timings']['process_start_ms'] or 0.0)), 2) return response finally: if worker is not None: self._available.put_nowait(worker) self._admission.release() def prewarm(self) -> dict: """Start one resident worker and warm its model/client in the background.""" return self.request({'op': 'warmup'}) def close(self): self._closed = True for worker in self._workers: worker.close() _pool: RagWorkerPool | None = None _pool_lock = threading.Lock() def get_pool() -> RagWorkerPool: global _pool with _pool_lock: if _pool is None: from ..config import DATA_DIR _pool = RagWorkerPool( # The local embedding model is loaded once per worker. Keep one # resident worker by default on the single-host deployment to # avoid duplicating several hundred MB and triggering macOS # memory pressure; operators can raise RAG_WORKER_POOL_SIZE # explicitly when concurrent retrieval is required. size=int(_number('RAG_WORKER_POOL_SIZE', 1, 1, 3)), total_timeout=_number('RAG_SEARCH_TIMEOUT_SECONDS', 45, 1, 180), # Content tasks commonly fan out several segment requests at # once. Give queued requests enough time to use the single # memory-efficient worker instead of surfacing a spurious # timeout while an earlier segment is still being searched. queue_timeout=_number('RAG_QUEUE_TIMEOUT_SECONDS', 10, 0, 30), max_pending=int(_number('RAG_MAX_PENDING', 8, 0, 32)), stderr_path=DATA_DIR / 'logs' / 'rag-worker.log') atexit.register(_pool.close) return _pool