RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
gitsync.py
Go to the documentation of this file.
1"""
2gitsync.py — multi-machine coordination for the verification campaign, over git.
3
4The machines share nothing but the GitHub remote, so the remote is the lock.
5Two operations matter:
6
7 claim(run_id) -- atomically take ownership of a run, or discover someone
8 else already has it
9 publish(...) -- record a finished run and push it, so every other machine
10 (and the user, from anywhere) sees the result immediately
11
12WHY A SEPARATE WORKTREE. Every git operation here runs in a dedicated worktree
13under `.sync/`, never in the checkout the solver is running from. This is not
14tidiness. `run_ensemble.py` carries a 20-line comment about the day a
15`git pull --rebase --autostash` replaced a results file underneath a running
16worker: a file handle names an inode, not a path, so the worker kept happily
17appending to an unlinked inode and 5 rows / 6.2 core-hours were written into
18nothing for three hours before anyone noticed. A campaign that pulls and
19pushes after every run would re-arm that trap on a much shorter fuse. Confining
20git to a worktree the solver never reads means a pull cannot move anything the
21solver is holding open.
22
23WHY ONE FILE PER RUN. A claim is `claims/<run_id>.json`, written by exactly one
24machine and never edited by another. Two machines therefore cannot produce a
25CONTENT conflict -- only a push race, where the loser fetches, replays its one
26commit on top and pushes again. That is a retry, not a merge. The alternative
27(one shared ledger file appended by everyone) conflicts on every concurrent
28finish and needs a real merge strategy.
29
30The branch is separate from `main` so a day of automated commits does not land
31in the history of the paper.
32"""
33from __future__ import annotations
34
35import errno
36import fcntl
37import json
38import os
39import platform
40import random
41import subprocess
42import time
43from contextlib import contextmanager
44from pathlib import Path
45
46HERE = Path(__file__).resolve().parent
47DEFAULT_BRANCH = "campaign-verification"
48SYNC_DIR = HERE / ".sync" # gitignored worktree
49PUSH_RETRIES = 6
50
51
52class SyncError(RuntimeError):
53 pass
54
55
56def _pid_alive(pid) -> bool:
57 """Is a process with this pid running? Used to tell a live sibling worker
58 from a claim left behind by one that died."""
59 try:
60 os.kill(int(pid), 0)
61 except (OSError, TypeError, ValueError) as exc:
62 return getattr(exc, "errno", None) == errno.EPERM # alive, not ours
63 return True
64
65
66def _git(args, cwd, check=True, timeout=180):
67 p = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True,
68 text=True, timeout=timeout)
69 if check and p.returncode != 0:
70 raise SyncError(f"git {' '.join(args)} failed in {cwd}:\n"
71 f"{p.stdout}\n{p.stderr}")
72 return p
73
74
75MACHINE_ID_FILE = Path.home() / ".radcluster_machine_id"
76
77
78def machine_id() -> str:
79 """Stable, UNIQUE identity for this host.
80
81 It was platform.node().split(".")[0], which is neither. This machine's
82 hostname changed from "MacBook-Pro" to "Mac.san.rr.com" mid-campaign (a
83 router handing out a domain), so it silently started claiming as "Mac" --
84 the id the OTHER machine already used. Two hosts sharing an id is not
85 cosmetic: claim() trusts pid liveness to tell a live sibling worker from a
86 dead one, and a pid is only meaningful on the machine that issued it. A
87 stale-looking claim could then be taken over while its real owner was still
88 computing, which is precisely the duplicate work the board exists to stop.
89
90 So the id is persisted on first use and never derived from a name that can
91 change underneath us. RADCLUSTER_MACHINE still overrides, for naming a
92 machine deliberately.
93 """
94 env = os.environ.get("RADCLUSTER_MACHINE")
95 if env:
96 return env
97 try:
98 cached = MACHINE_ID_FILE.read_text(encoding="utf-8").strip()
99 if cached:
100 return cached
101 except OSError:
102 pass
103 import uuid
104 ident = f"{platform.node().split('.')[0]}-{uuid.uuid4().hex[:4]}"
105 try:
106 MACHINE_ID_FILE.write_text(ident + "\n", encoding="utf-8")
107 except OSError:
108 pass # unwritable home: fall back to per-process id
109 return ident
110
111
113 """Git-backed claim board for one campaign branch."""
114
115 def __init__(self, repo_root: Path, branch: str = DEFAULT_BRANCH,
116 sync_dir: Path = SYNC_DIR, remote: str = "origin",
117 offline: bool = False):
118 self.repo = Path(repo_root).resolve()
119 self.branch = branch
120 self.dir = Path(sync_dir).resolve()
121 self.remote = remote
122 self.offline = offline
124
125 # WORKTREE MUTEX. Several workers on ONE machine share this worktree, and
126 # concurrent `git add`/`commit`/`push` in a single worktree race on
127 # .git/index.lock -- one of them fails, and which one is luck. Every
128 # sequence of git commands therefore runs under an flock on a sidecar file.
129 # Cross-machine exclusion is still the remote's job; this only serialises
130 # the local processes that share these files.
131 @contextmanager
132 def _lock(self, timeout_s=300):
133 self.dir.parent.mkdir(parents=True, exist_ok=True)
134 lf = self.dir.parent / ".sync.lock"
135 deadline = time.time() + timeout_s
136 fh = open(lf, "a+")
137 try:
138 while True:
139 try:
140 fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
141 break
142 except OSError:
143 if time.time() > deadline:
144 raise SyncError(f"worktree lock busy for {timeout_s}s")
145 time.sleep(0.2 + random.random() * 0.3)
146 yield
147 finally:
148 try:
149 fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
150 finally:
151 fh.close()
152
153 # ── setup ────────────────────────────────────────────────────────────────
154 def ensure(self):
155 """Create the worktree and branch if absent; make it current."""
156 with self._lock():
157 if not (self.dir / ".git").exists():
158 self._create_worktree()
159 self.refresh()
160 (self.dir / "claims").mkdir(exist_ok=True)
161 (self.dir / "results").mkdir(exist_ok=True)
162
164 self.dir.parent.mkdir(parents=True, exist_ok=True)
165 if not self.offline:
166 _git(["fetch", self.remote], self.repo, check=False)
167 remote_ref = f"{self.remote}/{self.branch}"
168 has_remote = _git(["rev-parse", "--verify", "-q", remote_ref],
169 self.repo, check=False).returncode == 0
170 has_local = _git(["rev-parse", "--verify", "-q", self.branch],
171 self.repo, check=False).returncode == 0
172 if has_remote:
173 _git(["worktree", "add", "--checkout", str(self.dir),
174 "-B", self.branch, remote_ref], self.repo)
175 elif has_local:
176 _git(["worktree", "add", str(self.dir), self.branch], self.repo)
177 else:
178 # First machine to start the campaign creates the branch, ORPHANED.
179 # Rooting it at HEAD instead would check the entire repository out a
180 # second time -- measured at 210 MB here, duplicated on every
181 # machine, to hold a directory of small JSON files. The link back
182 # to the code is kept where it belongs: each claim records the
183 # `code_sha` it ran at.
184 #
185 # --no-checkout means those 210 MB are never written at all: the
186 # index is populated but no file lands, and `rm -rf --cached` then
187 # empties the index before the first commit.
188 _git(["worktree", "add", "--detach", "--no-checkout",
189 str(self.dir), "HEAD"], self.repo)
190 _git(["checkout", "--orphan", self.branch], self.dir)
191 _git(["rm", "-rf", "--cached", "."], self.dir, check=False)
192 # `checkout --orphan` carries the index into the working tree, so
193 # the files land anyway; emptying the index does not remove them.
194 # This runs BEFORE claims/ and results/ are created, so there is
195 # nothing of ours for it to take -- and the worktree is a dedicated,
196 # gitignored directory, so `clean -fdx` here cannot reach the user's
197 # checkout.
198 _git(["clean", "-fdxq"], self.dir, check=False)
199 for sub in ("claims", "results"):
200 (self.dir / sub).mkdir(exist_ok=True)
201 keep = self.dir / sub / ".gitkeep"
202 if not keep.exists():
203 keep.write_text("")
204 self._commit("campaign: initialise claim board", paths=["claims", "results"])
205 self._push()
206
207 def refresh(self):
208 """Bring the worktree to the remote's state, discarding nothing of ours.
209
210 Our own commits are preserved by rebasing them on top; the worktree
211 holds only campaign bookkeeping, so there is never local work here that
212 did not come from this module.
213 """
214 if self.offline:
215 return
216 if _git(["fetch", self.remote, self.branch], self.dir,
217 check=False).returncode != 0:
218 return # branch not on the remote yet
219 r = _git(["rebase", f"{self.remote}/{self.branch}"], self.dir, check=False)
220 if r.returncode != 0:
221 _git(["rebase", "--abort"], self.dir, check=False)
222 _git(["reset", "--hard", f"{self.remote}/{self.branch}"], self.dir)
223
224 # ── claim board ──────────────────────────────────────────────────────────
225 def claims(self) -> dict:
226 """Every claim currently on the board, by run_id."""
227 out = {}
228 d = self.dir / "claims"
229 if not d.exists():
230 return out
231 for f in sorted(d.glob("*.json")):
232 try:
233 out[f.stem] = json.loads(f.read_text(encoding="utf-8"))
234 except Exception:
235 out[f.stem] = {"run_id": f.stem, "status": "unreadable"}
236 return out
237
238 def claim(self, run_id: str, meta: dict = None) -> bool:
239 """Try to take `run_id`. True if we own it, False if someone else does.
240
241 The remote decides. We write the claim, commit and push; if the push is
242 rejected we refresh and look again -- if the run is now claimed by
243 another machine we lose it cleanly, and if it is still free we retry.
244 """
245 for attempt in range(PUSH_RETRIES):
246 with self._lock():
247 self.refresh()
248 existing = self.claims().get(run_id)
249 if existing:
250 st = existing.get("status")
251 owner, opid = existing.get("machine"), existing.get("pid")
252 if st == "done":
253 return False # already computed
254 if owner != self.machine:
255 return False # another machine owns it
256 # SAME MACHINE. This used to return True on the reasoning that
257 # a claim by this machine must be ours resuming after a restart.
258 # With several workers on one box that is false, and it handed
259 # TWO of them the same run: both took T1_B1 and started
260 # identical multi-hour solves. The owning PROCESS is what
261 # decides -- a live sibling keeps it, a dead one leaves a stale
262 # claim we may take over.
263 if opid and int(opid) == os.getpid():
264 return True # genuinely ours
265 if opid and _pid_alive(opid):
266 return False # a live worker on this box owns it
267 # else: stale claim from a process that died -> take it over
268 rec = {
269 "run_id": run_id, "status": "running",
270 "machine": self.machine, "host": platform.node(),
271 "pid": os.getpid(),
272 "claimed_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
273 "code_sha": self._head_sha(),
274 }
275 rec.update(meta or {})
276 self._write_claim(rec)
277 self._commit(f"campaign: claim {run_id} [{self.machine}]",
278 paths=[f"claims/{run_id}.json"])
279 if self._push():
280 return True
281 # Lost the race (or a transient failure): back off and re-examine.
282 # Outside the lock, so a sibling can make progress meanwhile.
283 time.sleep(1.0 + random.random() * 2.0 * (attempt + 1))
284 raise SyncError(f"could not settle a claim for {run_id} after "
285 f"{PUSH_RETRIES} attempts")
286
287 def next_unclaimed(self, run_ids: list[str]) -> str | None:
288 self.refresh()
289 held = self.claims()
290 for rid in run_ids:
291 c = held.get(rid)
292 if c is None:
293 return rid
294 # A run whose owner died mid-flight stays 'running' forever; that is
295 # deliberate. Reassigning it automatically would risk two machines
296 # computing it at once. `--release` is the explicit way back.
297 if c.get("status") == "failed" and c.get("machine") == self.machine:
298 continue
299 return None
300
301 # ── publishing results ───────────────────────────────────────────────────
302 def publish(self, run_id: str, summary: dict, artifacts: dict = None) -> bool:
303 """Record a finished run and push it. Called after EVERY run."""
304 with self._lock():
305 return self._publish_locked(run_id, summary, artifacts)
306
307 def _publish_locked(self, run_id, summary, artifacts):
308 self.refresh()
309 rec = self.claims().get(run_id, {"run_id": run_id})
310 rec.update(summary)
311 rec["machine"] = self.machine
312 rec["published_at"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
313 self._write_claim(rec)
314 paths = [f"claims/{run_id}.json"]
315 rdir = self.dir / "results" / run_id
316 rdir.mkdir(parents=True, exist_ok=True)
317 for name, content in (artifacts or {}).items():
318 safe = os.path.basename(str(name))
319 tgt = rdir / safe
320 if isinstance(content, (bytes, bytearray)):
321 tgt.write_bytes(content)
322 else:
323 tgt.write_text(str(content), encoding="utf-8")
324 paths.append(f"results/{run_id}/{safe}")
325 status = rec.get("status", "?")
326 self._commit(f"campaign: {run_id} {status} [{self.machine}]", paths=paths)
327 for attempt in range(PUSH_RETRIES):
328 if self._push():
329 return True
330 time.sleep(1.0 + random.random() * 2.0 * (attempt + 1))
331 self.refresh()
332 return False
333
334 def release(self, run_id: str) -> bool:
335 """Drop a claim so another machine can take the run."""
336 with self._lock():
337 return self._release_locked(run_id)
338
339 def _release_locked(self, run_id):
340 self.refresh()
341 f = self.dir / "claims" / f"{run_id}.json"
342 if not f.exists():
343 return False
344 f.unlink()
345 self._commit(f"campaign: release {run_id} [{self.machine}]",
346 paths=[f"claims/{run_id}.json"])
347 return self._push()
348
349 # ── plumbing ─────────────────────────────────────────────────────────────
350 def _write_claim(self, rec):
351 d = self.dir / "claims"
352 d.mkdir(parents=True, exist_ok=True)
353 (d / f"{rec['run_id']}.json").write_text(
354 json.dumps(rec, indent=2, sort_keys=True, default=str) + "\n",
355 encoding="utf-8")
356
357 def _head_sha(self):
358 r = _git(["rev-parse", "--short", "HEAD"], self.repo, check=False)
359 return r.stdout.strip() if r.returncode == 0 else "unknown"
360
361 def _commit(self, msg, paths):
362 _git(["add", "-A", *paths], self.dir, check=False)
363 if not _git(["diff", "--cached", "--quiet"], self.dir,
364 check=False).returncode:
365 return False # nothing staged
366 _git(["commit", "-m", msg], self.dir)
367 return True
368
369 def _push(self) -> bool:
370 if self.offline:
371 return True
372 return _git(["push", self.remote, f"HEAD:{self.branch}"], self.dir,
373 check=False).returncode == 0
bool claim(self, str run_id, dict meta=None)
Definition gitsync.py:238
_write_claim(self, rec)
Definition gitsync.py:350
_commit(self, msg, paths)
Definition gitsync.py:361
str|None next_unclaimed(self, list[str] run_ids)
Definition gitsync.py:287
_release_locked(self, run_id)
Definition gitsync.py:339
bool release(self, str run_id)
Definition gitsync.py:334
_publish_locked(self, run_id, summary, artifacts)
Definition gitsync.py:307
__init__(self, Path repo_root, str branch=DEFAULT_BRANCH, Path sync_dir=SYNC_DIR, str remote="origin", bool offline=False)
Definition gitsync.py:117
bool publish(self, str run_id, dict summary, dict artifacts=None)
Definition gitsync.py:302
dict claims(self)
Definition gitsync.py:225
bool _push(self)
Definition gitsync.py:369
_lock(self, timeout_s=300)
Definition gitsync.py:132
str machine_id()
Definition gitsync.py:78
_git(args, cwd, check=True, timeout=180)
Definition gitsync.py:66
bool _pid_alive(pid)
Definition gitsync.py:56