iscc-app / cert_pdf_parallel.py
Couscouskabyle's picture
Deploy Sinetti V2 (FastAPI + React) [publication:363e1b82420d4c339dabaf2b4417eadc]
0fd673c verified
Raw History Blame
11.5 kB
"""Bounded process pool for the nightly PDF parse stages (certificates and audit reports).
Workers only fetch and parse; the parent records every outcome, so the SQLite work DB keeps a
single writer. Design: docs/superpowers/specs/2026-10-05-nightly-parallel-pdf-parsing-design.md.
"""
from __future__ import annotations
import time
from collections import deque
from concurrent.futures import FIRST_COMPLETED, Future, ProcessPoolExecutor, wait
from concurrent.futures.process import BrokenProcessPool
from dataclasses import asdict, dataclass, field
from typing import Callable, Generic, Mapping, Sequence, TypeVar
T = TypeVar("T")
R = TypeVar("R")
# A pool broken by a dying worker fails every in-flight future, so the culprit is unknown.
# Each affected item gets this many fresh attempts before it is recorded as a crash.
BROKEN_POOL_RETRIES = 1
@dataclass
class PoolStats:
workers: int = 0
submitted: int = 0
completed: int = 0
timeouts: int = 0
crashes: int = 0
recycles: int = 0
stopped_no_time: int = 0
wall_s: float = 0.0
peak_child_rss_mb: float | None = None
peak_in_flight: int = 0
peak_by_capacity: dict[str, int] = field(default_factory=dict)
submitted_by_capacity: dict[str, int] = field(default_factory=dict)
completed_by_capacity: dict[str, int] = field(default_factory=dict)
stopped_by_capacity: dict[str, int] = field(default_factory=dict)
first_admission_s: dict[str, float] = field(default_factory=dict)
last_completion_s: dict[str, float] = field(default_factory=dict)
def as_dict(self) -> dict:
return asdict(self)
@dataclass
class _Pending(Generic[T]):
item: T
attempts: int = 0
deadline: float | None = None
def peak_child_rss_mb() -> float | None:
"""Largest resident set of any reaped child process (Linux/macOS); None on Windows."""
try:
import resource
except ImportError:
return None
peak = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
# Linux reports KiB, macOS bytes.
import sys
return round(peak / (1024 * 1024 if sys.platform == "darwin" else 1024), 1)
def _terminate(pool: ProcessPoolExecutor) -> None:
"""Kill the pool's worker processes; shutdown(wait=False) alone leaves a wedged one running."""
# _processes is private but has been the executor's pid -> Process map since Python 3.2.
for process in list((getattr(pool, "_processes", None) or {}).values()):
try:
process.terminate()
except Exception: # noqa: BLE001 -- already gone
pass
pool.shutdown(wait=False, cancel_futures=True)
def run_bounded_pool(
items: Sequence[T],
work: Callable[[T], R],
record: Callable[[T, R], None],
*,
workers: int,
on_timeout: Callable[[T], R],
on_crash: Callable[[T, BaseException], R],
should_continue: Callable[[], bool] | None = None,
lane: Callable[[T], str] | None = None,
lane_limits: Mapping[str, int] | None = None,
capacity_keys: Callable[[T], Sequence[str]] | None = None,
capacity_limits: Mapping[str, int] | None = None,
item_timeout_s: float = 180.0,
task_timeout_s: Callable[[T], float] | None = None,
max_tasks_per_child: int = 25,
initializer: Callable[..., None] | None = None,
initargs: tuple = (),
) -> PoolStats:
"""Run ``work`` over ``items`` in worker processes, recording each outcome in the parent.
``work`` must be a picklable top-level function. Submission follows ``items`` order, except
that an item whose lane is at its limit waits while later items from other lanes start.
Alternatively ``capacity_keys`` supplies multiple independent capacity names per item;
every matching capacity must have space. The legacy lane arguments remain unchanged.
``should_continue`` is asked before every submission; once it says no, nothing new starts,
in-flight items finish, and the never-started count lands in ``stopped_no_time``. An item
running longer than ``item_timeout_s`` is recorded through ``on_timeout`` and the worker
processes are killed; innocent in-flight items are re-queued at the front. ``initializer``
runs once in every worker process (also after a recycle) to set up per-process state.
``task_timeout_s``, when supplied, returns the fixed timeout for each item in the parent;
omission retains the legacy shared ``item_timeout_s`` deadline.
"""
workers = max(1, int(workers))
if capacity_keys is not None and (lane is not None or lane_limits is not None):
raise ValueError("use capacity_keys/capacity_limits or lane/lane_limits, not both")
limits = {name: max(1, int(limit)) for name, limit in (lane_limits or {}).items()}
lane_of = lane or (lambda _item: "")
if capacity_keys is not None:
limits = dict(capacity_limits or {})
if any(type(limit) is not int or limit < 1 for limit in limits.values()):
raise ValueError("capacity limits must be positive integers")
def keys_of(item: T) -> tuple[str, ...]:
return tuple(dict.fromkeys(capacity_keys(item))) if capacity_keys else (lane_of(item),)
def expires_at(entry: _Pending[T], submitted_at: float) -> float:
# Mixed deadlines belong to the logical document, including innocent preemptions.
# Legacy shared-timeout mode deliberately retains its original per-submission clock.
return entry.deadline if task_timeout_s is not None else submitted_at + item_timeout_s
stats = PoolStats(workers=workers)
started = time.monotonic()
pending: deque[_Pending[T]] = deque(_Pending(item) for item in items)
stopping = False
def _next_submittable(running_lanes: dict[str, int]) -> _Pending[T] | None:
for index, entry in enumerate(pending):
if all(running_lanes.get(name, 0) < limits.get(name, workers)
for name in keys_of(entry.item)):
del pending[index]
return entry
return None
def _finish(item: T, result: R) -> None:
record(item, result)
stats.completed += 1
for name in keys_of(item):
stats.completed_by_capacity[name] = stats.completed_by_capacity.get(name, 0) + 1
stats.last_completion_s[name] = time.monotonic() - started
while pending and not stopping:
pool = ProcessPoolExecutor(
max_workers=workers,
max_tasks_per_child=max_tasks_per_child,
initializer=initializer,
initargs=initargs,
)
in_flight: dict[Future, tuple[_Pending[T], float]] = {}
running_lanes: dict[str, int] = {}
recycle = False
try:
while (pending and not stopping) or in_flight:
while not stopping and pending and len(in_flight) < workers:
if should_continue is not None and not should_continue():
stopping = True
break
entry = _next_submittable(running_lanes)
if entry is None:
break
if task_timeout_s is not None:
now = time.monotonic()
if entry.deadline is None:
entry.deadline = now + task_timeout_s(entry.item)
elif entry.deadline <= now:
stats.timeouts += 1
_finish(entry.item, on_timeout(entry.item))
continue
in_flight[pool.submit(work, entry.item)] = (entry, time.monotonic())
for name in keys_of(entry.item):
running_lanes[name] = running_lanes.get(name, 0) + 1
stats.peak_by_capacity[name] = max(stats.peak_by_capacity.get(name, 0), running_lanes[name])
stats.submitted_by_capacity[name] = stats.submitted_by_capacity.get(name, 0) + 1
stats.first_admission_s.setdefault(name, time.monotonic() - started)
stats.submitted += 1
stats.peak_in_flight = max(stats.peak_in_flight, len(in_flight))
if not in_flight:
break
next_deadline = min(expires_at(entry, submitted_at)
for entry, submitted_at in in_flight.values())
wait_s = max(0.0, next_deadline - time.monotonic())
finished, _ = wait(set(in_flight), timeout=wait_s, return_when=FIRST_COMPLETED)
broken: list[_Pending[T]] = []
for future in finished:
entry, _submitted_at = in_flight.pop(future)
for name in keys_of(entry.item):
running_lanes[name] -= 1
try:
result = future.result()
except BrokenProcessPool:
broken.append(entry)
continue
except Exception as exc: # noqa: BLE001 -- work() raised inside the worker
stats.crashes += 1
_finish(entry.item, on_crash(entry.item, exc))
continue
_finish(entry.item, result)
if broken:
# Everything still in flight is lost with the pool too.
broken.extend(entry for entry, _submitted_at in in_flight.values())
in_flight.clear()
for entry in reversed(broken):
if entry.attempts < BROKEN_POOL_RETRIES:
entry.attempts += 1
pending.appendleft(entry)
else:
stats.crashes += 1
_finish(entry.item, on_crash(entry.item, BrokenProcessPool("worker process died")))
recycle = True
break
now = time.monotonic()
overdue = [
future for future, (entry, submitted_at) in in_flight.items()
if now >= expires_at(entry, submitted_at)
]
if overdue:
for future in overdue:
entry, _submitted_at = in_flight.pop(future)
stats.timeouts += 1
_finish(entry.item, on_timeout(entry.item))
for entry, _submitted_at in reversed(list(in_flight.values())):
pending.appendleft(entry)
in_flight.clear()
recycle = True
break
except BaseException:
_terminate(pool)
raise
if recycle:
stats.recycles += 1
_terminate(pool)
else:
pool.shutdown(wait=True)
stats.stopped_no_time = len(pending)
for entry in pending:
for name in keys_of(entry.item):
stats.stopped_by_capacity[name] = stats.stopped_by_capacity.get(name, 0) + 1
stats.wall_s = round(time.monotonic() - started, 1)
stats.peak_child_rss_mb = peak_child_rss_mb()
return stats