2gitsync.py — multi-machine coordination for the verification campaign, over git.
4The machines share nothing but the GitHub remote, so the remote is the lock.
7 claim(run_id) -- atomically take ownership of a run, or discover someone
9 publish(...) -- record a finished run and push it, so every other machine
10 (and the user, from anywhere) sees the result immediately
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.
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.
30The branch is separate from `main` so a day of automated commits does not land
31in the history of the paper.
33from __future__
import annotations
43from contextlib
import contextmanager
44from pathlib
import Path
46HERE = Path(__file__).resolve().parent
47DEFAULT_BRANCH =
"campaign-verification"
48SYNC_DIR = HERE /
".sync"
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."""
61 except (OSError, TypeError, ValueError)
as exc:
62 return getattr(exc,
"errno",
None) == errno.EPERM
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}")
75MACHINE_ID_FILE = Path.home() /
".radcluster_machine_id"
79 """Stable, UNIQUE identity for this host.
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.
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
94 env = os.environ.get(
"RADCLUSTER_MACHINE")
98 cached = MACHINE_ID_FILE.read_text(encoding=
"utf-8").strip()
104 ident = f
"{platform.node().split('.')[0]}-{uuid.uuid4().hex[:4]}"
106 MACHINE_ID_FILE.write_text(ident +
"\n", encoding=
"utf-8")
113 """Git-backed claim board for one campaign branch."""
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()
120 self.
dir = Path(sync_dir).resolve()
133 self.
dir.parent.mkdir(parents=
True, exist_ok=
True)
134 lf = self.
dir.parent /
".sync.lock"
135 deadline = time.time() + timeout_s
140 fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
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)
149 fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
155 """Create the worktree and branch if absent; make it current."""
157 if not (self.
dir /
".git").exists():
160 (self.
dir /
"claims").mkdir(exist_ok=
True)
161 (self.
dir /
"results").mkdir(exist_ok=
True)
164 self.
dir.parent.mkdir(parents=
True, exist_ok=
True)
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
173 _git([
"worktree",
"add",
"--checkout", str(self.
dir),
188 _git([
"worktree",
"add",
"--detach",
"--no-checkout",
189 str(self.
dir),
"HEAD"], self.
repo)
191 _git([
"rm",
"-rf",
"--cached",
"."], self.
dir, check=
False)
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():
204 self.
_commit(
"campaign: initialise claim board", paths=[
"claims",
"results"])
208 """Bring the worktree to the remote's state, discarding nothing of ours.
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.
217 check=
False).returncode != 0:
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)
226 """Every claim currently on the board, by run_id."""
228 d = self.
dir /
"claims"
231 for f
in sorted(d.glob(
"*.json")):
233 out[f.stem] = json.loads(f.read_text(encoding=
"utf-8"))
235 out[f.stem] = {
"run_id": f.stem,
"status":
"unreadable"}
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.
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.
245 for attempt
in range(PUSH_RETRIES):
248 existing = self.
claims().get(run_id)
250 st = existing.get(
"status")
251 owner, opid = existing.get(
"machine"), existing.get(
"pid")
263 if opid
and int(opid) == os.getpid():
269 "run_id": run_id,
"status":
"running",
270 "machine": self.
machine,
"host": platform.node(),
272 "claimed_at": time.strftime(
"%Y-%m-%dT%H:%M:%S%z"),
275 rec.update(meta
or {})
277 self.
_commit(f
"campaign: claim {run_id} [{self.machine}]",
278 paths=[f
"claims/{run_id}.json"])
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")
297 if c.get(
"status") ==
"failed" and c.get(
"machine") == self.
machine:
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."""
309 rec = self.
claims().get(run_id, {
"run_id": run_id})
312 rec[
"published_at"] = time.strftime(
"%Y-%m-%dT%H:%M:%S%z")
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))
320 if isinstance(content, (bytes, bytearray)):
321 tgt.write_bytes(content)
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):
330 time.sleep(1.0 + random.random() * 2.0 * (attempt + 1))
335 """Drop a claim so another machine can take the run."""
341 f = self.
dir /
"claims" / f
"{run_id}.json"
345 self.
_commit(f
"campaign: release {run_id} [{self.machine}]",
346 paths=[f
"claims/{run_id}.json"])
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",
358 r =
_git([
"rev-parse",
"--short",
"HEAD"], self.
repo, check=
False)
359 return r.stdout.strip()
if r.returncode == 0
else "unknown"
362 _git([
"add",
"-A", *paths], self.
dir, check=
False)
363 if not _git([
"diff",
"--cached",
"--quiet"], self.
dir,
364 check=
False).returncode:
366 _git([
"commit",
"-m", msg], self.
dir)
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)
_commit(self, msg, paths)
str|None next_unclaimed(self, list[str] run_ids)
_release_locked(self, run_id)
bool release(self, str run_id)
_publish_locked(self, run_id, summary, artifacts)
__init__(self, Path repo_root, str branch=DEFAULT_BRANCH, Path sync_dir=SYNC_DIR, str remote="origin", bool offline=False)
bool publish(self, str run_id, dict summary, dict artifacts=None)
_lock(self, timeout_s=300)
_git(args, cwd, check=True, timeout=180)