exptrail 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- exptrail/__init__.py +7 -0
- exptrail/__main__.py +5 -0
- exptrail/_stack.py +27 -0
- exptrail/cli.py +210 -0
- exptrail/liveness.py +271 -0
- exptrail/meta.py +352 -0
- exptrail/plot.py +38 -0
- exptrail/readme.py +294 -0
- exptrail/run.py +481 -0
- exptrail/store.py +134 -0
- exptrail-0.1.0.dist-info/METADATA +200 -0
- exptrail-0.1.0.dist-info/RECORD +16 -0
- exptrail-0.1.0.dist-info/WHEEL +5 -0
- exptrail-0.1.0.dist-info/entry_points.txt +2 -0
- exptrail-0.1.0.dist-info/licenses/LICENSE +21 -0
- exptrail-0.1.0.dist-info/top_level.txt +1 -0
exptrail/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""exptrail: local experiment logging that keeps README numbers verifiable."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .run import ConfigWarning, DirtyTreeWarning, NoGitWarning, Run, track # noqa: E402
|
|
6
|
+
|
|
7
|
+
__all__ = ["Run", "track", "DirtyTreeWarning", "NoGitWarning", "ConfigWarning", "__version__"]
|
exptrail/__main__.py
ADDED
exptrail/_stack.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Point warnings at the user's line, however many exptrail frames are in between."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) + os.sep
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _in_package(filename: str) -> bool:
|
|
12
|
+
return os.path.abspath(filename).startswith(_PACKAGE_DIR)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def user_stacklevel() -> int:
|
|
16
|
+
"""``stacklevel`` for a ``warnings.warn`` made by the caller of this function.
|
|
17
|
+
|
|
18
|
+
Walks up to the first frame outside the exptrail package, so the warning
|
|
19
|
+
names the user's ``with Run(...)``, ``run.start()`` or ``@track``-decorated
|
|
20
|
+
call, whichever path led here.
|
|
21
|
+
"""
|
|
22
|
+
frame = sys._getframe(1) # the function that is about to call warnings.warn
|
|
23
|
+
level = 1
|
|
24
|
+
while frame is not None and _in_package(frame.f_code.co_filename):
|
|
25
|
+
frame = frame.f_back
|
|
26
|
+
level += 1
|
|
27
|
+
return level
|
exptrail/cli.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Command-line interface: ls, show, compare, plot, table, verify, mark."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .readme import render_table, verify_readme, write_table
|
|
12
|
+
from .run import END_STATUSES, _now, _write_json, default_root
|
|
13
|
+
from .store import SavedRun, find_run, glob_runs, list_runs
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _fmt(value) -> str:
|
|
17
|
+
if isinstance(value, float):
|
|
18
|
+
return f"{value:.4g}"
|
|
19
|
+
if isinstance(value, (dict, list)):
|
|
20
|
+
return json.dumps(value)
|
|
21
|
+
return "—" if value is None else str(value)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_table(header: list[str], rows: list[list[str]], out=None) -> None:
|
|
25
|
+
out = out or sys.stdout
|
|
26
|
+
widths = [max(len(str(c)) for c in col) for col in zip(header, *rows)]
|
|
27
|
+
for i, row in enumerate([header, *rows]):
|
|
28
|
+
print(" ".join(str(c).ljust(w) for c, w in zip(row, widths)).rstrip(), file=out)
|
|
29
|
+
if i == 0:
|
|
30
|
+
print(" ".join("-" * w for w in widths), file=out)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve(refs: list[str], root: Path) -> list[SavedRun]:
|
|
34
|
+
return [find_run(r, root) for r in refs]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cmd_ls(args) -> int:
|
|
38
|
+
runs = list_runs(args.root)
|
|
39
|
+
if not runs:
|
|
40
|
+
print(f"no runs in {args.root}")
|
|
41
|
+
return 0
|
|
42
|
+
rows = []
|
|
43
|
+
for run in runs:
|
|
44
|
+
summary = run.summary
|
|
45
|
+
keys = args.metric or list(summary)[:3]
|
|
46
|
+
metrics = ", ".join(f"{k}={_fmt(summary.get(k))}" for k in keys)
|
|
47
|
+
rows.append([run.id, run.status_display(), metrics, run.short_commit()])
|
|
48
|
+
print_table(["run", "status", "summary", "commit"], rows)
|
|
49
|
+
if any(r.dirty for r in runs):
|
|
50
|
+
print("\n* = ran on a dirty working tree")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_show(args) -> int:
|
|
55
|
+
run = find_run(args.run, args.root)
|
|
56
|
+
print(f"# {run.id} ({run.path})\n")
|
|
57
|
+
print(f"status: {run.status_display()}\n")
|
|
58
|
+
for title, data in (("config", run.config), ("summary", run.summary), ("meta", run.meta)):
|
|
59
|
+
print(f"## {title}")
|
|
60
|
+
print(json.dumps(data, indent=2))
|
|
61
|
+
print()
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_compare(args) -> int:
|
|
66
|
+
runs = _resolve(args.runs, args.root)
|
|
67
|
+
ids = [r.id for r in runs]
|
|
68
|
+
configs = [r.config for r in runs]
|
|
69
|
+
keys = list(dict.fromkeys(k for c in configs for k in c))
|
|
70
|
+
differing = [k for k in keys if len({json.dumps(c.get(k), sort_keys=True) for c in configs}) > 1]
|
|
71
|
+
shown = keys if args.all else differing
|
|
72
|
+
print("## config" + ("" if args.all else " (differing keys only; --all for everything)"))
|
|
73
|
+
if shown:
|
|
74
|
+
print_table(["key", *ids], [[k, *(_fmt(c.get(k)) for c in configs)] for k in shown])
|
|
75
|
+
else:
|
|
76
|
+
print("(identical)")
|
|
77
|
+
summaries = [r.summary for r in runs]
|
|
78
|
+
mkeys = list(dict.fromkeys(k for s in summaries for k in s))
|
|
79
|
+
print("\n## summary")
|
|
80
|
+
print_table(["metric", *ids], [[k, *(_fmt(s.get(k)) for s in summaries)] for k in mkeys])
|
|
81
|
+
print("\n## provenance")
|
|
82
|
+
print_table(["", *ids], [["status", *(r.status_display() for r in runs)], ["commit", *(r.short_commit() for r in runs)]])
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_plot(args) -> int:
|
|
87
|
+
from .plot import plot_runs
|
|
88
|
+
|
|
89
|
+
runs = _resolve(args.runs, args.root)
|
|
90
|
+
out = Path(args.out or f"{args.metric}.png")
|
|
91
|
+
n = plot_runs(runs, args.metric, out)
|
|
92
|
+
if n == 0:
|
|
93
|
+
print(f"error: no run has logged {args.metric!r}", file=sys.stderr)
|
|
94
|
+
return 1
|
|
95
|
+
print(f"wrote {out} ({n} run{'s' if n != 1 else ''})")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_table(args) -> int:
|
|
100
|
+
runs = []
|
|
101
|
+
for pattern in args.runs:
|
|
102
|
+
runs += [r for r in glob_runs(pattern, args.root) if r.path not in {x.path for x in runs}]
|
|
103
|
+
if not args.include_failed:
|
|
104
|
+
runs = [r for r in runs if r.status == "finished"]
|
|
105
|
+
if not runs:
|
|
106
|
+
print("error: no finished runs match " + " ".join(args.runs), file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
readme = Path(args.readme)
|
|
109
|
+
command = "exptrail " + shlex.join(args.argv)
|
|
110
|
+
block = render_table(runs, args.metric, readme, args.config or [], args.precision, command,
|
|
111
|
+
absolute_links=args.absolute_links)
|
|
112
|
+
changed = write_table(readme, block)
|
|
113
|
+
print(f"{'updated' if changed else 'unchanged'}: {readme} ({len(runs)} run{'s' if len(runs) != 1 else ''})")
|
|
114
|
+
for r in runs:
|
|
115
|
+
if r.dirty:
|
|
116
|
+
print(f"warning: {r.id} ran on a dirty working tree", file=sys.stderr)
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_verify(args) -> int:
|
|
121
|
+
failed = False
|
|
122
|
+
for readme in args.readme:
|
|
123
|
+
res = verify_readme(Path(readme), strict=args.strict)
|
|
124
|
+
for w in res.warnings:
|
|
125
|
+
print(f"warning: {w}", file=sys.stderr)
|
|
126
|
+
for e in res.errors:
|
|
127
|
+
print(f"MISMATCH: {e}", file=sys.stderr)
|
|
128
|
+
status = "OK" if res.ok else "FAILED"
|
|
129
|
+
print(f"{status}: {readme} — {res.rows} rows, {res.checked} values checked")
|
|
130
|
+
failed |= not res.ok
|
|
131
|
+
return 1 if failed else 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_mark(args) -> int:
|
|
135
|
+
run = find_run(args.run, args.root)
|
|
136
|
+
shown = run.status_display()
|
|
137
|
+
if run.status == "running" and not args.force:
|
|
138
|
+
print(f"error: {run.id} shows as {shown}; pass --force to mark it anyway", file=sys.stderr)
|
|
139
|
+
return 1
|
|
140
|
+
meta = run.read_json("meta.json")
|
|
141
|
+
previous = meta.get("status")
|
|
142
|
+
meta["status"] = args.status
|
|
143
|
+
meta["marked"] = {"by": "hand", "at": _now().isoformat(), "previous_status": previous, "shown_as": shown}
|
|
144
|
+
_write_json(run.path / "meta.json", meta)
|
|
145
|
+
print(f"marked {run.id} as {args.status} (was {shown})")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
150
|
+
p = argparse.ArgumentParser(prog="exptrail", description=__doc__)
|
|
151
|
+
p.add_argument("--root", type=Path, default=None, help="runs directory (default: $EXPTRAIL_ROOT or ./runs)")
|
|
152
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
153
|
+
|
|
154
|
+
s = sub.add_parser("ls", help="list runs")
|
|
155
|
+
s.add_argument("--metric", action="append", help="summary metric to show (repeatable)")
|
|
156
|
+
s.set_defaults(func=cmd_ls)
|
|
157
|
+
|
|
158
|
+
s = sub.add_parser("show", help="show config, summary and meta of a run")
|
|
159
|
+
s.add_argument("run")
|
|
160
|
+
s.set_defaults(func=cmd_show)
|
|
161
|
+
|
|
162
|
+
s = sub.add_parser("compare", help="config diff and metric table for several runs")
|
|
163
|
+
s.add_argument("runs", nargs="+")
|
|
164
|
+
s.add_argument("--all", action="store_true", help="show all config keys, not just differing ones")
|
|
165
|
+
s.set_defaults(func=cmd_compare)
|
|
166
|
+
|
|
167
|
+
s = sub.add_parser("plot", help="overlay a metric's curves to a PNG")
|
|
168
|
+
s.add_argument("runs", nargs="+")
|
|
169
|
+
s.add_argument("--metric", required=True)
|
|
170
|
+
s.add_argument("--out", "-o", help="output PNG (default: <metric>.png)")
|
|
171
|
+
s.set_defaults(func=cmd_plot)
|
|
172
|
+
|
|
173
|
+
s = sub.add_parser("table", help="write a results table into a README")
|
|
174
|
+
s.add_argument("--metric", action="append", required=True, help="summary metric column (repeatable)")
|
|
175
|
+
s.add_argument("--runs", nargs="+", default=["*"], help="glob(s) of run folders (default: all)")
|
|
176
|
+
s.add_argument("--readme", default="README.md")
|
|
177
|
+
s.add_argument("--config", action="append", help="config key to show as a column (repeatable)")
|
|
178
|
+
s.add_argument("--precision", type=int, default=4, help="decimals for floats (default 4)")
|
|
179
|
+
s.add_argument("--include-failed", action="store_true")
|
|
180
|
+
s.add_argument("--absolute-links", action="store_true",
|
|
181
|
+
help="link run folders by their GitHub/GitLab URL (for READMEs shown on PyPI)")
|
|
182
|
+
s.set_defaults(func=cmd_table)
|
|
183
|
+
|
|
184
|
+
s = sub.add_parser("verify", help="check README results against saved runs")
|
|
185
|
+
s.add_argument("readme", nargs="+")
|
|
186
|
+
s.add_argument("--strict", action="store_true", help="also fail on dirty or git-less runs")
|
|
187
|
+
s.set_defaults(func=cmd_verify)
|
|
188
|
+
|
|
189
|
+
s = sub.add_parser("mark", help="permanently record how a run ended (e.g. one that shows as dead)")
|
|
190
|
+
s.add_argument("run")
|
|
191
|
+
s.add_argument("status", choices=END_STATUSES)
|
|
192
|
+
s.add_argument("--force", action="store_true", help="mark it even if it still shows as running")
|
|
193
|
+
s.set_defaults(func=cmd_mark)
|
|
194
|
+
return p
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def main(argv: list[str] | None = None) -> int:
|
|
198
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
199
|
+
args = build_parser().parse_args(argv)
|
|
200
|
+
args.root = args.root or default_root()
|
|
201
|
+
args.argv = argv
|
|
202
|
+
try:
|
|
203
|
+
return args.func(args)
|
|
204
|
+
except (LookupError, ValueError, RuntimeError, FileNotFoundError) as exc:
|
|
205
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__": # pragma: no cover
|
|
210
|
+
sys.exit(main())
|
exptrail/liveness.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Is a run whose meta.json says ``running`` really still running?
|
|
2
|
+
|
|
3
|
+
The usual ways ML jobs die (SIGKILL, the out-of-memory killer, a Colab
|
|
4
|
+
disconnect, a laptop losing power) run no cleanup code, so nothing can write
|
|
5
|
+
a "died" status. Instead, a run leaves evidence while it is alive:
|
|
6
|
+
|
|
7
|
+
- at start, who it is: hostname, boot ID, pid and the process start time;
|
|
8
|
+
- every ``heartbeat_s`` seconds, a daemon thread touches ``<run>/heartbeat``.
|
|
9
|
+
|
|
10
|
+
Readers combine the two into a status at read time, without writing anything.
|
|
11
|
+
On the same machine the answer is certain (``running`` or ``dead``). From
|
|
12
|
+
another machine, or where the process can't be inspected, only the heartbeat's
|
|
13
|
+
age is available, so an old heartbeat means ``stale``: probably dead.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import socket
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
IS_WINDOWS = os.name == "nt"
|
|
30
|
+
DEFAULT_HEARTBEAT_S = 30.0
|
|
31
|
+
STALE_FACTOR = 3
|
|
32
|
+
HEARTBEAT_FILE = "heartbeat"
|
|
33
|
+
# kern.boottime (macOS) shifts when the wall clock is stepped; a real reboot
|
|
34
|
+
# closer than this to the previous boot is still caught by the pid check.
|
|
35
|
+
BOOT_TOLERANCE_S = 300.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _positive_seconds(value, what: str) -> float:
|
|
39
|
+
try:
|
|
40
|
+
seconds = float(value)
|
|
41
|
+
except (TypeError, ValueError):
|
|
42
|
+
raise ValueError(f"{what} must be a number of seconds, got {value!r}") from None
|
|
43
|
+
if not (math.isfinite(seconds) and seconds > 0):
|
|
44
|
+
raise ValueError(f"{what} must be a positive number of seconds, got {value!r}")
|
|
45
|
+
return seconds
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _env_seconds(name: str) -> float | None:
|
|
49
|
+
raw = os.environ.get(name, "").strip()
|
|
50
|
+
return _positive_seconds(raw, f"${name}") if raw else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def heartbeat_interval(value=None) -> float:
|
|
54
|
+
"""``value``, else ``$EXPTRAIL_HEARTBEAT_S``, else 30 seconds."""
|
|
55
|
+
if value is not None:
|
|
56
|
+
return _positive_seconds(value, "heartbeat_s")
|
|
57
|
+
return _env_seconds("EXPTRAIL_HEARTBEAT_S") or DEFAULT_HEARTBEAT_S
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def stale_after(interval: float) -> float:
|
|
61
|
+
"""``$EXPTRAIL_STALE_S``, else 3x the run's heartbeat interval."""
|
|
62
|
+
return _env_seconds("EXPTRAIL_STALE_S") or STALE_FACTOR * interval
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# -- identity of this machine and of a process ------------------------------
|
|
66
|
+
|
|
67
|
+
def _run(args: list[str]) -> str:
|
|
68
|
+
try:
|
|
69
|
+
out = subprocess.run(args, capture_output=True, text=True, timeout=5,
|
|
70
|
+
env={**os.environ, "LC_ALL": "C"})
|
|
71
|
+
except (OSError, subprocess.SubprocessError):
|
|
72
|
+
return ""
|
|
73
|
+
return out.stdout if out.returncode == 0 else ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _linux_stat(pid: int) -> list[str] | None:
|
|
77
|
+
"""Fields 3.. of /proc/<pid>/stat (the command name may contain spaces)."""
|
|
78
|
+
try:
|
|
79
|
+
stat = Path(f"/proc/{pid}/stat").read_text()
|
|
80
|
+
except OSError:
|
|
81
|
+
return None
|
|
82
|
+
return stat[stat.rindex(")") + 2:].split()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def process_start_time(pid: int) -> str | None:
|
|
86
|
+
"""An opaque token that changes if ``pid`` is reused by another process."""
|
|
87
|
+
if sys.platform.startswith("linux"):
|
|
88
|
+
fields = _linux_stat(pid)
|
|
89
|
+
# field 22: start time in clock ticks since boot
|
|
90
|
+
return fields[19] if fields and len(fields) > 19 else None
|
|
91
|
+
if sys.platform == "darwin":
|
|
92
|
+
return " ".join(_run(["ps", "-o", "lstart=", "-p", str(pid)]).split()) or None
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_zombie(pid: int) -> bool:
|
|
97
|
+
if sys.platform.startswith("linux"):
|
|
98
|
+
fields = _linux_stat(pid)
|
|
99
|
+
return bool(fields) and fields[0] == "Z"
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def boot_id() -> str | None:
|
|
104
|
+
"""Changes on every reboot, so a recorded pid can't be mistaken for a new one."""
|
|
105
|
+
if sys.platform.startswith("linux"):
|
|
106
|
+
try:
|
|
107
|
+
return Path("/proc/sys/kernel/random/boot_id").read_text().strip() or None
|
|
108
|
+
except OSError:
|
|
109
|
+
return None
|
|
110
|
+
if sys.platform == "darwin":
|
|
111
|
+
m = re.search(r"sec\s*=\s*(\d+),\s*usec\s*=\s*(\d+)", _run(["sysctl", "-n", "kern.boottime"]))
|
|
112
|
+
return f"boottime:{m.group(1)}.{int(m.group(2)):06d}" if m else None
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _same_boot(a: str, b: str) -> bool:
|
|
117
|
+
if a.startswith("boottime:") and b.startswith("boottime:"):
|
|
118
|
+
try:
|
|
119
|
+
return abs(float(a[9:]) - float(b[9:])) < BOOT_TOLERANCE_S
|
|
120
|
+
except ValueError:
|
|
121
|
+
pass
|
|
122
|
+
return a == b
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def pid_alive(pid: int) -> bool | None:
|
|
126
|
+
"""True/False, or None when it can't be checked."""
|
|
127
|
+
if IS_WINDOWS:
|
|
128
|
+
return None # never os.kill(pid, 0) on Windows: signal 0 is CTRL_C_EVENT there
|
|
129
|
+
if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
|
|
130
|
+
return None # os.kill(0 or -1, ...) would address a whole process group
|
|
131
|
+
try:
|
|
132
|
+
os.kill(pid, 0)
|
|
133
|
+
except ProcessLookupError:
|
|
134
|
+
return False
|
|
135
|
+
except PermissionError:
|
|
136
|
+
return True # exists, owned by someone else
|
|
137
|
+
except OSError:
|
|
138
|
+
return None
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def record(heartbeat_s: float) -> dict:
|
|
143
|
+
"""What a starting run writes into meta.json under ``liveness``."""
|
|
144
|
+
pid = os.getpid()
|
|
145
|
+
return {
|
|
146
|
+
"hostname": socket.gethostname(),
|
|
147
|
+
"pid": pid,
|
|
148
|
+
"proc_start": process_start_time(pid),
|
|
149
|
+
"boot_id": boot_id(),
|
|
150
|
+
"heartbeat_s": heartbeat_s,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# -- heartbeat --------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
class Heartbeat:
|
|
157
|
+
"""Daemon thread that touches ``path`` every ``interval`` seconds.
|
|
158
|
+
|
|
159
|
+
Only the file's mtime is used, so it never races with meta.json writes,
|
|
160
|
+
and it beats whether or not the job calls log().
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, path: Path, interval: float):
|
|
164
|
+
self.path = path
|
|
165
|
+
self.interval = interval
|
|
166
|
+
self._stop = threading.Event()
|
|
167
|
+
self._thread: threading.Thread | None = None
|
|
168
|
+
|
|
169
|
+
def start(self) -> None:
|
|
170
|
+
self.path.touch()
|
|
171
|
+
self._thread = threading.Thread(target=self._beat, name="exptrail-heartbeat", daemon=True)
|
|
172
|
+
self._thread.start()
|
|
173
|
+
|
|
174
|
+
def _beat(self) -> None:
|
|
175
|
+
while not self._stop.wait(self.interval):
|
|
176
|
+
try:
|
|
177
|
+
os.utime(self.path)
|
|
178
|
+
except OSError:
|
|
179
|
+
pass # folder moved or disk hiccup: keep trying, never crash the job
|
|
180
|
+
|
|
181
|
+
def stop(self) -> None:
|
|
182
|
+
self._stop.set()
|
|
183
|
+
if self._thread is not None and self._thread is not threading.current_thread():
|
|
184
|
+
# os.utime can block for a long time on a network or FUSE mount (Google
|
|
185
|
+
# Drive on Colab). Never hang finish() on it: the thread is a daemon, so
|
|
186
|
+
# abandoning it is safe, and it exits once the pending utime returns.
|
|
187
|
+
self._thread.join(timeout=min(self.interval, 5.0))
|
|
188
|
+
self._thread = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# -- read-time status -------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class Liveness:
|
|
195
|
+
status: str # running | dead | stale
|
|
196
|
+
reason: str = ""
|
|
197
|
+
certain: bool = False
|
|
198
|
+
|
|
199
|
+
def display(self) -> str:
|
|
200
|
+
return f"{self.status} ({self.reason})" if self.reason else self.status
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _ago(seconds: float) -> str:
|
|
204
|
+
if seconds < 90:
|
|
205
|
+
return f"{seconds:.0f}s"
|
|
206
|
+
if seconds < 90 * 60:
|
|
207
|
+
return f"{seconds / 60:.0f}m"
|
|
208
|
+
if seconds < 48 * 3600:
|
|
209
|
+
return f"{seconds / 3600:.0f}h"
|
|
210
|
+
return f"{seconds / 86400:.0f}d"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _check_local(rec: dict) -> Liveness | None:
|
|
214
|
+
"""Certain answer when the run was on this machine, this boot; else None."""
|
|
215
|
+
if not rec.get("hostname") or rec["hostname"] != socket.gethostname():
|
|
216
|
+
return None
|
|
217
|
+
recorded, current = rec.get("boot_id"), boot_id()
|
|
218
|
+
if not isinstance(recorded, str) or not recorded or not current:
|
|
219
|
+
return None
|
|
220
|
+
if not _same_boot(recorded, current):
|
|
221
|
+
return Liveness("dead", "machine rebooted", True)
|
|
222
|
+
pid = rec.get("pid")
|
|
223
|
+
alive = pid_alive(pid)
|
|
224
|
+
if alive is None:
|
|
225
|
+
return None
|
|
226
|
+
if not alive or _is_zombie(pid):
|
|
227
|
+
return Liveness("dead", "pid gone", True)
|
|
228
|
+
started, now_started = rec.get("proc_start"), process_start_time(pid)
|
|
229
|
+
if not started or not now_started:
|
|
230
|
+
return None
|
|
231
|
+
if str(started) != now_started:
|
|
232
|
+
return Liveness("dead", "pid reused", True)
|
|
233
|
+
return Liveness("running", "", True)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _last_sign_of_life(run_dir: Path) -> tuple[float, str] | None:
|
|
237
|
+
try:
|
|
238
|
+
return (run_dir / HEARTBEAT_FILE).stat().st_mtime, "heartbeat"
|
|
239
|
+
except OSError:
|
|
240
|
+
pass
|
|
241
|
+
# runs from before heartbeats existed: the newest file they wrote
|
|
242
|
+
times = []
|
|
243
|
+
for name in ("meta.json", "metrics.csv", "summary.json"):
|
|
244
|
+
try:
|
|
245
|
+
times.append((run_dir / name).stat().st_mtime)
|
|
246
|
+
except OSError:
|
|
247
|
+
pass
|
|
248
|
+
return (max(times), "activity") if times else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def assess(run_dir: Path, meta: dict, now: float | None = None) -> Liveness:
|
|
252
|
+
"""Status of a run whose stored status is ``running``. Reads only."""
|
|
253
|
+
rec = meta.get("liveness")
|
|
254
|
+
rec = rec if isinstance(rec, dict) else {}
|
|
255
|
+
if not IS_WINDOWS:
|
|
256
|
+
local = _check_local(rec)
|
|
257
|
+
if local is not None:
|
|
258
|
+
return local
|
|
259
|
+
try:
|
|
260
|
+
interval = _positive_seconds(rec.get("heartbeat_s"), "heartbeat_s")
|
|
261
|
+
except ValueError:
|
|
262
|
+
interval = DEFAULT_HEARTBEAT_S
|
|
263
|
+
host = rec.get("hostname") or meta.get("hostname")
|
|
264
|
+
where = f"on {host}, " if host and host != socket.gethostname() else ""
|
|
265
|
+
seen = _last_sign_of_life(run_dir)
|
|
266
|
+
if seen is None:
|
|
267
|
+
return Liveness("stale", f"{where}no heartbeat")
|
|
268
|
+
last, what = seen
|
|
269
|
+
age = max(0.0, (time.time() if now is None else now) - last)
|
|
270
|
+
status = "stale" if age > stale_after(interval) else "running"
|
|
271
|
+
return Liveness(status, f"{where}last {what} {_ago(age)} ago")
|