pyrtan / sourcev0.19.0 · main

pyrtan / src / pyrtan / gaps.py

3,779 bytes · last changed 2026-10-25 in Add tokens, CORS, persistent gaps, egress policy, the catalog and the console

1"""Where a service keeps its gaps.
2
3A gap is a decision that met a value no branch covered. The runtime records
4one on the trace; the service collects them here so an author can read the
5list and write the missing branches. Without a path the store lives in
6memory and is lost with the process, which is enough to try the loop (run,
7see the gap, add the branch, reload). With a path it appends every new gap
8to a JSON-lines file and reads the file back on start, so a fleet's gaps
9outlive restarts and a hub can collect them later.
10
11The same gap seen twice is one entry with a count: the list is what to
12write next, not a log of every run.
13"""
14
15from __future__ import annotations
16
17import json
18import sys
19import threading
20import time
21from pathlib import Path
22from typing import Any, Iterable
23
24
25def _key(gap: dict[str, Any]) -> str:
26 return json.dumps([gap.get("tree"), gap.get("node"), gap.get("on"), gap.get("observed")], sort_keys=True, ensure_ascii=False, default=str)
27
28
29class GapStore:
30 def __init__(self, path: str | Path | None = None) -> None:
31 self.path = Path(path) if path else None
32 self.error: str | None = None # the last failure to write the file; a run never fails over it
33 self._gaps: dict[str, dict[str, Any]] = {}
34 self._lock = threading.Lock()
35 if self.path is not None and self.path.exists():
36 try:
37 self._load()
38 except OSError as e:
39 self.error = str(e)
40 print(f"pyrtan: cannot read gaps from {self.path}: {e}; starting empty", file=sys.stderr)
41
42 def _load(self) -> None:
43 assert self.path is not None
44 for line in self.path.read_text(encoding="utf-8").splitlines():
45 line = line.strip()
46 if not line:
47 continue
48 try:
49 gap = json.loads(line)
50 except json.JSONDecodeError:
51 continue
52 if isinstance(gap, dict) and "tree" in gap:
53 self._merge(gap, persist=False)
54
55 def _merge(self, gap: dict[str, Any], *, persist: bool) -> None:
56 key = _key(gap)
57 now = time.time()
58 current = self._gaps.get(key)
59 if current is None:
60 entry = {
61 "tree": gap.get("tree"),
62 "node": gap.get("node"),
63 "on": gap.get("on"),
64 "observed": gap.get("observed"),
65 "count": int(gap.get("count", 1)),
66 "first_seen": gap.get("first_seen", now),
67 "last_seen": gap.get("last_seen", now),
68 }
69 self._gaps[key] = entry
70 else:
71 current["count"] += int(gap.get("count", 1))
72 current["last_seen"] = max(current["last_seen"], gap.get("last_seen", now))
73 entry = current
74 if persist and self.path is not None:
75 try:
76 self.path.parent.mkdir(parents=True, exist_ok=True)
77 with self.path.open("a", encoding="utf-8") as f:
78 f.write(json.dumps({**entry, "count": 1, "last_seen": now}, ensure_ascii=False, default=str) + "\n")
79 self.error = None
80 except OSError as e:
81 if self.error is None:
82 print(f"pyrtan: cannot write gaps to {self.path}: {e}; keeping them in memory", file=sys.stderr)
83 self.error = str(e)
84
85 def add(self, gaps: Iterable[dict[str, Any]]) -> None:
86 with self._lock:
87 for gap in gaps:
88 self._merge(gap, persist=True)
89
90 def all(self) -> list[dict[str, Any]]:
91 with self._lock:
92 return sorted((dict(g) for g in self._gaps.values()), key=lambda g: (-g["count"], g["last_seen"]))
93
94 def __len__(self) -> int:
95 return len(self._gaps)