Files
stack/src/bcda/ui/runner.py
kert e31282c02d
Some checks failed
CI / lint (push) Successful in 51s
CI / test (push) Failing after 2m28s
CI / notebooks-smoke (push) Successful in 1m37s
Infra CI / zotero (push) Successful in 16s
Infra CI / mc (push) Failing after 32s
Infra CI / bcda (push) Successful in 1m31s
Infra CI / notebooks (push) Successful in 55s
Infra CI / docs (push) Successful in 1m40s
Infra CI / api (push) Successful in 1m4s
Infra CI / llm (push) Successful in 53s
CI / lint (pull_request) Successful in 45s
CI / test (pull_request) Failing after 2m33s
CI / notebooks-smoke (pull_request) Successful in 1m39s
Infra CI / notebooks (pull_request) Successful in 1m5s
Package Supply Chain / pkg-supply-chain (push) Successful in 1m30s
Infra CI / zotero (pull_request) Successful in 22s
Infra CI / llm (pull_request) Successful in 21s
Infra CI / mc (pull_request) Failing after 40s
Infra CI / docs (pull_request) Successful in 15s
Infra CI / api (pull_request) Successful in 15s
Infra CI / bcda (pull_request) Successful in 15s
feat(bcda): /metrics for the BCDA manager — OTel instruments on a second port, scraped by Prometheus
bcda.ui.metrics follows the #579 convention: perf.meter hands back an
OTel meter + PrometheusMetricReader when STACK_TELEMETRY is on and a
no-op MockMeter otherwise. Streamlit cannot mount a route and only
executes the app script when a browser session opens, so the registry is
published from a second port (BCDA_METRICS_PORT, 8502) by a new
`python -m bcda.ui` entrypoint that starts the metrics server first and
then boots Streamlit's CLI in the same process; the image CMD and
`stack bcda ui` use it. Prometheus scrapes bcda:8502/metrics directly
(infra/prometheus/targets/services.yml) like api and llm.

Instruments: stack_bcda_runs_total{kind,state}, stack_bcda_runs_inflight
{kind}, stack_bcda_run_seconds{kind} (recorded by the runner, which now
labels runs export / adopt / load / vendor_check / other),
stack_bcda_page_views_total{page}, and two observable gauges read at
scrape time, stack_bcda_store_jobs{status} and stack_bcda_store_bytes.
Attribute values are closed sets. The image installs the perf extra;
the compose service joins the observability network with
OTEL_SERVICE_NAME=bcda. polars joins the bcda extra: the views import it,
and it had only been a dev dependency, so the first metrics image
crash-looped on `No module named polars` the moment the entrypoint
imported the app at process start.

Verified in the stack: bcda:8502/metrics serves stack_bcda_* series,
the prometheus target is up and stack_bcda_store_jobs answers a query.

refs #1139
2026-10-25 14:40:31 -04:00

171 lines
5.7 KiB
Python

"""Background runs for the BCDA manager UI.
Streamlit re-executes the script on every interaction, so anything that
takes longer than a click (a sandbox export, a 2 GB download, a flatten
+ load) runs in a daemon thread and is tracked here, in a process-level
registry that survives reruns. Each run captures the ``bcda`` logger
lines emitted on its own thread (job started, progress, files stored),
so the page can show what the client is doing without the client
knowing about the UI.
run = runner.submit("export Patient", store.export, client, types=["Patient"])
...
runner.get(run.id).state # queued · running · done · failed
runner.get(run.id).log # ["Export ABC started", "Job in progress: 35%", ...]
"""
from __future__ import annotations
import logging
import threading
import time
import traceback
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
_LOG_LIMIT = 400
@dataclass
class Run:
"""One background job and what it has said so far."""
id: str
name: str
kind: str = "other"
state: str = "queued"
started: float = field(default_factory=time.time)
finished: float | None = None
result: Any = None
error: str | None = None
log: list[str] = field(default_factory=list)
thread_id: int | None = None
@property
def elapsed(self) -> float:
return (self.finished or time.time()) - self.started
@property
def active(self) -> bool:
return self.state in ("queued", "running")
def append(self, line: str) -> None:
self.log.append(line)
if len(self.log) > _LOG_LIMIT:
del self.log[: len(self.log) - _LOG_LIMIT]
class _ThreadLogHandler(logging.Handler):
"""Route ``bcda.*`` log records to the Run owning the emitting thread."""
def __init__(self, registry: Runner) -> None:
super().__init__(level=logging.DEBUG)
self._registry = registry
def emit(self, record: logging.LogRecord) -> None:
run = self._registry._by_thread.get(record.thread)
if run is not None:
run.append(record.getMessage())
class Runner:
"""Process-level registry of background runs."""
def __init__(self) -> None:
self._runs: dict[str, Run] = {}
self._by_thread: dict[int, Run] = {}
self._lock = threading.Lock()
self._handler: _ThreadLogHandler | None = None
# ── logging capture ──────────────────────────────────────
def _ensure_handler(self) -> None:
if self._handler is None:
self._handler = _ThreadLogHandler(self)
logger = logging.getLogger("bcda")
logger.addHandler(self._handler)
if logger.level == logging.NOTSET or logger.level > logging.DEBUG:
logger.setLevel(logging.DEBUG)
# ── API ──────────────────────────────────────────────────
def submit(
self,
name: str,
fn: Callable[..., Any],
*args: Any,
kind: str = "other",
**kwargs: Any,
) -> Run:
"""Start *fn* on a daemon thread; return its Run immediately.
``kind`` labels the run for metrics (export, adopt, load,
vendor_check, other) — see :mod:`bcda.ui.metrics`.
"""
from bcda.ui import metrics
self._ensure_handler()
run = Run(id=uuid.uuid4().hex[:8], name=name, kind=kind)
with self._lock:
self._runs[run.id] = run
def _target() -> None:
run.thread_id = threading.get_ident()
with self._lock:
self._by_thread[run.thread_id] = run
run.state = "running"
run.append(f"started: {name}")
metrics.run_started(kind)
# The state flips last: ``wait()`` and the page read ``state``
# without a lock, so result, log and finish time must already be
# in place when they see done/failed.
try:
run.result = fn(*args, **kwargs)
run.append("finished")
run.finished = time.time()
run.state = "done"
except Exception as exc: # noqa: BLE001 — surfaced to the page
run.error = f"{type(exc).__name__}: {exc}"
run.append(run.error)
run.append(traceback.format_exc(limit=3))
run.finished = time.time()
run.state = "failed"
finally:
metrics.run_finished(kind, run.state, run.elapsed)
with self._lock:
self._by_thread.pop(run.thread_id, None)
threading.Thread(target=_target, name=f"bcda-ui-{run.id}", daemon=True).start()
return run
def get(self, run_id: str) -> Run | None:
return self._runs.get(run_id)
def all(self) -> list[Run]:
"""Newest first."""
return sorted(self._runs.values(), key=lambda r: r.started, reverse=True)
def active(self) -> list[Run]:
return [r for r in self.all() if r.active]
def wait(self, run_id: str, timeout: float = 30.0) -> Run:
"""Block until the run leaves queued/running (tests and the CLI)."""
run = self._runs[run_id]
deadline = time.time() + timeout
while run.active and time.time() < deadline:
time.sleep(0.02)
return run
def clear_finished(self) -> int:
with self._lock:
gone = [k for k, r in self._runs.items() if not r.active]
for k in gone:
del self._runs[k]
return len(gone)
runner = Runner()
"""The registry the UI and CLI share."""