Files
eco/tests/test_status_server.py
lemke_handClaude Sonnet 5 e876c1af4f FS-based status_server settings, per-channel size projection, required-filter for monitors
Three follow-ups to the status_server namespace component and
recording feature:

1. StatusServer's recording settings are now AdjustableFS, not
   AdjustableMemory - each a small JSON file under the same shared
   eco_cnf_bernina/configuration tree channels_JF/config_JFs already
   use. The point: a setting changed in one process is visible to any
   other process reading the same filesystem (another session, or the
   status server itself) with no REST call or other new communication
   - confirmed with a test constructing two independent StatusServer
   instances against the same config_dir.

2. RecordingSession.size_report(): per-channel projected bandwidth -
   rate (this channel's own point count over elapsed time) times a
   sample value's size (element count * dtype itemsize, "frequency *
   data shape * bitdepth") - to judge storage/bandwidth cost from a
   short trial recording before committing to a long one. Works on a
   still-running recording. Exposed via GET /recording/<id>?size=1
   (optional &size_top_n=N) and StatusServerClient.recording(...,
   size=True).

3. failed_required for recordings: a channel that fails to attach and
   belongs to a required namespace component (namespace.required_names())
   is now reported separately in a recording's report() - the same
   "everyone else failing is expected, this failing is not" distinction
   /health's failed_required already makes for init failures. New
   client-side warn_recording_failed_required (red, but does not
   suggest reinit() the way the health one does - a demoted/
   disconnected channel isn't fixed by rebuilding the namespace),
   wired into Daq.start_scan_monitoring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 17:34:49 +02:00

2269 lines
83 KiB
Python

"""Tests for the namespace-hosted status server (eco/status_server/).
Everything here runs against a fake namespace object that mimics the parts
of eco.utilities.config.Namespace the store actually uses - no EPICS, no
eco.bernina import - so the state machine, the REST contract and the daq
client's fallback behaviour are testable without the beamline.
"""
import json
import sys
import threading
import time
import types
import pytest
from eco.status_server import namespace_store as ns_store
from eco.status_server.config import NamespaceServerConfig
from eco.status_server.namespace_server import create_namespace_app
from eco.status_server.namespace_store import NamespaceMonitorStore, NotReady
from eco.status_server.storage import write_status_snapshot
# --------------------------------------------------------------------------
# fake namespace
class FakeAlias:
def __init__(self, full_name, channel=None):
self._full_name = full_name
self.channel = channel
def get_full_name(self, base=None, joiner="."):
return self._full_name
class FakeDetector:
def __init__(self, full_name, value, channel=None):
self.alias = FakeAlias(full_name, channel)
self.name = full_name
self._value = value
def get_current_value(self):
return self._value
class FakeStatusCollection:
def __init__(self, items):
self._items = items
def get_list(self, selection=None, **kwargs):
return list(self._items)
class FakeNamespaceAlias:
"""Enough of eco.aliases.aliases.Alias.get_all() for the /aliases route:
a flat list built from whatever detectors this fake namespace holds,
same shape as the real Namespace.alias.get_all()."""
def __init__(self, detectors):
self._detectors = detectors
def get_all(self, joiner=".", channeltypes=None):
out = []
for d in self._detectors:
entry = {
"alias": d.alias.get_full_name(joiner=joiner),
"channel": d.alias.channel,
"channeltype": "CA",
}
if (not channeltypes) or (entry["channeltype"] in channeltypes):
out.append(entry)
return out
class FakeNamespace:
"""Enough of Namespace for NamespaceMonitorStore."""
def __init__(self, names=("a", "b", "c"), required=("a", "b"), init_delay=0.0,
fail=()):
self.all_names = set(names)
self.initialized_names = set()
self.failed_names = set()
self.lazy_names = set(names)
self.failed_items_excpetion = {}
self._required = list(required)
self._init_delay = init_delay
self._fail = set(fail)
self.init_calls = []
self.reinit_calls = []
self.status_collection = FakeStatusCollection(
[
FakeDetector(f"fake.{n}", n.upper(), channel=f"PV:{n.upper()}")
for n in sorted(names)
]
)
self.alias = FakeNamespaceAlias(self.status_collection._items)
def required_names(self):
return list(self._required)
def init_all(self, required_only=True, exclude_names=(), max_workers=1,
background=False, silent=True, **kwargs):
self.init_calls.append(
{"exclude_names": sorted(exclude_names), "max_workers": max_workers,
"background": background}
)
targets = self.all_names - set(exclude_names)
for name in sorted(targets):
if self._init_delay:
time.sleep(self._init_delay)
if name in self._fail:
self.failed_names.add(name)
self.failed_items_excpetion[name] = ValueError(f"{name} boom")
else:
self.initialized_names.add(name)
self.lazy_names.discard(name)
def reinitialize(self, *names, verbose=True, raise_errors=False,
reload_modules=False):
self.reinit_calls.append({"names": list(names), "reload": reload_modules})
for name in names:
self.failed_names.discard(name)
self.failed_items_excpetion.pop(name, None)
self.lazy_names.add(name)
return {n: True for n in names}
def get_status(self, base=None, raise_on_incomplete=True, threads=True,
max_workers=20):
dets = self.status_collection.get_list()
return {
"status": {d.alias.get_full_name(): d.get_current_value() for d in dets},
"status_channels": {
d.alias.get_full_name(): d.alias.channel for d in dets
},
"status_times": {d.alias.get_full_name(): 0.001 for d in dets},
"selections": {},
}
@pytest.fixture
def fake_module(request):
"""Register a throwaway module holding a FakeNamespace, so the store's
importlib.import_module(module_name) path is exercised for real."""
made = []
def _make(name="eco_fake_ns_module", **kwargs):
mod = types.ModuleType(name)
mod.namespace = FakeNamespace(**kwargs)
sys.modules[name] = mod
made.append(name)
return name, mod
yield _make
for name in made:
sys.modules.pop(name, None)
def _store(module_name, **kwargs):
kwargs.setdefault("init_required_only", False)
return NamespaceMonitorStore(module_name, **kwargs)
# --------------------------------------------------------------------------
# store lifecycle
def test_store_construction_does_not_block_and_reaches_ready(fake_module):
name, mod = fake_module()
store = _store(name)
assert store.wait_ready(timeout=10)
assert store.state == ns_store.READY
assert store.generation == 1
assert mod.namespace.initialized_names == {"a", "b", "c"}
def test_store_reports_progress_while_initializing(fake_module):
name, mod = fake_module(names=[f"n{i}" for i in range(10)], init_delay=0.05)
store = _store(name)
seen_initializing = False
for _ in range(200):
rep = store.connection_report()
if rep["state"] == ns_store.INITIALIZING:
seen_initializing = True
assert rep["ready"] is False
assert rep["n_target_names"] == 10
break
if rep["state"] == ns_store.READY:
break
time.sleep(0.01)
assert seen_initializing, "never observed the initializing state"
assert store.wait_ready(timeout=20)
assert store.connection_report()["n_initialized"] == 10
def test_snapshot_before_ready_raises_not_ready(fake_module):
name, _ = fake_module(names=[f"n{i}" for i in range(10)], init_delay=0.05)
store = _store(name)
with pytest.raises(NotReady) as info:
store.snapshot()
assert info.value.state in (ns_store.IMPORTING, ns_store.INITIALIZING)
assert store.wait_ready(timeout=20)
def test_failed_import_leaves_store_failed_but_alive(fake_module):
store = NamespaceMonitorStore("eco_module_that_does_not_exist", start=True)
for _ in range(200):
if store.state == ns_store.FAILED:
break
time.sleep(0.01)
assert store.state == ns_store.FAILED
assert "ModuleNotFoundError" in store.last_error
# /health-equivalent still answers rather than the process dying
assert store.connection_report()["state"] == ns_store.FAILED
def test_snapshot_matches_get_status_shape(fake_module):
name, _ = fake_module()
store = _store(name)
assert store.wait_ready(timeout=10)
snap = store.snapshot()
assert snap["status"] == {"fake.a": "A", "fake.b": "B", "fake.c": "C"}
assert snap["status_channels"]["fake.a"] == "PV:A"
assert set(("status", "status_channels", "status_times", "selections")) <= set(snap)
assert snap["mode"] == "get_status"
def test_target_names_from_required_without_writing_required_names(fake_module):
name, mod = fake_module()
store = NamespaceMonitorStore(name, init_required_only=True)
assert store.wait_ready(timeout=10)
assert store._target_names == {"a", "b"}
assert mod.namespace.init_calls[0]["exclude_names"] == ["c"]
# the shared required_names() list itself must be untouched
assert mod.namespace.required_names() == ["a", "b"]
def test_explicit_names_and_exclusions(fake_module):
name, mod = fake_module()
store = NamespaceMonitorStore(
name, names=["a", "b", "c", "nonexistent"], exclude_names=["b"]
)
assert store.wait_ready(timeout=10)
assert store._target_names == {"a", "c"}
assert mod.namespace.init_calls[0]["exclude_names"] == ["b"]
def test_init_workers_are_passed_through(fake_module):
name, mod = fake_module()
store = _store(name, init_workers=6)
assert store.wait_ready(timeout=10)
assert mod.namespace.init_calls[0]["max_workers"] == 6
assert mod.namespace.init_calls[0]["background"] is False
# --------------------------------------------------------------------------
# reinit
def test_reinit_failed_only_touches_failed_names(fake_module):
name, mod = fake_module(fail=["b"])
store = _store(name)
assert store.wait_ready(timeout=10)
assert store.connection_report()["failed_names"] == ["b"]
store.start_reinit(mode="failed")
assert store.wait_ready(timeout=10)
assert mod.namespace.reinit_calls[-1]["names"] == ["b"]
assert store.generation == 2
def test_reinit_full_rebuilds_target_set(fake_module):
name, mod = fake_module()
store = _store(name)
assert store.wait_ready(timeout=10)
store.start_reinit(mode="full", reload_modules=True)
assert store.wait_ready(timeout=10)
assert mod.namespace.reinit_calls[-1]["names"] == ["a", "b", "c"]
assert mod.namespace.reinit_calls[-1]["reload"] is True
def test_reinit_rejects_unknown_mode(fake_module):
name, _ = fake_module()
store = _store(name)
assert store.wait_ready(timeout=10)
store.start_reinit(mode="nonsense")
# the worker records the failure and returns to a usable state rather
# than leaving the store stuck busy
for _ in range(200):
if store.state == ns_store.READY:
break
time.sleep(0.01)
assert store.state == ns_store.READY
assert "unknown reinit mode" in store.last_error
def test_reinit_while_busy_raises(fake_module):
name, _ = fake_module(names=[f"n{i}" for i in range(10)], init_delay=0.05)
store = _store(name)
with pytest.raises(NotReady):
store.start_reinit(mode="failed")
assert store.wait_ready(timeout=20)
def test_reimport_builds_a_new_namespace_object(fake_module, monkeypatch):
name, mod = fake_module()
store = _store(name)
assert store.wait_ready(timeout=10)
first = store.namespace
replacement = FakeNamespace()
# Patch the whole step: the real one purges every eco.* module from
# sys.modules, which would leave the rest of this pytest session with a
# second copy of eco's classes (see purge_eco_modules' docstring - it is
# tested separately below, against a throwaway module table).
def _reimport():
store.namespace = replacement
store._target_names = store._resolve_target_names(replacement)
monkeypatch.setattr(store, "_reimport_namespace", _reimport)
store.start_reinit(mode="reimport")
assert store.wait_ready(timeout=10)
assert store.namespace is replacement
assert store.namespace is not first
assert store.generation == 2
assert replacement.initialized_names == {"a", "b", "c"}
def test_new_names_widen_the_target_set_on_reinit(fake_module):
name, mod = fake_module()
store = NamespaceMonitorStore(name, names=["a"])
assert store.wait_ready(timeout=10)
assert store._target_names == {"a"}
store.start_reinit(mode="init", new_names=["a", "b", "c"])
assert store.wait_ready(timeout=10)
assert store._target_names == {"a", "b", "c"}
assert mod.namespace.initialized_names == {"a", "b", "c"}
# --------------------------------------------------------------------------
# REST layer
@pytest.fixture
def app_and_store(fake_module):
name, mod = fake_module()
store = _store(name)
assert store.wait_ready(timeout=10)
config = NamespaceServerConfig(module_name=name)
app = create_namespace_app(config, store=store)
app.config["TESTING"] = True
return app, store, mod
def test_health_reports_ready_state(app_and_store):
app, store, _ = app_and_store
body = app.test_client().get("/health").get_json()
assert body["ready"] is True
assert body["status"] == "ok"
assert body["state"] == "ready"
assert body["generation"] == 1
assert body["pid"] > 0
assert body["instance_id"]
def test_snapshot_endpoint_returns_status(app_and_store):
app, _, _ = app_and_store
body = app.test_client().post("/status/snapshot", json={}).get_json()
assert body["status"]["fake.a"] == "A"
assert body["namespace"]
def test_snapshot_endpoint_503_while_not_ready(fake_module):
name, _ = fake_module(names=[f"n{i}" for i in range(10)], init_delay=0.05)
store = _store(name)
app = create_namespace_app(NamespaceServerConfig(module_name=name), store=store)
resp = app.test_client().post("/status/snapshot", json={})
assert resp.status_code == 503
body = resp.get_json()
assert body["status"] == "error"
assert body["state"] in (ns_store.IMPORTING, ns_store.INITIALIZING)
assert store.wait_ready(timeout=20)
def test_snapshot_save_writes_status_file(app_and_store, tmp_path):
app, _, _ = app_and_store
app.config["ECO_CONFIG"].data_root_pattern = (
str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
)
body = app.test_client().post(
"/status/snapshot",
json={"save": True, "pgroup": "p12345", "run_number": 7},
).get_json()
path = tmp_path / "p12345" / "run0007" / "aux" / "status.json"
assert body["saved_to"] == str(path)
written = json.loads(path.read_text())
assert set(written) == {"status_run_start"}
assert written["status_run_start"]["status"]["fake.a"] == "A"
# server-only bookkeeping must not leak into the on-disk file
assert "generation" not in written["status_run_start"]
assert "snapshot_seconds" not in written["status_run_start"]
def test_snapshot_save_merges_run_end_into_the_same_file(app_and_store, tmp_path):
app, _, _ = app_and_store
app.config["ECO_CONFIG"].data_root_pattern = (
str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
)
client = app.test_client()
for key in ("status_run_start", "status_run_end"):
client.post(
"/status/snapshot",
json={"save": True, "pgroup": "p1", "run_number": 1, "key": key},
)
written = json.loads(
(tmp_path / "p1" / "run0001" / "aux" / "status.json").read_text()
)
assert set(written) == {"status_run_start", "status_run_end"}
def test_snapshot_save_requires_pgroup_and_run_number(app_and_store):
app, _, _ = app_and_store
resp = app.test_client().post("/status/snapshot", json={"save": True})
assert resp.status_code == 400
def test_async_write_job_completes(app_and_store, tmp_path):
app, _, _ = app_and_store
app.config["ECO_CONFIG"].data_root_pattern = (
str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
)
client = app.test_client()
body = client.post(
"/status/snapshot",
json={"save": True, "pgroup": "p1", "run_number": 2, "write_async": True},
).get_json()
job_id = body["write_job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
assert job["state"] == "done"
assert (tmp_path / "p1" / "run0002" / "aux" / "status.json").exists()
def test_unknown_job_id_is_404(app_and_store):
app, _, _ = app_and_store
assert app.test_client().get("/status/job/nope").status_code == 404
def test_names_and_failures_endpoints(fake_module):
name, _ = fake_module(fail=["b"])
store = _store(name)
assert store.wait_ready(timeout=10)
app = create_namespace_app(NamespaceServerConfig(module_name=name), store=store)
client = app.test_client()
names = client.get("/names").get_json()
assert names["target_names"] == ["a", "b", "c"]
assert names["failed_names"] == ["b"]
failures = client.get("/failures").get_json()["failures"]
assert "b boom" in failures["b"]
def test_monitor_policy_endpoint_reports_ca_tuning_state(app_and_store):
app, _, _ = app_and_store
body = app.test_client().get("/monitor_policy").get_json()
assert "max_rate_hz" in body
assert "auto_monitor_default" in body
assert "known_fast" in body
def test_aliases_endpoint_returns_the_alias_list(app_and_store):
app, _, _ = app_and_store
body = app.test_client().get("/aliases").get_json()
assert body["n_aliases"] == 3
assert {"alias": "fake.a", "channel": "PV:A", "channeltype": "CA"} in body["aliases"]
def test_aliases_endpoint_filters_by_channeltype(app_and_store):
app, _, _ = app_and_store
body = app.test_client().get("/aliases?channeltype=BS").get_json()
assert body["aliases"] == []
assert body["n_aliases"] == 0
body = app.test_client().get("/aliases?channeltype=CA").get_json()
assert body["n_aliases"] == 3
def test_aliases_endpoint_503_while_not_ready(fake_module):
name, _ = fake_module(names=[f"n{i}" for i in range(10)], init_delay=0.05)
store = _store(name)
app = create_namespace_app(NamespaceServerConfig(module_name=name), store=store)
resp = app.test_client().get("/aliases")
assert resp.status_code == 503
assert resp.get_json()["status"] == "error"
assert store.wait_ready(timeout=20)
def test_reinit_endpoint_202_then_409_while_busy(fake_module):
name, _ = fake_module(names=[f"n{i}" for i in range(20)], init_delay=0.03)
store = _store(name)
assert store.wait_ready(timeout=30)
app = create_namespace_app(NamespaceServerConfig(module_name=name), store=store)
client = app.test_client()
first = client.post("/admin/reinit", json={"mode": "full"})
assert first.status_code == 202
second = client.post("/admin/reinit", json={"mode": "full"})
assert second.status_code == 409
assert store.wait_ready(timeout=60)
# --------------------------------------------------------------------------
# storage
def test_write_status_snapshot_accepts_get_status_shape(tmp_path):
snap = {"status": {"a": 1}, "status_channels": {"a": "PV:A"},
"status_times": {"a": 0.1}, "selections": {}}
path = write_status_snapshot(tmp_path, snap)
assert json.loads(path.read_text())["status_run_start"]["status"] == {"a": 1}
def test_write_status_snapshot_accepts_flat_registry_shape(tmp_path):
snap = {"a": {"value": 1, "pvname": "PV:A"}}
path = write_status_snapshot(tmp_path, snap)
written = json.loads(path.read_text())["status_run_start"]
assert written["status"] == {"a": 1}
assert written["status_channels"] == {"a": "PV:A"}
def test_write_status_snapshot_survives_a_corrupt_existing_file(tmp_path):
(tmp_path / "status.json").write_text("{not json")
snap = {"status": {"a": 1}, "status_channels": {}}
path = write_status_snapshot(tmp_path, snap)
assert json.loads(path.read_text())["status_run_start"]["status"] == {"a": 1}
# --------------------------------------------------------------------------
# StatusServerClient against a real socket
@pytest.fixture
def live_server(fake_module):
"""Run the Flask app on a real port so StatusServerClient's actual HTTP
calls (not a test client) are exercised."""
from werkzeug.serving import make_server
name, mod = fake_module(names=[f"n{i}" for i in range(6)], init_delay=0.1)
store = _store(name)
config = NamespaceServerConfig(module_name=name, port=0)
app = create_namespace_app(config, store=store)
server = make_server("127.0.0.1", 0, app, threaded=True)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url = f"http://127.0.0.1:{server.server_port}"
try:
yield url, store, app, mod
finally:
server.shutdown()
thread.join(timeout=5)
def test_client_wait_ready_reports_progress_then_succeeds(live_server, capsys):
from eco.status_server.client import StatusServerClient
url, store, _, _ = live_server
client = StatusServerClient(url)
assert client.is_ready() is False
health = client.wait_ready(timeout=30, poll=0.05, progress=True)
assert health["ready"] is True
out = capsys.readouterr().out
assert "initializing" in out
assert "status server ready" in out
def test_client_snapshot_raises_not_ready_before_init_finishes(live_server):
from eco.status_server.client import StatusServerClient, StatusServerNotReady
url, store, _, _ = live_server
client = StatusServerClient(url)
with pytest.raises(StatusServerNotReady):
client.get_status()
client.wait_ready(timeout=30, poll=0.05)
assert client.get_status()["status"]["fake.n0"] == "N0"
def test_client_reinit_waits_for_the_new_generation(live_server):
from eco.status_server.client import StatusServerClient
url, store, _, _ = live_server
client = StatusServerClient(url)
client.wait_ready(timeout=30, poll=0.05)
gen_before = client.health()["generation"]
health = client.reinit(mode="full", wait=True, timeout=60, progress=False)
assert health["generation"] == gen_before + 1
assert health["ready"] is True
def test_client_reinit_wait_does_not_return_on_the_pre_reinit_state(live_server):
"""The bug this guards: a reinit request returns immediately, so a naive
'poll until ready' can be answered by the still-ready old state before
the rebuild has even started."""
from eco.status_server.client import StatusServerClient
url, store, _, _ = live_server
client = StatusServerClient(url)
client.wait_ready(timeout=30, poll=0.05)
gen_before = client.health()["generation"]
health = client.reinit(mode="full", wait=True, timeout=60)
assert health["generation"] > gen_before
# the fake namespace re-runs its 0.1 s-per-name init, so a return on the
# stale state would have been visibly too fast
assert store.last_init_seconds >= 0.5
def test_client_snapshot_save_and_async_job(live_server, tmp_path):
from eco.status_server.client import StatusServerClient
url, store, app, _ = live_server
app.config["ECO_CONFIG"].data_root_pattern = (
str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
)
client = StatusServerClient(url)
client.wait_ready(timeout=30, poll=0.05)
resp = client.snapshot(pgroup="p1", run_number=3, save=True)
assert json.loads(open(resp["saved_to"]).read())["status_run_start"]["status"]
resp = client.snapshot(
pgroup="p1", run_number=4, save=True, key="status_run_end", write_async=True
)
job = client.wait_write_job(resp["write_job_id"], timeout=10)
assert job["state"] == "done"
assert (tmp_path / "p1" / "run0004" / "aux" / "status.json").exists()
def test_client_push_status_merges_into_a_real_capture_job(live_server, tmp_path):
from eco.status_server.client import StatusServerClient
url, store, app, _ = live_server
app.config["ECO_CONFIG"].data_root_pattern = (
str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
)
client = StatusServerClient(url)
client.wait_ready(timeout=30, poll=0.05)
started = client.capture(pgroup="p1", run_number=5, upload=False,
keep_status=True)
client.wait_write_job(started["job_id"], timeout=10)
resp = client.push_status(
"p1", 5, {"scans.acquiring_scan.description": "a scan"}
)
assert resp["status"] == "ok"
job = client.wait_write_job(started["job_id"], timeout=10, include_status=True)
assert job["status"]["scans.acquiring_scan.description"] == "a scan"
assert job["status"]["fake.n0"] == "N0"
def test_client_push_status_serializes_datetime_and_numpy_values(live_server):
"""Real bug, hit live on a real test scan: requests.post(json=body)
serializes with plain stdlib json.dumps, which has no encoder for a
datetime (Daq._push_acquiring_scan_status used to hand one over
directly) or a numpy scalar - both used to raise TypeError inside
requests' own prepare_body, before the client ever got a response back
to fall back gracefully from."""
import datetime
import numpy as np
from eco.status_server.client import StatusServerClient
url, store, app, _ = live_server
client = StatusServerClient(url)
client.wait_ready(timeout=30, poll=0.05)
resp = client.push_status(
"p1",
7,
{
"scans.acquiring_scan.start_time": datetime.datetime(2026, 1, 1),
"scans.acquiring_scan.initial_values": [np.float64(1.5), np.int32(3)],
},
)
assert resp["status"] == "ok"
pushed = store.pop_pushed_status("p1", 7, "status_run_start")
assert pushed["scans.acquiring_scan.initial_values"] == [1.5, 3]
assert isinstance(pushed["scans.acquiring_scan.start_time"], float)
def test_client_monitor_policy_round_trips(live_server):
from eco.status_server.client import StatusServerClient
url, store, app, _ = live_server
client = StatusServerClient(url)
client.wait_ready(timeout=30, poll=0.05)
body = client.monitor_policy()
assert "max_rate_hz" in body
def test_snapshot_endpoint_serializes_numpy_values(fake_module):
"""A waveform PV or an image stat returns a numpy array; Flask's default
JSON provider raises TypeError on those, which turned one odd value into
a 500 for the whole snapshot."""
import numpy as np
name, mod = fake_module()
mod.namespace.status_collection = FakeStatusCollection(
[
FakeDetector("fake.arr", np.arange(3)),
FakeDetector("fake.f", np.float64(1.5)),
FakeDetector("fake.weird", object()),
]
)
store = _store(name)
assert store.wait_ready(timeout=10)
app = create_namespace_app(NamespaceServerConfig(module_name=name), store=store)
resp = app.test_client().post("/status/snapshot", json={})
assert resp.status_code == 200
body = resp.get_json()
assert body["status"]["fake.arr"] == [0, 1, 2]
assert body["status"]["fake.f"] == 1.5
assert isinstance(body["status"]["fake.weird"], str)
def test_status_file_write_serializes_numpy_values(tmp_path):
import numpy as np
snap = {"status": {"a": np.arange(2), "b": np.float32(0.5)},
"status_channels": {}}
path = write_status_snapshot(tmp_path, snap)
assert json.loads(path.read_text())["status_run_start"]["status"]["a"] == [0, 1]
def test_restart_argv_reconstructs_the_dash_m_form(monkeypatch):
"""`python -m eco.status_server` puts __main__.py's *path* in argv[0];
re-execing that directly dies on the package's relative imports."""
import types as _types
from eco.status_server import namespace_server
fake_main = _types.ModuleType("__main__")
fake_main.__spec__ = _types.SimpleNamespace(
name="eco.status_server.__main__", parent="eco.status_server"
)
monkeypatch.setitem(sys.modules, "__main__", fake_main)
monkeypatch.setattr(
namespace_server.sys, "argv",
["/path/to/eco/status_server/__main__.py", "--mode", "namespace",
"--config", "/etc/x.json"],
)
argv = namespace_server._restart_argv()
assert argv[1:] == ["-m", "eco.status_server", "--mode", "namespace",
"--config", "/etc/x.json"]
def test_restart_argv_falls_back_for_a_plain_script(monkeypatch):
import types as _types
from eco.status_server import namespace_server
fake_main = _types.ModuleType("__main__")
fake_main.__spec__ = None
monkeypatch.setitem(sys.modules, "__main__", fake_main)
monkeypatch.setattr(namespace_server.sys, "argv", ["server.py", "--port", "1"])
assert namespace_server._restart_argv()[1:] == ["server.py", "--port", "1"]
def test_init_retries_names_that_were_never_really_attempted(fake_module):
"""A component slower than init_timeout ends up in failed_items either
with an IsInitialisingError or - the case seen on bernina - with no
exception at all, because init_all's giveup_failed sweeps up whatever is
still lazy when the retry cap is hit. Both are retried; a genuine
failure is not."""
from eco.utilities.config import IsInitialisingError
name, mod = fake_module()
ns = mod.namespace
real_init_all = ns.init_all
state = {"pass": 0}
def flaky_init_all(**kwargs):
state["pass"] += 1
real_init_all(**kwargs)
if state["pass"] == 1:
ns.initialized_names.discard("a")
ns.failed_names.add("a")
ns.failed_items_excpetion["a"] = IsInitialisingError("still building")
ns.initialized_names.discard("b")
ns.failed_names.add("b")
ns.failed_items_excpetion["b"] = ValueError("genuinely broken")
# no recorded exception at all - the giveup_failed sweep
ns.initialized_names.discard("c")
ns.failed_names.add("c")
ns.init_all = flaky_init_all
ns.move_failed_to_lazy = lambda *names: [
(ns.failed_names.discard(n), ns.failed_items_excpetion.pop(n, None),
ns.initialized_names.add(n)) for n in names
]
store = _store(name)
assert store.wait_ready(timeout=10)
assert state["pass"] == 2, "did not run a retry pass"
assert "a" in ns.initialized_names
assert "c" in ns.initialized_names
assert "b" in ns.failed_names, "a genuine failure must not be retried"
def test_create_app_can_defer_starting_the_store(fake_module):
"""__main__ binds the port before starting the namespace init, so a port
clash costs a failed bind instead of a wasted multi-minute init_all()."""
name, mod = fake_module()
app = create_namespace_app(
NamespaceServerConfig(module_name=name), start_store=False
)
store = app.config["ECO_STORE"]
assert store.state == ns_store.IMPORTING
assert store.namespace is None
assert mod.namespace.init_calls == []
store.start()
assert store.wait_ready(timeout=10)
assert mod.namespace.init_calls
def test_restart_closes_the_listening_socket_first(app_and_store, monkeypatch):
"""werkzeug's run_simple() marks the listening socket inheritable, so it
survives os.execv() and the re-exec'd process cannot rebind ("Address
already in use") - the restart has to hand the port back explicitly."""
from eco.status_server import namespace_server
app, _, _ = app_and_store
closed = []
execd = []
app.config["ECO_WSGI_SERVER"] = types.SimpleNamespace(
server_close=lambda: closed.append(True)
)
monkeypatch.setattr(namespace_server.os, "execv",
lambda exe, argv: execd.append(argv))
monkeypatch.setattr(namespace_server.time, "sleep", lambda s: None)
resp = app.test_client().post("/admin/restart", json={"delay": 0})
assert resp.status_code == 202
deadline = time.time() + 5
while time.time() < deadline and not execd:
time.sleep(0.01)
assert closed == [True]
assert execd, "never reached the exec"
def test_purge_eco_modules_keeps_the_status_server_itself():
from eco.status_server.namespace_store import purge_eco_modules
table = {
"eco": object(),
"eco.bernina.bernina": object(),
"eco.elements.assembly": object(),
"eco.status_server.namespace_store": object(),
"ecosystem": object(),
"numpy": object(),
}
assert purge_eco_modules(table) == 3
assert sorted(table) == [
"eco.status_server.namespace_store",
"ecosystem",
"numpy",
]
def test_status_file_and_every_directory_level_are_group_writable(tmp_path):
"""Not just the leaf: mkdir(parents=True) creates the intermediate levels
at 0o755, so the first account to start a run used to lock every other
account out of creating the next one."""
directory = tmp_path / "run_data" / "daq" / "run0001" / "aux"
path = write_status_snapshot(directory, {"status": {"a": 1}, "status_channels": {}})
assert path.stat().st_mode & 0o664 == 0o664
for level in (directory, directory.parent, directory.parent.parent):
assert level.stat().st_mode & 0o770 == 0o770, level
def test_retry_passes_run_serially(fake_module):
"""Workers colliding on a shared dependency is what makes a component a
straggler, so the retry pass that goes after those uses one worker."""
name, mod = fake_module()
ns = mod.namespace
real_init_all = ns.init_all
passes = []
def recording_init_all(**kwargs):
passes.append(kwargs["max_workers"])
real_init_all(**kwargs)
if len(passes) == 1:
ns.initialized_names.discard("a")
ns.failed_names.add("a")
ns.init_all = recording_init_all
ns.move_failed_to_lazy = lambda *names: [
(ns.failed_names.discard(n), ns.initialized_names.add(n)) for n in names
]
store = _store(name, init_workers=8, retry_workers=1)
assert store.wait_ready(timeout=10)
assert passes == [8, 1]
# --------------------------------------------------------------------------
# recording
class FakePV:
"""Stands in for the pyepics PV a CallbackEpics wraps - just enough for
the recording session's seed-if-already-monitored decision and the seed
read itself (pv.get()/pv.timestamp), same shape pyepics gives a real,
connected, auto_monitor=True PV where get() is a cached-value read with
no CA traffic."""
def __init__(self, pvname, value=0.0, auto_monitor=True, connected=True):
self.pvname = pvname
self.auto_monitor = auto_monitor
self.connected = connected
self.timestamp = time.time()
self._value = value
def get(self, **kwargs):
return self._value
class FakeMonitor:
"""Stands in for CallbackEpics: remembers how it was started and lets a
test push updates through the callback the store registered."""
instances = []
def __init__(self, func, pv=None):
self.func = func
self.pv = pv
self.started_with = None
self.stopped = False
FakeMonitor.instances.append(self)
def start(self, add_current_value=True, with_ctrlvars=True, auto_monitor=True):
self.started_with = {
"add_current_value": add_current_value,
"with_ctrlvars": with_ctrlvars,
"auto_monitor": auto_monitor,
}
# Same order as the real CallbackEpics.start(): seed before the
# subscription is (would be) registered.
if add_current_value and self.pv is not None:
self.func(pvname=self.pv.pvname, value=self.pv.get(),
timestamp=self.pv.timestamp)
def stop(self):
self.stopped = True
def push(self, value, timestamp=None):
self.func(value=value, timestamp=timestamp if timestamp is not None
else time.time())
class MonitorableDetector(FakeDetector):
def __init__(self, full_name, value, channel=None, pv=None):
super().__init__(full_name, value, channel=channel)
self._fake_pv = pv
def set_current_value_callback(self, func="accumulate", **kwargs):
return FakeMonitor(func, pv=self._fake_pv)
class AttachFailingDetector(FakeDetector):
"""Monitorable (has the method, so isinstance(ts, MonitorableValueUpdate)
is True and it lands in _monitorable) but attaching always fails - a
demoted/disconnected-channel stand-in, distinct from not being
monitorable at all."""
def set_current_value_callback(self, func="accumulate", **kwargs):
return None
@pytest.fixture
def recording_store(fake_module):
FakeMonitor.instances = []
name, mod = fake_module()
mod.namespace.status_collection = FakeStatusCollection(
[
MonitorableDetector("fake.m1", 1.0, channel="PV:M1"),
MonitorableDetector("fake.m2", 2.0, channel="PV:M2"),
FakeDetector("fake.plain", 3.0, channel="PV:P"), # not monitorable
]
)
store = _store(name)
assert store.wait_ready(timeout=10)
return store
def _recording_store_with(fake_module, detectors, **fake_module_kwargs):
"""Like `recording_store`, but with caller-chosen detectors - for tests
that need specific FakePV configurations (auto_monitor/connected) per
channel, or a specific required_names() (fake_module_kwargs, e.g.
required=(...))."""
FakeMonitor.instances = []
name, mod = fake_module(**fake_module_kwargs)
mod.namespace.status_collection = FakeStatusCollection(detectors)
store = _store(name)
assert store.wait_ready(timeout=10)
return store
def test_only_monitorable_detectors_are_recordable(recording_store):
assert recording_store.monitorable_names() == ["fake.m1", "fake.m2"]
assert recording_store.connection_report()["n_monitorable"] == 2
def test_recording_attaches_without_a_ca_get_storm(recording_store):
recording_store.start_recording("r1")
assert len(FakeMonitor.instances) == 2
for mon in FakeMonitor.instances:
# both would be a blocking CA round trip per channel at start
assert mon.started_with["add_current_value"] is False
assert mon.started_with["with_ctrlvars"] is False
def test_recording_mode_all_stores_every_update(recording_store):
recording_store.start_recording("r1")
for mon in FakeMonitor.instances:
for v in range(5):
mon.push(float(v))
result = recording_store.stop_recording("r1")
assert result["n_updates"] == 10
assert result["n_stored"] == 10
assert result["data"]["fake.m1"]["values"] == [0.0, 1.0, 2.0, 3.0, 4.0]
assert all(m.stopped for m in FakeMonitor.instances)
def test_recording_throttle_drops_updates_within_the_interval(recording_store):
recording_store.start_recording("r1", names=["fake.m1"], mode="throttle",
min_interval=10.0)
mon = FakeMonitor.instances[0]
for v in range(5):
mon.push(float(v))
result = recording_store.stop_recording("r1")
assert result["n_updates"] == 5
assert result["n_stored"] == 1
assert result["n_dropped_throttle"] == 4
def test_recording_sample_mode_uses_a_grid_not_the_update_rate(recording_store):
recording_store.start_recording("r1", names=["fake.m1"], mode="sample",
sample_interval=0.05)
mon = FakeMonitor.instances[0]
for v in range(200):
mon.push(float(v))
time.sleep(0.3)
result = recording_store.stop_recording("r1")
assert result["n_updates"] == 200
# a handful of grid points, nowhere near 200
assert 1 <= result["n_stored"] <= 20
assert result["data"]["fake.m1"]["values"][-1] == 199.0
# --------------------------------------------------------------------------
# seeding: a channel that is already auto_monitor=True and connected gets a
# free current-value point at attach time (pyepics' PV.get() is a cached
# read with no CA traffic there - see ca_tuning's "Resolved 2026-09-06"
# note), so a channel that never itself updates during the recording is not
# automatically empty.
def test_an_already_monitored_connected_channel_is_seeded_for_free(fake_module):
store = _recording_store_with(fake_module, [
MonitorableDetector("fake.m1", 1.0, channel="PV:M1",
pv=FakePV("PV:M1", value=42.0)),
])
result = store.start_recording("r1")
assert result.n_seeded == 1
assert FakeMonitor.instances[0].started_with["add_current_value"] is True
stopped = store.stop_recording("r1")
# seeded, never pushed again - one point, the seed value
assert stopped["data"]["fake.m1"]["values"] == [42.0]
def test_a_demoted_channel_is_not_seeded(fake_module):
"""auto_monitor=False (ca_tuning demoted it as too fast) means PV.get()
is a real CA round trip, not a cached read - skip the seed rather than
pay for thousands of those at once."""
store = _recording_store_with(fake_module, [
MonitorableDetector("fake.m1", 1.0, channel="PV:M1",
pv=FakePV("PV:M1", auto_monitor=False)),
])
result = store.start_recording("r1")
assert result.n_seeded == 0
assert FakeMonitor.instances[0].started_with["add_current_value"] is False
def test_a_disconnected_channel_is_not_seeded(fake_module):
store = _recording_store_with(fake_module, [
MonitorableDetector("fake.m1", 1.0, channel="PV:M1",
pv=FakePV("PV:M1", connected=False)),
])
result = store.start_recording("r1")
assert result.n_seeded == 0
def test_a_channel_with_no_pv_reference_is_not_seeded(recording_store):
"""The plain FakeMonitor(pv=None) case - e.g. a Monitorable whose
CallbackEpics wraps something other than a bare pyepics PV."""
result = recording_store.start_recording("r1")
assert result.n_seeded == 0
def test_seeding_still_respects_the_recording_mode(fake_module):
"""The seed goes through the same per-mode callback as any other
update - throttle/sample apply to it exactly like a real one."""
store = _recording_store_with(fake_module, [
MonitorableDetector("fake.m1", 1.0, channel="PV:M1",
pv=FakePV("PV:M1", value=7.0)),
])
store.start_recording("r1", mode="throttle", min_interval=10.0)
mon = FakeMonitor.instances[0]
mon.push(8.0) # within min_interval of the seed - dropped
stopped = store.stop_recording("r1")
assert stopped["data"]["fake.m1"]["values"] == [7.0]
assert stopped["n_dropped_throttle"] == 1
def test_a_composed_virtual_channel_is_seeded_for_free(fake_module):
"""DetectorVirtual/AdjustableVirtual (eco.elements) implement
set_current_value_callback() themselves when every one of their own
parents does - discovered here via the same isinstance(ts,
MonitorableValueUpdate) check that finds any other monitorable channel,
no special-casing needed. Their seed is a recompute from already-
monitored parents, not a CA get of its own - free the same way a plain
monitored PV's seed is."""
from eco.elements.detector import DetectorVirtual
a = MonitorableDetector("fake.a", 1.0, channel="PV:A")
b = MonitorableDetector("fake.b", 2.0, channel="PV:B")
composed = DetectorVirtual(
[a, b], foo_get_current_value=lambda x, y: x + y, name="fake.composed"
)
store = _recording_store_with(fake_module, [composed])
assert store.monitorable_names() == ["fake.composed"]
result = store.start_recording("r1")
assert result.n_seeded == 1
stopped = store.stop_recording("r1")
assert stopped["data"]["fake.composed"]["values"] == [3.0]
# --------------------------------------------------------------------------
# failed_required: the recording equivalent of /health's failed_required -
# a channel that fails to attach and belongs to a required namespace
# component is reported separately, same "expected vs not" distinction.
def test_recording_reports_failed_required_channels(fake_module):
store = _recording_store_with(
fake_module,
[
AttachFailingDetector("required_comp.a", 1.0, channel="PV:A"),
AttachFailingDetector("optional_comp.b", 2.0, channel="PV:B"),
MonitorableDetector("required_comp.c", 3.0, channel="PV:C"),
],
required=("required_comp",),
)
session = store.start_recording("r1")
rep = session.report()
assert rep["n_attach_failed"] == 2
assert rep["failed_required"] == ["required_comp.a"]
assert rep["n_failed_required"] == 1
def test_recording_failed_required_is_empty_when_nothing_required_failed(
fake_module
):
store = _recording_store_with(
fake_module,
[
AttachFailingDetector("optional_comp.b", 2.0, channel="PV:B"),
MonitorableDetector("required_comp.c", 3.0, channel="PV:C"),
],
required=("required_comp",),
)
session = store.start_recording("r1")
rep = session.report()
assert rep["failed_required"] == []
assert rep["n_failed_required"] == 0
# --------------------------------------------------------------------------
# size_report: projected bandwidth per channel (frequency * shape *
# bitdepth), to judge storage/bandwidth cost from a short trial recording
def test_size_report_computes_rate_times_value_size(recording_store):
import numpy as np
recording_store.start_recording("r1", names=["fake.m1"])
mon = FakeMonitor.instances[0]
for _ in range(10):
mon.push(np.zeros(4, dtype=np.float64)) # 4 elements * 8 bytes = 32
session = recording_store._recordings["r1"]
session.started_at -= 2.0 # pretend 2s elapsed, for an exact rate
rep = session.size_report()
assert rep["fake.m1"]["n_points"] == 10
assert rep["fake.m1"]["rate_hz"] == pytest.approx(5.0, rel=0.01)
assert rep["fake.m1"]["n_elements"] == 4
assert rep["fake.m1"]["bytes_per_point"] == 32
assert rep["fake.m1"]["projected_bytes_per_s"] == pytest.approx(160.0, rel=0.01)
def test_size_report_skips_channels_with_no_points_yet(recording_store):
recording_store.start_recording("r1")
session = recording_store._recordings["r1"]
assert session.size_report() == {}
def test_size_report_sorts_biggest_contributor_first(recording_store):
import numpy as np
recording_store.start_recording("r1")
m1, m2 = FakeMonitor.instances
m1.push(np.zeros(1)) # small
m2.push(np.zeros(1000)) # big
session = recording_store._recordings["r1"]
rep = session.size_report()
assert list(rep.keys())[0] == "fake.m2"
def test_size_report_top_n_truncates(recording_store):
import numpy as np
recording_store.start_recording("r1")
for mon in FakeMonitor.instances:
mon.push(np.zeros(1))
session = recording_store._recordings["r1"]
assert len(session.size_report(top_n=1)) == 1
def test_recording_report_endpoint_includes_size_on_request(
recording_store, tmp_path
):
import numpy as np
config = NamespaceServerConfig(module_name=recording_store.module_name)
app = create_namespace_app(config, store=recording_store)
client = app.test_client()
client.post("/recording/start", json={"recording_id": "r1"})
FakeMonitor.instances[0].push(np.zeros(4, dtype=np.float64))
plain = client.get("/recording/r1").get_json()
assert "size_per_channel" not in plain
sized = client.get("/recording/r1?size=1").get_json()
assert "fake.m1" in sized["size_per_channel"]
assert sized["size_per_channel"]["fake.m1"]["bytes_per_point"] == 32
def test_client_recording_size_round_trips(live_server):
"""live_server's FakeNamespace has no monitorable channels wired up for
recording, so this only checks the request shape/plumbing, not real
numbers - see the fake_module-based tests above for that."""
from eco.status_server.client import StatusServerClient
url, store, app, _ = live_server
client = StatusServerClient(url)
client.wait_ready(timeout=30, poll=0.05)
client.start_recording("r1")
body = client.recording("r1", size=True, size_top_n=5)
assert "size_per_channel" in body
# --------------------------------------------------------------------------
# backfill: opportunistically fill a still-empty channel from a status
# snapshot that was already being taken for another reason (a scan's own
# status_run_start/status_run_end capture) - never a trigger of new CA
# traffic on its own.
def test_backfill_from_status_fills_empty_channels_only(recording_store):
recording_store.start_recording("r1")
mon0 = FakeMonitor.instances[0] # fake.m1
mon0.push(5.0) # fake.m1 already has a real point - must not be touched
filled = recording_store._recordings["r1"].backfill_from_status(
{"fake.m1": 999.0, "fake.m2": 2.5, "fake.plain": 3.0}
)
assert filled == 1 # only fake.m2 was empty
stopped = recording_store.stop_recording("r1")
assert stopped["data"]["fake.m1"]["values"] == [5.0]
assert stopped["data"]["fake.m2"]["values"] == [2.5]
assert stopped["n_backfilled"] == 1
def test_backfill_ignores_channels_not_in_the_status_dict(recording_store):
recording_store.start_recording("r1")
filled = recording_store._recordings["r1"].backfill_from_status(
{"fake.m1": 1.0} # nothing for fake.m2
)
assert filled == 1
stopped = recording_store.stop_recording("r1")
assert "fake.m2" not in stopped["data"]
def test_backfill_ignores_a_none_value(recording_store):
recording_store.start_recording("r1")
filled = recording_store._recordings["r1"].backfill_from_status(
{"fake.m1": None, "fake.m2": 2.0}
)
assert filled == 1
stopped = recording_store.stop_recording("r1")
assert "fake.m1" not in stopped["data"]
def test_backfill_running_recordings_finds_the_matching_run(recording_store):
recording_store.start_recording(
"r1", pgroup="p1", run_number=5, names=["fake.m1"]
)
recording_store.start_recording(
"r2", pgroup="p1", run_number=6, names=["fake.m2"]
)
touched = recording_store.backfill_running_recordings(
"p1", 5, {"fake.m1": 1.0, "fake.m2": 2.0}
)
assert touched == 1 # only r1 matches (p1, 5)
assert recording_store._recordings["r1"].n_backfilled == 1
assert recording_store._recordings["r2"].n_backfilled == 0
def test_backfill_running_recordings_ignores_a_stopped_recording(recording_store):
recording_store.start_recording("r1", pgroup="p1", run_number=5)
recording_store.stop_recording("r1")
touched = recording_store.backfill_running_recordings(
"p1", 5, {"fake.m1": 1.0}
)
assert touched == 0
def test_backfill_running_recordings_is_a_noop_without_pgroup_or_run_number(
recording_store
):
recording_store.start_recording("r1") # no pgroup/run_number given
assert recording_store.backfill_running_recordings(
"p1", 5, {"fake.m1": 1.0}
) == 0
def test_status_capture_backfills_a_running_recording_for_the_same_run(
recording_store, tmp_path
):
"""The real end-to-end path: /status/capture's own snapshot (already
happening for other reasons) opportunistically fills in the recording's
still-empty channels for the same run - no extra CA traffic beyond what
the status capture was already doing."""
config = NamespaceServerConfig(module_name=recording_store.module_name)
config.data_root_pattern = str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
app = create_namespace_app(config, store=recording_store)
client = app.test_client()
client.post(
"/recording/start",
json={"recording_id": "r1", "pgroup": "p1", "run_number": 12},
)
job_id = client.post(
"/status/capture",
json={"pgroup": "p1", "run_number": 12, "upload": False},
).get_json()["job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
assert job["state"] == "done"
live = client.get("/recording/r1").get_json()
assert live["n_backfilled"] == 2 # fake.m1 and fake.m2, both still empty
def test_status_capture_does_not_backfill_a_recording_from_a_different_run(
recording_store, tmp_path
):
config = NamespaceServerConfig(module_name=recording_store.module_name)
config.data_root_pattern = str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
app = create_namespace_app(config, store=recording_store)
client = app.test_client()
client.post(
"/recording/start",
json={"recording_id": "r1", "pgroup": "p1", "run_number": 12},
)
job_id = client.post(
"/status/capture",
json={"pgroup": "p1", "run_number": 13, "upload": False}, # different run
).get_json()["job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
live = client.get("/recording/r1").get_json()
assert live["n_backfilled"] == 0
def test_recording_respects_the_point_cap(recording_store):
recording_store.start_recording("r1", names=["fake.m1"],
max_points_per_channel=3)
mon = FakeMonitor.instances[0]
for v in range(10):
mon.push(float(v))
result = recording_store.stop_recording("r1")
assert result["n_stored"] == 3
assert result["n_dropped_at_cap"] == 7
def test_second_start_with_the_same_id_is_refused(recording_store):
recording_store.start_recording("r1")
with pytest.raises(ValueError, match="already running"):
recording_store.start_recording("r1")
recording_store.stop_recording("r1")
def test_unknown_recording_name_is_rejected(recording_store):
with pytest.raises(KeyError):
recording_store.start_recording("r1", names=["fake.plain"])
def test_reinit_stops_a_running_recording(recording_store):
recording_store.start_recording("r1")
recording_store.start_reinit(mode="init")
assert recording_store.wait_ready(timeout=10)
assert all(m.stopped for m in FakeMonitor.instances)
assert recording_store.list_recordings()[0]["running"] is False
def test_recording_endpoints_round_trip(recording_store, tmp_path):
config = NamespaceServerConfig(module_name=recording_store.module_name)
config.data_root_pattern = str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
app = create_namespace_app(config, store=recording_store)
client = app.test_client()
assert client.get("/recording").get_json()["n_monitorable"] == 2
started = client.post("/recording/start", json={"recording_id": "r1"})
assert started.status_code == 202
assert started.get_json()["n_channels_attached"] == 2
for mon in FakeMonitor.instances:
for v in range(4):
mon.push(float(v))
live = client.get("/recording/r1").get_json()
assert live["running"] is True and live["n_updates"] == 8
stopped = client.post(
"/recording/stop",
json={"recording_id": "r1", "save": True, "pgroup": "p1", "run_number": 5},
).get_json()
assert stopped["status"] == "ok"
assert stopped["n_written"] == 2
path = tmp_path / "p1" / "run0005" / "aux" / "monitors.esc.h5"
assert stopped["saved_to"] == str(path)
assert path.exists()
# dropped by default, so the buffers are not held for the life of the
# process
assert client.get("/recording/r1").status_code == 404
def test_recording_stop_save_requires_pgroup(recording_store):
app = create_namespace_app(
NamespaceServerConfig(module_name=recording_store.module_name),
store=recording_store,
)
client = app.test_client()
client.post("/recording/start", json={"recording_id": "r1"})
resp = client.post("/recording/stop", json={"recording_id": "r1", "save": True})
assert resp.status_code == 400
def test_subscription_mask_log_maps_to_dbe_log(recording_store):
from epics.dbr import DBE_LOG
app = create_namespace_app(
NamespaceServerConfig(module_name=recording_store.module_name),
store=recording_store,
)
app.test_client().post(
"/recording/start",
json={"recording_id": "r1", "subscription_mask": "log"},
)
assert FakeMonitor.instances[0].started_with["auto_monitor"] == DBE_LOG
def test_monitor_recording_file_holds_one_arraytimestamps_per_channel(tmp_path):
import h5py
from eco.status_server.storage import write_monitor_recording
t0 = time.time()
rec = {
"started_at": t0,
"stopped_at": t0 + 1,
"data": {
"bernina.a": {"values": [1.0, 2.0], "timestamps": [t0, t0 + 0.5]},
"bernina.enum": {"values": ["Open", "Closed"], "timestamps": [t0, t0 + 0.5]},
},
}
path = write_monitor_recording(tmp_path, rec)
assert rec["write_report"]["n_written"] == 2
with h5py.File(path) as f:
assert f["bernina.a/data_0000"][()].tolist() == [1.0, 2.0]
assert f["bernina.a/timestamps_0000"].shape == (2,)
assert f["bernina.a/scan/timestamp_intervals"].shape == (1, 2)
def test_one_unstorable_channel_does_not_lose_the_file(tmp_path):
import numpy as np
from eco.status_server.storage import write_monitor_recording
t0 = time.time()
rec = {
"started_at": t0,
"stopped_at": t0 + 1,
"data": {
"bernina.ok": {"values": [1.0], "timestamps": [t0]},
# a waveform PV whose length changed between updates
"bernina.wave": {
"values": [np.arange(3), np.arange(4)],
"timestamps": [t0, t0 + 0.5],
},
},
}
path = write_monitor_recording(tmp_path, rec)
assert path.exists()
assert rec["write_report"]["n_written"] == 1
assert "bernina.wave" in rec["write_report"]["skipped"]
def test_health_reports_process_cost(app_and_store):
app, _, _ = app_and_store
body = app.test_client().get("/health").get_json()
# Linux-only, but that is where this runs; assert it is actually there
# rather than silently losing the one measurement that answers "is
# monitoring costing anything?"
assert body["cpu_seconds"] > 0
assert body["rss_mb"] > 0
assert body["n_threads"] >= 1
def test_stop_truncates_a_half_written_point(recording_store):
"""A callback caught between its value append and its timestamp append
leaves the two lists one apart; pairing them then shifts every
timestamp."""
recording_store.start_recording("r1", names=["fake.m1"])
mon = FakeMonitor.instances[0]
mon.push(1.0)
session = recording_store._recordings["r1"]
session._buffers["fake.m1"]["values"].append(2.0) # timestamp not yet appended
data = recording_store.stop_recording("r1")["data"]
assert len(data["fake.m1"]["values"]) == len(data["fake.m1"]["timestamps"]) == 1
def test_sample_mode_does_not_upsample_a_slow_channel(recording_store):
"""Sampling every channel on the grid turns a namespace of mostly-idle
channels into far more data than recording every update: measured on
bernina, 15x more. A slow channel must contribute points only when it
actually updates."""
recording_store.start_recording("r1", names=["fake.m1", "fake.m2"],
mode="sample", sample_interval=0.02)
fast, slow = FakeMonitor.instances
slow.push(1.0, timestamp=1.0) # one update, then silence
for i in range(100):
fast.push(float(i), timestamp=100.0 + i)
time.sleep(0.002)
time.sleep(0.2)
result = recording_store.stop_recording("r1")
data = result["data"]
assert data["fake.m2"]["values"] == [1.0], "idle channel was upsampled"
n_fast = len(data["fake.m1"]["values"])
assert 1 < n_fast < 100, f"fast channel not decimated to the grid ({n_fast})"
def test_recording_file_uses_the_compact_hdf5_layout(tmp_path):
"""A namespace recording is thousands of tiny datasets, where HDF5's
default object-header layout costs far more than the data itself."""
import h5py
from eco.status_server.storage import write_monitor_recording
t0 = time.time()
rec = {
"started_at": t0,
"stopped_at": t0 + 1,
"data": {f"bernina.dev{i}.value": {"values": [1.0], "timestamps": [t0]}
for i in range(200)},
}
compact = write_monitor_recording(tmp_path / "a", dict(rec))
legacy = write_monitor_recording(tmp_path / "b", dict(rec), libver=None)
assert compact.stat().st_size < legacy.stat().st_size
with h5py.File(compact) as f:
assert len(f.keys()) == 200
def test_max_value_elements_keeps_waveforms_out(recording_store):
"""A handful of waveform channels dominate a recording's size by an
order of magnitude over every scalar channel combined."""
import numpy as np
recording_store.start_recording("r1", names=["fake.m1"], max_value_elements=16)
mon = FakeMonitor.instances[0]
mon.push(1.0)
mon.push(np.arange(8000)) # a digitizer waveform
mon.push(np.arange(4)) # small array, still fine
result = recording_store.stop_recording("r1")
assert result["n_stored"] == 2
assert result["n_dropped_too_large"] == 1
# --------------------------------------------------------------------------
# /recording/capture: stop + write + upload, all off the client's clock -
# the recording equivalent of /status/capture and /aliases/capture. Stopping
# itself (detaching callbacks) happens synchronously before the response;
# only the write and upload are backgrounded.
def test_recording_capture_returns_immediately_and_does_the_work_in_the_background(
recording_store, tmp_path, monkeypatch
):
config = NamespaceServerConfig(module_name=recording_store.module_name)
config.data_root_pattern = str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
app = create_namespace_app(config, store=recording_store)
client = app.test_client()
posted = []
import requests
class _Resp:
status_code = 200
@staticmethod
def json():
return {"status": "ok", "message": "copying user file(s) finished"}
monkeypatch.setattr(
requests, "post", lambda url, json=None, timeout=None: (
posted.append((url, json)), _Resp)[1]
)
client.post("/recording/start", json={"recording_id": "r1"})
for mon in FakeMonitor.instances:
for v in range(4):
mon.push(float(v))
started = client.post(
"/recording/capture",
json={"recording_id": "r1", "pgroup": "p1", "run_number": 9},
)
assert started.status_code == 202
job_id = started.get_json()["job_id"]
# stopping (detaching callbacks) already happened, synchronously, before
# the response - only the write+upload are still pending
assert client.get("/recording/r1").get_json()["running"] is False
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
assert job["state"] == "done", job
path = tmp_path / "p1" / "run0009" / "aux" / "monitors.esc.h5"
assert path.exists()
assert job["n_written"] == 2
url, body = posted[0]
assert url.endswith("/copy_user_files")
assert body["pgroup"] == "p1" and body["run_number"] == 9
assert body["files"] == [str(path)]
assert job["upload"]["status"] == "ok"
# dropped by default once the job is done
assert client.get("/recording/r1").status_code == 404
def test_recording_capture_can_skip_the_upload(recording_store, tmp_path, monkeypatch):
config = NamespaceServerConfig(module_name=recording_store.module_name)
config.data_root_pattern = str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
app = create_namespace_app(config, store=recording_store)
client = app.test_client()
import requests
monkeypatch.setattr(requests, "post", lambda *a, **kw: pytest.fail("uploaded"))
client.post("/recording/start", json={"recording_id": "r1"})
job_id = client.post(
"/recording/capture",
json={"recording_id": "r1", "pgroup": "p1", "run_number": 10,
"upload": False},
).get_json()["job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
assert job["state"] == "done"
assert "upload" not in job
def test_recording_capture_can_keep_the_recording_instead_of_dropping_it(
recording_store, tmp_path
):
config = NamespaceServerConfig(module_name=recording_store.module_name)
config.data_root_pattern = str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
app = create_namespace_app(config, store=recording_store)
client = app.test_client()
client.post("/recording/start", json={"recording_id": "r1"})
job_id = client.post(
"/recording/capture",
json={"recording_id": "r1", "pgroup": "p1", "run_number": 11,
"upload": False, "drop": False},
).get_json()["job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
assert client.get("/recording/r1").status_code == 200
def test_recording_capture_requires_pgroup_and_run_number(recording_store):
app = create_namespace_app(
NamespaceServerConfig(module_name=recording_store.module_name),
store=recording_store,
)
client = app.test_client()
client.post("/recording/start", json={"recording_id": "r1"})
resp = client.post("/recording/capture", json={"recording_id": "r1"})
assert resp.status_code == 400
def test_recording_capture_unknown_recording_id_is_404(recording_store):
app = create_namespace_app(
NamespaceServerConfig(module_name=recording_store.module_name),
store=recording_store,
)
resp = app.test_client().post(
"/recording/capture",
json={"recording_id": "nope", "pgroup": "p1", "run_number": 1},
)
assert resp.status_code == 404
def test_recording_capture_of_an_already_stopped_recording_is_409(recording_store):
app = create_namespace_app(
NamespaceServerConfig(module_name=recording_store.module_name),
store=recording_store,
)
client = app.test_client()
client.post("/recording/start", json={"recording_id": "r1"})
client.post(
"/recording/capture",
json={"recording_id": "r1", "pgroup": "p1", "run_number": 1,
"upload": False, "drop": False},
)
resp = client.post(
"/recording/capture",
json={"recording_id": "r1", "pgroup": "p1", "run_number": 1,
"upload": False},
)
assert resp.status_code == 409
# --------------------------------------------------------------------------
# /status/capture: snapshot + write + upload, all off the client's clock
def test_capture_returns_immediately_and_does_the_work_in_the_background(
app_and_store, tmp_path, monkeypatch
):
app, _, _ = app_and_store
app.config["ECO_CONFIG"].data_root_pattern = (
str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
)
posted = []
import requests
class _Resp:
status_code = 200
@staticmethod
def json():
return {"status": "ok", "message": "copying user file(s) finished"}
monkeypatch.setattr(
requests, "post", lambda url, json=None, timeout=None: (
posted.append((url, json)), _Resp)[1]
)
client = app.test_client()
started = client.post(
"/status/capture",
json={"pgroup": "p1", "run_number": 3, "key": "status_run_start"},
)
assert started.status_code == 202
job_id = started.get_json()["job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
assert job["state"] == "done", job
path = tmp_path / "p1" / "run0003" / "aux" / "status.json"
assert path.exists()
assert json.loads(path.read_text())["status_run_start"]["status"]
# the server, not the client, handed the file to the broker
url, body = posted[0]
assert url.endswith("/copy_user_files")
assert body["pgroup"] == "p1" and body["run_number"] == 3
assert body["files"] == [str(path)]
assert job["upload"]["status"] == "ok"
def test_capture_can_skip_the_upload(app_and_store, tmp_path, monkeypatch):
app, _, _ = app_and_store
app.config["ECO_CONFIG"].data_root_pattern = (
str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
)
import requests
monkeypatch.setattr(requests, "post", lambda *a, **kw: pytest.fail("uploaded"))
client = app.test_client()
job_id = client.post(
"/status/capture",
json={"pgroup": "p1", "run_number": 4, "upload": False},
).get_json()["job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
assert job["state"] == "done"
assert "upload" not in job
def test_capture_requires_pgroup_and_run_number(app_and_store):
app, _, _ = app_and_store
assert app.test_client().post("/status/capture", json={}).status_code == 400
def test_capture_is_refused_while_not_ready(fake_module):
name, _ = fake_module(names=[f"n{i}" for i in range(10)], init_delay=0.05)
store = _store(name)
app = create_namespace_app(NamespaceServerConfig(module_name=name), store=store)
resp = app.test_client().post(
"/status/capture", json={"pgroup": "p1", "run_number": 1}
)
assert resp.status_code == 503
assert store.wait_ready(timeout=20)
# --------------------------------------------------------------------------
# /status/push: values the store can never poll itself, merged into the next
# /status/job read for the matching (pgroup, run_number, key)
def test_status_push_requires_pgroup_run_number_and_values(app_and_store):
app, _, _ = app_and_store
client = app.test_client()
assert client.post("/status/push", json={}).status_code == 400
assert client.post(
"/status/push", json={"pgroup": "p1", "run_number": 1}
).status_code == 400
def test_status_push_rejects_non_dict_values(app_and_store):
app, _, _ = app_and_store
resp = app.test_client().post(
"/status/push",
json={"pgroup": "p1", "run_number": 1, "values": ["not", "a", "dict"]},
)
assert resp.status_code == 400
def test_status_push_records_on_the_store(app_and_store):
app, store, _ = app_and_store
resp = app.test_client().post(
"/status/push",
json={"pgroup": "p1", "run_number": 5, "values": {"scans.acquiring_scan.a": 1}},
)
assert resp.status_code == 200
body = resp.get_json()
assert body == {"status": "ok", "pgroup": "p1", "run_number": 5,
"key": "status_run_start", "n_values": 1}
assert store.pop_pushed_status("p1", 5, "status_run_start") == {
"scans.acquiring_scan.a": 1
}
def test_status_push_merges_into_the_next_capture_job_read(app_and_store, tmp_path):
"""This is the real flow: capture (server-pollable values) runs first,
the client pushes what it alone knows a moment later, and the *next*
read with include_status=1 sees both merged into one status dict."""
app, _, _ = app_and_store
app.config["ECO_CONFIG"].data_root_pattern = (
str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
)
client = app.test_client()
job_id = client.post(
"/status/capture",
json={"pgroup": "p1", "run_number": 6, "upload": False, "keep_status": True},
).get_json()["job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
assert job["state"] == "done"
client.post(
"/status/push",
json={"pgroup": "p1", "run_number": 6,
"values": {"scans.acquiring_scan.description": "a scan"}},
)
body = client.get(f"/status/job/{job_id}?include_status=1").get_json()["job"]
assert body["status"]["fake.a"] == "A" # the server's own polled value
assert body["status"]["scans.acquiring_scan.description"] == "a scan"
assert body["n_pushed"] == 1
# handed over once, same as the job's own status
body_again = client.get(f"/status/job/{job_id}?include_status=1").get_json()["job"]
assert "status" not in body_again
def test_status_push_arriving_after_the_capture_write_still_lands(
app_and_store, tmp_path
):
"""The push does not have to race the server's own (potentially
multi-second) snapshot - only the client's later job read."""
app, _, _ = app_and_store
app.config["ECO_CONFIG"].data_root_pattern = (
str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
)
client = app.test_client()
job_id = client.post(
"/status/capture",
json={"pgroup": "p1", "run_number": 8, "upload": False, "keep_status": True},
).get_json()["job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
# a push for an unrelated run must not show up here
client.post(
"/status/push",
json={"pgroup": "p1", "run_number": 999, "values": {"x": 1}},
)
body = client.get(f"/status/job/{job_id}?include_status=1").get_json()["job"]
assert "x" not in body["status"]
assert "n_pushed" not in body
def test_a_pending_push_is_not_stolen_by_an_unrelated_job_kind(
app_and_store, tmp_path
):
"""aliases-capture (and recording-capture) jobs carry pgroup/run_number
for their own reporting but no "key" - the merge in /status/job must not
default one, or a push meant for the real status_run_start job of the
same run could be consumed by whichever job happens to be read first
with include_status=1."""
app, _, _ = app_and_store
app.config["ECO_CONFIG"].data_root_pattern = (
str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
)
client = app.test_client()
client.post(
"/status/push",
json={"pgroup": "p1", "run_number": 20,
"values": {"scans.acquiring_scan.description": "a scan"}},
)
alias_job_id = client.post(
"/aliases/capture",
json={"pgroup": "p1", "run_number": 20, "upload": False},
).get_json()["job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{alias_job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
# reading the aliases job with include_status=1 must not consume the
# push meant for the run's real status_run_start job
body = client.get(
f"/status/job/{alias_job_id}?include_status=1"
).get_json()["job"]
assert "status" not in body
assert "n_pushed" not in body
# it is still there for the job it was actually meant for
status_job_id = client.post(
"/status/capture",
json={"pgroup": "p1", "run_number": 20, "upload": False,
"keep_status": True},
).get_json()["job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{status_job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
body = client.get(
f"/status/job/{status_job_id}?include_status=1"
).get_json()["job"]
assert body["status"]["scans.acquiring_scan.description"] == "a scan"
# --------------------------------------------------------------------------
# NamespaceMonitorStore.push_status/pop_pushed_status directly: things with
# no CA channel (e.g. scans.acquiring_scan.*) that snapshot() can never see
# on its own -- see push_status's docstring. (Deliberately placed late in
# this file, not next to the other store-lifecycle tests up top: those run
# store._build_worker() on a background thread and are sensitive to overall
# process load, and extra tests immediately ahead of them was enough to
# perturb timing in test_target_names_from_required_without_writing_required_names.)
def test_push_status_and_pop_pushed_status_round_trip(fake_module):
name, _ = fake_module()
store = _store(name)
assert store.wait_ready(timeout=10)
store.push_status("p1", 3, "status_run_start", {"a.b": 1})
assert store.pop_pushed_status("p1", 3, "status_run_start") == {"a.b": 1}
def test_pop_pushed_status_consumes_the_value(fake_module):
"""Handed over once, same as a job's own status - not re-readable."""
name, _ = fake_module()
store = _store(name)
store.push_status("p1", 3, "status_run_start", {"a.b": 1})
assert store.pop_pushed_status("p1", 3, "status_run_start") == {"a.b": 1}
assert store.pop_pushed_status("p1", 3, "status_run_start") == {}
def test_pop_pushed_status_ignores_run_number_type(fake_module):
"""The client sends run_number as an int; a job dict may carry it as
whatever JSON gave it back - both must resolve to the same key."""
name, _ = fake_module()
store = _store(name)
store.push_status("p1", 3, "status_run_start", {"a.b": 1})
assert store.pop_pushed_status("p1", "3", "status_run_start") == {"a.b": 1}
def test_pop_pushed_status_is_scoped_by_pgroup_run_number_and_key(fake_module):
name, _ = fake_module()
store = _store(name)
store.push_status("p1", 3, "status_run_start", {"a.b": 1})
assert store.pop_pushed_status("p2", 3, "status_run_start") == {}
assert store.pop_pushed_status("p1", 4, "status_run_start") == {}
assert store.pop_pushed_status("p1", 3, "status_run_end") == {}
assert store.pop_pushed_status("p1", 3, "status_run_start") == {"a.b": 1}
def test_push_status_replaces_not_merges(fake_module):
"""The caller always sends its full current view, not a delta."""
name, _ = fake_module()
store = _store(name)
store.push_status("p1", 3, "status_run_start", {"a.b": 1, "a.c": 2})
store.push_status("p1", 3, "status_run_start", {"a.b": 9})
assert store.pop_pushed_status("p1", 3, "status_run_start") == {"a.b": 9}
def test_pop_pushed_status_drops_a_push_older_than_max_age(fake_module, monkeypatch):
name, _ = fake_module()
store = _store(name)
t = [1000.0]
monkeypatch.setattr(ns_store.time, "time", lambda: t[0])
store.push_status("p1", 3, "status_run_start", {"a.b": 1})
t[0] += 400
assert store.pop_pushed_status("p1", 3, "status_run_start", max_age=300) == {}
def test_push_status_rejects_non_dict_values(fake_module):
name, _ = fake_module()
store = _store(name)
with pytest.raises(TypeError):
store.push_status("p1", 3, "status_run_start", ["not", "a", "dict"])
# --------------------------------------------------------------------------
# /aliases/capture: compute + write + upload, all off the client's clock
def test_aliases_capture_returns_immediately_and_does_the_work_in_the_background(
app_and_store, tmp_path, monkeypatch
):
app, _, _ = app_and_store
app.config["ECO_CONFIG"].data_root_pattern = (
str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
)
posted = []
import requests
class _Resp:
status_code = 200
@staticmethod
def json():
return {"status": "ok", "message": "copying user file(s) finished"}
monkeypatch.setattr(
requests, "post", lambda url, json=None, timeout=None: (
posted.append((url, json)), _Resp)[1]
)
client = app.test_client()
started = client.post(
"/aliases/capture", json={"pgroup": "p1", "run_number": 3}
)
assert started.status_code == 202
job_id = started.get_json()["job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
assert job["state"] == "done", job
path = tmp_path / "p1" / "run0003" / "aux" / "aliases.json"
assert path.exists()
written = json.loads(path.read_text())
assert {"alias": "fake.a", "channel": "PV:A", "channeltype": "CA"} in written
# the server, not the client, handed the file to the broker
url, body = posted[0]
assert url.endswith("/copy_user_files")
assert body["pgroup"] == "p1" and body["run_number"] == 3
assert body["files"] == [str(path)]
assert job["upload"]["status"] == "ok"
def test_aliases_capture_can_skip_the_upload(app_and_store, tmp_path, monkeypatch):
app, _, _ = app_and_store
app.config["ECO_CONFIG"].data_root_pattern = (
str(tmp_path) + "/{pgroup}/run{run_number:04d}/aux"
)
import requests
monkeypatch.setattr(requests, "post", lambda *a, **kw: pytest.fail("uploaded"))
client = app.test_client()
job_id = client.post(
"/aliases/capture", json={"pgroup": "p1", "run_number": 4, "upload": False}
).get_json()["job_id"]
deadline = time.time() + 10
while time.time() < deadline:
job = client.get(f"/status/job/{job_id}").get_json()["job"]
if job["state"] != "running":
break
time.sleep(0.02)
assert job["state"] == "done"
assert "upload" not in job
def test_aliases_capture_requires_pgroup_and_run_number(app_and_store):
app, _, _ = app_and_store
assert app.test_client().post("/aliases/capture", json={}).status_code == 400
def test_aliases_capture_is_refused_while_not_ready(fake_module):
name, _ = fake_module(names=[f"n{i}" for i in range(10)], init_delay=0.05)
store = _store(name)
app = create_namespace_app(NamespaceServerConfig(module_name=name), store=store)
resp = app.test_client().post(
"/aliases/capture", json={"pgroup": "p1", "run_number": 1}
)
assert resp.status_code == 503
assert store.wait_ready(timeout=20)
def test_health_reports_failed_required_components_separately(fake_module):
"""A failed component that is in required_names() is the one a client has
to be told about loudly - the setup is not supposed to fail those."""
name, mod = fake_module(names=["a", "b", "c"], required=["a", "b"], fail=["b", "c"])
store = _store(name, init_required_only=False)
assert store.wait_ready(timeout=10)
report = store.connection_report()
assert report["failed_names"] == ["b", "c"]
assert report["failed_required"] == ["b"], "c is not required, b is"
assert report["n_failed_required"] == 1
app = create_namespace_app(NamespaceServerConfig(module_name=name), store=store)
body = app.test_client().get("/health").get_json()
assert body["failed_required"] == ["b"]
def test_no_failed_required_is_reported_as_empty(fake_module):
name, _ = fake_module(names=["a", "b"], required=["a"], fail=["b"])
store = _store(name, init_required_only=False)
assert store.wait_ready(timeout=10)
report = store.connection_report()
assert report["failed_names"] == ["b"]
assert report["failed_required"] == []
def test_the_client_warns_in_red_about_failed_required(capsys):
from eco.status_server.client import warn_failed_required
warned = warn_failed_required({"failed_required": ["mon_und", "scilog"]})
out = capsys.readouterr().out
assert warned == ["mon_und", "scilog"]
assert "REQUIRED" in out and "mon_und" in out and "scilog" in out
assert "\x1b[" in out, "should be colourised"
def test_the_client_stays_quiet_when_nothing_required_failed(capsys):
from eco.status_server.client import warn_failed_required
assert warn_failed_required({"failed_required": [], "failed_names": ["x"]}) == []
assert warn_failed_required({}) == []
assert capsys.readouterr().out == ""
def test_the_client_warns_in_red_about_recording_failed_required(capsys):
from eco.status_server.client import warn_recording_failed_required
warned = warn_recording_failed_required(
{"failed_required": ["daq.something"]}
)
out = capsys.readouterr().out
assert warned == ["daq.something"]
assert "REQUIRED" in out and "daq.something" in out
assert "\x1b[" in out, "should be colourised"
# deliberately does not suggest reinit() - see the function's docstring
assert "reinit" not in out
def test_the_client_stays_quiet_when_no_recording_required_channel_failed(capsys):
from eco.status_server.client import warn_recording_failed_required
assert warn_recording_failed_required({"failed_required": []}) == []
assert warn_recording_failed_required({}) == []
assert capsys.readouterr().out == ""