Coverage for src/lilbee/data/extract/batch.py: 100%
72 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"""Coalesce concurrent single-document extractions into one xberg batch call.
3Active only when ``cfg.batch_extraction`` is on. Intercepts the pipeline's per-file
4extraction coroutines and groups in-flight ones into a single ``extract_batch``,
5leaving the per-file contract (progress, admission, results) untouched. No
6off-the-shelf async coalescer speaks xberg's per-input config, so the buffer-and-
7flush below is hand-rolled. Inputs sharing a mode share a batch config; each keeps
8its own OCR token as a per-file override.
9"""
11from __future__ import annotations
13import asyncio
14import contextvars
15from dataclasses import dataclass
16from typing import TYPE_CHECKING
18from .xberg import BatchItem
20if TYPE_CHECKING:
21 from collections.abc import Awaitable, Callable
23 from xberg import ExtractedDocument, ExtractionConfig, OcrConfig
24 from xberg.progress import ProgressCallback
26 from lilbee.data.types import ExtractMode
28# Delay before firing a partial batch, so concurrent extractions arriving in the
29# same burst join it. Essential for the tail and when fewer than a full batch's
30# worth of files are ever admitted at once.
31_FLUSH_WINDOW_S = 0.05
34@dataclass
35class _Pending:
36 data: bytes
37 filename: str
38 ocr_token: str
39 on_progress: ProgressCallback | None
40 future: asyncio.Future[ExtractedDocument]
43class ExtractBatcher:
44 """Buffers extraction requests per mode and flushes them as one batch call."""
46 def __init__(
47 self,
48 *,
49 size: int,
50 config_fn: Callable[[ExtractMode], ExtractionConfig],
51 ocr_fn: Callable[[str], OcrConfig | None],
52 batch_fn: Callable[
53 [list[BatchItem], ExtractionConfig], Awaitable[list[ExtractedDocument | Exception]]
54 ],
55 window: float = _FLUSH_WINDOW_S,
56 ) -> None:
57 self._size = size
58 self._window = window
59 self._config_fn = config_fn
60 self._ocr_fn = ocr_fn
61 self._batch_fn = batch_fn
62 self._groups: dict[ExtractMode, list[_Pending]] = {}
63 self._timers: dict[ExtractMode, asyncio.TimerHandle] = {}
64 self._running: set[asyncio.Task[None]] = set()
66 async def submit(
67 self,
68 mode: ExtractMode,
69 data: bytes,
70 filename: str,
71 ocr_token: str,
72 on_progress: ProgressCallback | None = None,
73 ) -> ExtractedDocument:
74 """Enqueue one extraction; resolves when its batch completes.
76 *on_progress* receives this file's OCR page events only.
77 """
78 loop = asyncio.get_running_loop()
79 future: asyncio.Future[ExtractedDocument] = loop.create_future()
80 group = self._groups.setdefault(mode, [])
81 group.append(_Pending(data, filename, ocr_token, on_progress, future))
82 if len(group) >= self._size:
83 self._flush(mode)
84 elif mode not in self._timers:
85 self._timers[mode] = loop.call_later(self._window, self._flush, mode)
86 return await future
88 def _flush(self, mode: ExtractMode) -> None:
89 timer = self._timers.pop(mode, None)
90 if timer is not None:
91 timer.cancel()
92 pending = self._groups.pop(mode, [])
93 if not pending:
94 return
95 config = self._config_fn(mode)
96 # mime=None: xberg detects the format from the filename, matching the
97 # single-file path. Passing lilbee's bare content_type here is rejected.
98 items = [
99 BatchItem(p.data, None, p.filename, self._ocr_fn(p.ocr_token), p.on_progress)
100 for p in pending
101 ]
102 task = asyncio.ensure_future(self._run(items, config, pending))
103 self._running.add(task)
104 task.add_done_callback(self._running.discard)
106 async def _run(
107 self, items: list[BatchItem], config: ExtractionConfig, pending: list[_Pending]
108 ) -> None:
109 try:
110 docs = await self._batch_fn(items, config)
111 except Exception as exc: # whole-batch failure fails every awaiter
112 for p in pending:
113 if not p.future.done():
114 p.future.set_exception(exc)
115 return
116 for p, doc in zip(pending, docs, strict=True):
117 if p.future.done():
118 continue
119 if isinstance(doc, BaseException):
120 p.future.set_exception(doc)
121 else:
122 p.future.set_result(doc)
124 async def close(self) -> None:
125 """Flush every buffered group and await the batches in flight."""
126 for mode in list(self._groups):
127 self._flush(mode)
128 if self._running:
129 await asyncio.gather(*self._running, return_exceptions=True)
132_active: contextvars.ContextVar[ExtractBatcher | None] = contextvars.ContextVar(
133 "lilbee_extract_batcher", default=None
134)
137def active_extract_batcher() -> ExtractBatcher | None:
138 """The batcher for the current ingest run, or None when batching is off."""
139 return _active.get()
142def set_active_batcher(batcher: ExtractBatcher) -> contextvars.Token[ExtractBatcher | None]:
143 return _active.set(batcher)
146def reset_active_batcher(token: contextvars.Token[ExtractBatcher | None]) -> None:
147 _active.reset(token)