Coverage for src/lilbee/data/offload.py: 100%
63 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Dedicated thread pool for ingest-side blocking work.
3``asyncio.to_thread`` runs on the loop's default executor -- the same pool the
4serving path (chat dispatch, handlers) offloads to. A fanned ingest can fill
5that pool with extraction/OCR/embedding calls and starve request handling, so
6ingest work runs on its own bounded executor instead.
7"""
9from __future__ import annotations
11import asyncio
12import contextvars
13import functools
14import logging
15import os
16from collections.abc import Callable, Coroutine
17from concurrent.futures import Executor, ThreadPoolExecutor
18from typing import Any, ParamSpec, TypeVar
20log = logging.getLogger(__name__)
22_P = ParamSpec("_P")
23_R = TypeVar("_R")
25_MAX_WORKERS_ENV = "LILBEE_INGEST_MAX_WORKERS"
26_POOL_THREAD_PREFIX = "lilbee-ingest"
28# Files per embed replica to keep in flight during ingest. A replica interleaves
29# its file's extraction with embedding, so a handful of files per card must be
30# admitted at once or the GPU sits idle between requests. Scaling admission by
31# the detected replica count auto-sizes a multi-GPU fleet with no manual cap,
32# instead of the CPU-bound default that pins an 8-GPU box at ~4 files/card.
33# Tuned against the 8x A100 MS MARCO fleet; override per run with
34# ``ingest_max_inflight`` / ``LILBEE_INGEST_MAX_INFLIGHT``.
35_EMBED_INFLIGHT_PER_REPLICA = 8
38def embed_inflight_target() -> int:
39 """Admission that keeps every embed replica fed, from the detected fleet size.
41 ``embed replicas x _EMBED_INFLIGHT_PER_REPLICA`` when a multi-replica fleet is
42 resolvable, else 0 (single card or no fleet: the CPU-bound sizing already
43 fits). Never raises -- a fleet that cannot be probed yet returns 0.
44 """
45 try:
46 from lilbee.providers.fleet.replicas import gpu_device_count, resolve_replica_count
47 from lilbee.providers.roles import WorkerRole
49 slots = resolve_replica_count(WorkerRole.EMBED, gpu_device_count())
50 except Exception:
51 return 0
52 return slots * _EMBED_INFLIGHT_PER_REPLICA if slots > 1 else 0
55@functools.cache
56def max_workers() -> int:
57 """The ingest pool's worker count -- its hard concurrency ceiling.
59 The adaptive-concurrency controller uses this as the upper bound on in-flight
60 documents, since each one needs a pool thread to run its blocking extraction.
61 Resolved once and cached: the pool is built with this same value on first use,
62 so re-reading ``LILBEE_INGEST_MAX_WORKERS`` per call would let the reported
63 ceiling drift away from the pool that actually exists.
65 Extraction rasterizes PDFs and drives OCR on this pool. The default caps at
66 ``min(32, cpu_count + 4)``: a worker-count sweep on forced-OCR multi-page PDFs
67 (4x H100) held throughput flat from 16 to 32 workers and *declining* past it,
68 with the GPUs already ~85-90% busy the whole time. OCR ingest is GPU-bound, not
69 extraction-bound, so extra threads only rasterize ahead into a buffer the GPUs
70 cannot drain any faster while oversubscribing the box. The ``+4`` keeps headroom
71 for threads parked on OCR/embed I/O; small hosts scale below the cap. The
72 override lifts the ceiling for genuinely CPU-bound work (e.g. bulk text
73 extraction) that can use more threads; non-positive or unparseable values warn
74 and fall back to the default.
75 """
76 override = os.environ.get(_MAX_WORKERS_ENV)
77 if override is not None:
78 try:
79 value = int(override)
80 if value > 0:
81 return value
82 except ValueError:
83 pass # bad override falls through to the warning + default below
84 log.warning(
85 "Ignoring %s=%r: must be a positive integer; using default.",
86 _MAX_WORKERS_ENV,
87 override,
88 )
89 default = min(32, (os.cpu_count() or 4) + 4)
90 # The pool (and thus the adaptive controller's permit_max, which is this
91 # value) must be able to feed the admission ceiling, or in adaptive mode the
92 # gate clamps back to 32 and a multi-GPU fleet stays starved. Size it to the
93 # explicit ingest_max_inflight override, else auto-scale with the detected
94 # embed fleet so no manual cap is needed on a multi-GPU box.
95 from lilbee.core.config import active_config
97 inflight = active_config().ingest_max_inflight or embed_inflight_target()
98 return max(default, inflight)
101@functools.cache
102def _ingest_executor() -> ThreadPoolExecutor:
103 """The shared ingest pool for work outside an ingest run, created on first use."""
104 return ThreadPoolExecutor(max_workers=max_workers(), thread_name_prefix=_POOL_THREAD_PREFIX)
107_run_pool: contextvars.ContextVar[ThreadPoolExecutor | None] = contextvars.ContextVar(
108 "lilbee_ingest_run_pool", default=None
109)
112def owns_ingest_pool(
113 run: Callable[_P, Coroutine[Any, Any, _R]],
114) -> Callable[_P, Coroutine[Any, Any, _R]]:
115 """Give each call of the async *run* its own ingest pool, shut down when the call ends."""
117 @functools.wraps(run)
118 async def _with_pool(*args: _P.args, **kwargs: _P.kwargs) -> _R:
119 pool = ThreadPoolExecutor(max_workers=max_workers(), thread_name_prefix=_POOL_THREAD_PREFIX)
120 token = _run_pool.set(pool)
121 try:
122 return await run(*args, **kwargs)
123 finally:
124 _run_pool.reset(token)
125 pool.shutdown(wait=False, cancel_futures=True)
127 return _with_pool
130async def to_executor(
131 executor: Executor, fn: Callable[_P, _R], /, *args: _P.args, **kwargs: _P.kwargs
132) -> _R:
133 """``asyncio.to_thread`` on *executor*, contextvars preserved.
135 Extraction relies on contextvar propagation into its workers (cancel, config
136 and progress context), which ``run_in_executor`` alone would drop; copy the
137 context exactly like ``asyncio.to_thread`` does.
138 """
139 return await _submit(executor, fn, *args, **kwargs)
142def _submit(
143 executor: Executor, fn: Callable[_P, _R], /, *args: _P.args, **kwargs: _P.kwargs
144) -> asyncio.Future[_R]:
145 loop = asyncio.get_running_loop()
146 ctx = contextvars.copy_context()
147 call = functools.partial(ctx.run, fn, *args, **kwargs)
148 return loop.run_in_executor(executor, call)
151async def to_ingest_thread(fn: Callable[_P, _R], /, *args: _P.args, **kwargs: _P.kwargs) -> _R:
152 """Run *fn* on the current ingest run's pool, or on the shared pool outside a run."""
153 return await ingest_thread_future(fn, *args, **kwargs)
156def ingest_thread_future(
157 fn: Callable[_P, _R], /, *args: _P.args, **kwargs: _P.kwargs
158) -> asyncio.Future[_R]:
159 """Start *fn* on the ingest pool; the future is not a task, so no sweep of tasks cancels it."""
160 return _submit(_run_pool.get() or _ingest_executor(), fn, *args, **kwargs)