STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
descriptions.py
Go to the documentation of this file.
1"""One shared, editable line of context per run: "hotfire 3, ox lead 200 ms".
2
3A run is identified by a timestamp, which says when it happened and nothing about what
4it was. This is the one piece of viewer state that is *written* rather than derived, and
5it is deliberately unowned — anybody can set or change any run's line, and everybody sees
6the same text. There is no login on this tool, so there is nobody to attribute it to.
7
8Stored beside the run dirs (`<ELODIN_DIR>/run_descriptions.json`), for the same reason
9the config snapshot is: that is where the runs themselves live, it is backed up with
10them, and it survives the parquet cache being wiped — which docker-compose explicitly
11calls safe to do. It must never live under WEBVIEWER_CACHE_DIR for that reason.
12
13Last write wins. Two people editing one line in the same second is not a problem worth a
14revision protocol here; the write itself is atomic, so a torn file is not possible.
15"""
16
17from __future__ import annotations
18
19import json
20import os
21import tempfile
22import threading
23from datetime import datetime, timezone
24
25from . import config
26
27# Long enough for "hotfire 3 — ox lead 200 ms, aborted on chamber PT", short enough that
28# it stays one line in the run list rather than becoming a notes field.
29MAX_LEN = 120
30
31_SCHEMA = 1
32# Single-process (the Dockerfile's uvicorn has no --workers), so a process-wide lock is
33# enough to serialise read-modify-write. Multiple workers would need file locking.
34_lock = threading.Lock()
35
36
37def _now() -> str:
38 return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
39
40
41def normalise(text: str) -> str:
42 """Force the input to the one line it claims to be: newlines and runs of whitespace
43 collapse to single spaces, then truncate. A pasted paragraph becomes a sentence
44 rather than being rejected."""
45 return " ".join(text.split())[:MAX_LEN]
46
47
48def _read() -> dict[str, dict]:
49 """The whole store. Unreadable or malformed → empty: a description is a convenience,
50 and losing one must never stop the viewer listing runs."""
51 try:
52 with config.DESCRIPTIONS_PATH.open("r", encoding="utf-8") as fh:
53 data = json.load(fh)
54 except (OSError, json.JSONDecodeError):
55 return {}
56 runs = data.get("runs")
57 return runs if isinstance(runs, dict) else {}
58
59
60def _write(runs: dict[str, dict]) -> None:
61 """Replace the store atomically, so a crash or a concurrent reader never sees a
62 half-written file. Same directory as the target — os.replace is only atomic within
63 one filesystem."""
64 path = config.DESCRIPTIONS_PATH
65 path.parent.mkdir(parents=True, exist_ok=True)
66 fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".descriptions-", suffix=".tmp")
67 try:
68 with os.fdopen(fd, "w", encoding="utf-8") as fh:
69 json.dump({"version": _SCHEMA, "runs": runs}, fh, indent=1, sort_keys=True)
70 fh.flush()
71 os.fsync(fh.fileno())
72 os.replace(tmp, path)
73 except BaseException:
74 try:
75 os.unlink(tmp)
76 except OSError:
77 pass
78 raise
79
80
81def all_texts() -> dict[str, str]:
82 """run_id → description, for decorating a run listing. One file read."""
83 return {
84 rid: e["text"]
85 for rid, e in _read().items()
86 if isinstance(e, dict) and isinstance(e.get("text"), str) and e["text"]
87 }
88
89
90def get(run_id: str) -> str:
91 return all_texts().get(run_id, "")
92
93
94def set_text(run_id: str, text: str) -> dict:
95 """Set (or, with empty text, clear) one run's description. Returns the stored entry.
96
97 Also drops entries for runs that no longer exist — a discarded session takes its DB
98 dir and its config snapshot with it (session-manager.ts) but knows nothing about this
99 file, so orphans would otherwise pile up forever.
100 """
101 text = normalise(text)
102 with _lock:
103 runs = _read()
104 if text:
105 runs[run_id] = {"text": text, "updated_at": _now()}
106 else:
107 runs.pop(run_id, None)
108 runs = {
109 rid: e for rid, e in runs.items() if (config.ELODIN_DIR / rid).is_dir()
110 }
111 _write(runs)
112 return runs.get(run_id, {"text": "", "updated_at": None})
str normalise(str text)
dict[str, dict] _read()
dict set_text(str run_id, str text)
None _write(dict[str, dict] runs)
str get(str run_id)
dict[str, str] all_texts()