flexlock 0.8.2__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.
flexlock/run_record.py ADDED
@@ -0,0 +1,173 @@
1
+ """The on-disk contract for a single run directory.
2
+
3
+ ``RunRecord`` is the *one* owner of the files that define a run on disk —
4
+ ``run.lock`` (the snapshot written before execution), ``run.complete`` (the
5
+ success marker), and ``results.json`` (the return payload). Every writer —
6
+ the serial ``Project.submit`` path, the sweep ``worker``, and the legacy
7
+ ``write_complete_marker`` helper — routes through it, so the driver and worker
8
+ are structurally identical and there is a single place to hook the
9
+ fingerprint index (see ``index.py``, wired in Phase 2.2).
10
+
11
+ All writes are atomic (temp file + ``os.replace``) so a concurrent reader
12
+ never observes a partial file.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import tempfile
18
+ import traceback
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+ from loguru import logger
24
+ from omegaconf import OmegaConf
25
+
26
+ LOCK_NAME = "run.lock"
27
+ COMPLETE_MARKER = "run.complete"
28
+ RESULTS_NAME = "results.json"
29
+ ERROR_NAME = "run.error"
30
+ COMPLETE_VERSION = 1
31
+ ERROR_VERSION = 1
32
+
33
+ # Run lifecycle states derived from what's on disk.
34
+ STATUS_DONE = "DONE" # run.lock + run.complete
35
+ STATUS_INCOMPLETE = "INCOMPLETE" # run.lock only (interrupted / in-flight)
36
+ STATUS_MISSING = "MISSING" # nothing
37
+
38
+
39
+ def _atomic_write(target: Path, text: str) -> Path:
40
+ target.parent.mkdir(parents=True, exist_ok=True)
41
+ with tempfile.NamedTemporaryFile("w", dir=target.parent, delete=False) as tf:
42
+ tf.write(text)
43
+ tmp_name = tf.name
44
+ os.replace(tmp_name, target)
45
+ return target
46
+
47
+
48
+ class RunRecord:
49
+ """Encapsulates one run directory's files and lifecycle."""
50
+
51
+ def __init__(self, save_dir):
52
+ self.save_dir = Path(save_dir)
53
+
54
+ # ── paths ──
55
+ @property
56
+ def lock_path(self) -> Path:
57
+ return self.save_dir / LOCK_NAME
58
+
59
+ @property
60
+ def complete_path(self) -> Path:
61
+ return self.save_dir / COMPLETE_MARKER
62
+
63
+ @property
64
+ def results_path(self) -> Path:
65
+ return self.save_dir / RESULTS_NAME
66
+
67
+ @property
68
+ def error_path(self) -> Path:
69
+ return self.save_dir / ERROR_NAME
70
+
71
+ # ── writers ──
72
+ def write_lock(self, snapshot_data: dict) -> Path:
73
+ """Write ``run.lock`` (the snapshot) atomically before execution."""
74
+ return _atomic_write(self.lock_path, OmegaConf.to_yaml(snapshot_data))
75
+
76
+ def write_results(self, result: Any) -> Path:
77
+ """Write ``results.json`` with the run's return payload."""
78
+ payload = result if isinstance(result, dict) else {"result": result}
79
+ return _atomic_write(
80
+ self.results_path, json.dumps(payload, indent=2, default=str)
81
+ )
82
+
83
+ def mark_complete(self, result: Any = None) -> Path:
84
+ """Write ``run.complete`` after the user function returns successfully.
85
+
86
+ A cache hit requires *both* ``run.lock`` and ``run.complete``; an
87
+ interrupted run leaves ``run.lock`` alone and is correctly skipped.
88
+ This is the single place the fingerprint index is refreshed (2.2).
89
+ """
90
+ # Success removes any stale failure record. Because every writer routes
91
+ # through RunRecord, this single hook implements "a completed run has no
92
+ # run.error" for the serial, sweep-worker, and legacy marker paths.
93
+ self.clear_error()
94
+ payload = {"ts": datetime.now().isoformat(), "version": COMPLETE_VERSION}
95
+ if result is not None:
96
+ payload["has_result"] = True
97
+ return _atomic_write(self.complete_path, json.dumps(payload))
98
+
99
+ def write_error(self, exc: BaseException, *, task_id=None, node=None) -> "Path | None":
100
+ """Record a ``run.error`` JSON sidecar describing a failed run.
101
+
102
+ Capturing the failure must *never* mask the original exception, so the
103
+ entire body is defensive: any problem writing the record is logged and
104
+ swallowed, and the caller re-raises the user's exception unchanged.
105
+ """
106
+ try:
107
+ payload = {
108
+ "version": ERROR_VERSION,
109
+ "exc_type": type(exc).__name__,
110
+ "exc_message": str(exc),
111
+ "traceback": "".join(
112
+ traceback.format_exception(type(exc), exc, exc.__traceback__)
113
+ ),
114
+ "timestamp": datetime.now().isoformat(),
115
+ }
116
+ if task_id is not None:
117
+ payload["task_id"] = task_id
118
+ if node is not None:
119
+ payload["node"] = node
120
+ return _atomic_write(self.error_path, json.dumps(payload, indent=2))
121
+ except Exception as write_exc: # pragma: no cover - defensive
122
+ logger.warning(f"Could not write {ERROR_NAME} at {self.save_dir}: {write_exc}")
123
+ return None
124
+
125
+ def clear_error(self) -> None:
126
+ """Remove a stale ``run.error`` if present (no-op otherwise)."""
127
+ try:
128
+ self.error_path.unlink(missing_ok=True)
129
+ except Exception as exc: # pragma: no cover - defensive
130
+ logger.warning(f"Could not clear {ERROR_NAME} at {self.save_dir}: {exc}")
131
+
132
+ # ── readers ──
133
+ def load(self) -> Optional[dict]:
134
+ """Return the parsed ``run.lock`` snapshot, or ``None`` if absent."""
135
+ if not self.lock_path.exists():
136
+ return None
137
+ return OmegaConf.to_container(OmegaConf.load(self.lock_path), resolve=True)
138
+
139
+ def load_results(self) -> Optional[Any]:
140
+ """Return the parsed ``results.json`` payload, or ``None`` if absent."""
141
+ if not self.results_path.exists():
142
+ return None
143
+ with open(self.results_path) as f:
144
+ return json.load(f)
145
+
146
+ def load_error(self) -> Optional[dict]:
147
+ """Return the parsed ``run.error`` payload, or ``None`` if absent."""
148
+ if not self.error_path.exists():
149
+ return None
150
+ try:
151
+ with open(self.error_path) as f:
152
+ return json.load(f)
153
+ except Exception as exc: # pragma: no cover - corrupt sidecar
154
+ logger.warning(f"Could not read {ERROR_NAME} at {self.save_dir}: {exc}")
155
+ return None
156
+
157
+ # ── lifecycle ──
158
+ @property
159
+ def is_complete(self) -> bool:
160
+ return self.lock_path.exists() and self.complete_path.exists()
161
+
162
+ def is_complete_task(self) -> bool:
163
+ """Completeness for a sweep-task dir, which carries ``run.complete`` and
164
+ ``results.json`` but no ``run.lock`` (its snapshot lives in the task DB)."""
165
+ return self.complete_path.exists() and self.results_path.exists()
166
+
167
+ @property
168
+ def status(self) -> str:
169
+ if self.is_complete:
170
+ return STATUS_DONE
171
+ if self.lock_path.exists():
172
+ return STATUS_INCOMPLETE
173
+ return STATUS_MISSING