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
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
171 lines
5.7 KiB
Python
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."""
|