oct
Loading...
Searching...
No Matches
ledger.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/timing/ledger.py
4
5"""
6Purpose
7-------
8Persist stage timings.
9
10Responsibilities
11----------------
12- Keep local timing data bounded, private and advisory.
13
14Diagnostics
15-----------
16Domain: GENERAL
17Levels:
18 L1 -- instrumentation failures
19 L2 -- lifecycle
20 L3 -- semantic details
21 L4 -- deep tracing
22
23Contracts
24---------
25- Timing instrumentation never changes the wrapped operation's outcome.
26- Only the fixed timing schema is persisted; command arguments are never stored.
27
28Dependencies
29------------
30- oc_diagnostics (structured timing diagnostics)
31- oct (version and timing contracts)
32- click (lazy; walks the registered command groups to name the stage)
33"""
34from __future__ import annotations
35
36from contextlib import contextmanager
37from dataclasses import asdict, dataclass
38from datetime import datetime, timezone
39import json
40import os
41from pathlib import Path
42import re
43import socket
44import time
45from typing import Iterator
46from oc_diagnostics import _dbg, dbg_timed
47from oct import __version__
48
49@dataclass
51 """Mutable measurements with a fixed, argument-free serialized shape."""
52 ts: str
53 host: str
54 cpus: int
55 stage: str
56 kind: str = "stage"
57 wall_ms: int = 0
58 cpu_ms: int = 0
59 files: int | None = None
60 scope: str | None = None
61 outcome: int | str = "ok"
62 version: str = __version__
63
64def _failure() -> None:
65 """Report instrumentation failure without exposing paths or caller data."""
66 func = "_failure"
67 try:
68 _dbg("GENERAL", func, "SYSTEM_ERROR: timing ledger unavailable", 1)
69 except Exception:
70 # Diagnostics itself may be unavailable; the operation remains primary.
71 return
72
73def _token(value: object) -> str | None:
74 """Accept short tokens only, never free text or paths."""
75 return value if isinstance(value, str) and re.fullmatch(r"[a-zA-Z0-9_.-]{1,64}", value) else None
76
77def _append(root: Path, sample: TimingSample) -> None:
78 """Serialize one bounded record under a nonblocking interprocess lock."""
79 func = "_append"
80 path = root / ".option_c" / "logs" / "timings.jsonl"
81 path.parent.mkdir(parents=True, exist_ok=True)
82 with (path.parent / "timings.lock").open("a+b") as lock:
83 lock.seek(0, 2)
84 if lock.tell() == 0:
85 lock.write(b"0")
86 lock.flush()
87 lock.seek(0)
88 if os.name == "nt":
89 import msvcrt
90 msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
91 else:
92 import fcntl
93 fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
94 try:
95 data = asdict(sample)
96 data["stage"] = _token(sample.stage) or "unknown"
97 data["scope"] = _token(sample.scope)
98 data["kind"] = sample.kind if sample.kind in ("stage", "wait") else "stage"
99 data["outcome"] = sample.outcome if type(sample.outcome) is int or sample.outcome in ("ok", "error") else "error"
100 data["files"] = sample.files if type(sample.files) is int and sample.files >= 0 else None
101 line = json.dumps(data, separators=(",", ":")) + "\n"
102 with path.open("a+", encoding="utf-8", newline="\n") as stream:
103 stream.seek(0)
104 lines = stream.readlines()
105 if len(lines) > 5000:
106 stream.seek(0)
107 stream.truncate()
108 stream.writelines((lines + [line])[-4000:])
109 else:
110 if lines and not lines[-1].endswith("\n"):
111 stream.write("\n")
112 stream.write(line)
113 finally:
114 lock.seek(0)
115 if os.name == "nt":
116 msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1)
117 else:
118 fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
119
120def read_records(root: Path) -> list[dict]:
121 """Read complete JSON records, ignoring damaged or incomplete lines."""
122 func = "read_records"
123 path = Path(root) / ".option_c" / "logs" / "timings.jsonl"
124 try:
125 with path.open(encoding="utf-8") as stream:
126 rows = []
127 for line in stream:
128 try:
129 row = json.loads(line)
130 except ValueError:
131 _dbg("GENERAL", func, "VALIDATION_ERROR: invalid timing row", 3)
132 continue
133 if isinstance(row, dict):
134 rows.append(row)
135 return rows
136 except FileNotFoundError:
137 return []
138 except (OSError, UnicodeError):
139 _failure()
140 return []
141
142@contextmanager
143def timed_stage(root: Path, stage: str, *, kind: str = "stage", scope: str | None = None, files: int | None = None, outcome: int | str = "ok") -> Iterator[TimingSample]:
144 """Measure one operation and preserve all success and exception semantics."""
145 func = "timed_stage"
146 sample = TimingSample(datetime.now(timezone.utc).isoformat(), socket.gethostname(), os.cpu_count() or 1, _token(stage) or "unknown", kind=kind, scope=scope, files=files, outcome=outcome)
147 # _depth=3: past this generator and contextlib's frame, so the ENTER and
148 # EXIT records are located at the code running the stage, not here.
149 # oc_diagnostics < 2.2.0 has no _depth (and located them in contextlib).
150 try:
151 diagnostic = dbg_timed("GENERAL", func, label=sample.stage, _depth=3)
152 except TypeError:
153 diagnostic = dbg_timed("GENERAL", func, label=sample.stage)
154 active = False
155 failed = False
156 try:
157 diagnostic.__enter__()
158 active = True
159 except Exception:
160 failed = True
161 started = time.perf_counter_ns()
162 cpu = time.process_time_ns()
163 try:
164 yield sample
165 except BaseException as exc:
166 code = getattr(exc, "exit_code", getattr(exc, "code", None))
167 if isinstance(exc, SystemExit) and code is None:
168 code = 0
169 sample.outcome = (code if type(code) is int else 1) if type(outcome) is int else "error"
170 raise
171 finally:
172 sample.wall_ms = max(0, (time.perf_counter_ns() - started) // 1_000_000)
173 sample.cpu_ms = max(0, (time.process_time_ns() - cpu) // 1_000_000)
174 if active:
175 try:
176 diagnostic.__exit__(None, None, None)
177 except Exception:
178 failed = True
179 try:
180 _append(Path(root), sample)
181 except Exception:
182 failed = True
183 if failed:
184 _failure()
185
186def command_stage(ctx: object) -> str:
187 """Use registered command names only; never serialize arbitrary argv."""
188 import click
189 args = list(ctx._protected_args) + list(ctx.args)
190 command = ctx.command
191 parts = []
192 while args and isinstance(command, click.Group):
193 child = command.get_command(ctx, args[0])
194 if child is None:
195 break
196 parts.append(child.name)
197 args.pop(0)
198 command = child
199 return ".".join(parts) or "oct"
str command_stage(object ctx)
Definition ledger.py:186
str|None _token(object value)
Definition ledger.py:73
None _append(Path root, TimingSample sample)
Definition ledger.py:77
None _failure()
Definition ledger.py:64
list[dict] read_records(Path root)
Definition ledger.py:120
Iterator[TimingSample] timed_stage(Path root, str stage, *, str kind="stage", str|None scope=None, int|None files=None, int|str outcome="ok")
Definition ledger.py:143