nightshift-grid 0.1.1__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.
chaos_test.py ADDED
@@ -0,0 +1,700 @@
1
+ """End-to-end chaos test: Coordinator + 3 heterogeneous workers, real processes, real HTTP, auth on.
2
+
3
+ Phase 1 - Good-citizen preemption: submit 24 chunks, wait until node-legacy-laptop holds a
4
+ lease mid-stream, `touch /tmp/preempt_node-legacy-laptop`, and measure
5
+ detect -> reclaim -> re-lease -> recovered-chunk-complete latencies.
6
+ Phase 2 - Worker crash: SIGKILL node-office-pc mid-lease (no goodbye) and measure lease-expiry
7
+ recovery.
8
+ Phase 3 - Coordinator crash: SIGKILL the coordinator mid-job while chunks are in flight,
9
+ restart it on the same SQLite state file, and verify the job finishes - in-flight
10
+ leases should be completed by the workers that held them (no wasted work).
11
+ Phase 4 - Host CPU spike ("the user started a compile"): once every worker is leasing, spawn
12
+ one busy-loop process per core. Every worker shares this host, so every worker's
13
+ foreign-CPU guard should trip: measure hog-start -> `cpu_busy` preemption per worker,
14
+ assert no lease is handed to a paused worker while the hog runs, kill the hog and
15
+ measure the time until each worker leases again.
16
+ Verify - every chunk COMPLETED exactly once, nothing dead-lettered, output_graph.jsonl holds
17
+ exactly one record per (job, chunk), unauthenticated requests are rejected, and no
18
+ worker was preempted for a reason the test did not inject (e.g. the CPU guard firing
19
+ on our own load during phases 1-3).
20
+
21
+ The CPU guard is enabled with the same threshold for the whole run rather than only for
22
+ phase 4: the worker processes are shared by all phases, and running phases 1-3 with the guard
23
+ armed is exactly what proves it does not false-positive on the grid's own load.
24
+
25
+ python chaos_test.py --mock # ~3 min, no model needed
26
+ python chaos_test.py --model qwen3:4b # live Ollama
27
+ python chaos_test.py --mock --phases 1,4 --markdown --markdown-out bench.md
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import functools
33
+ import json
34
+ import os
35
+ import secrets
36
+ import signal
37
+ import subprocess
38
+ import sys
39
+ import time
40
+ from pathlib import Path
41
+ from typing import Optional
42
+
43
+ import httpx
44
+
45
+ from demo_submit import SAMPLE_CHUNKS
46
+
47
+ HERE = Path(__file__).resolve().parent
48
+ # Reports and logs go next to the code in a checkout, else to the current directory (installed copy).
49
+ OUT = HERE if (HERE / "pyproject.toml").exists() else Path.cwd()
50
+ WORKERS = [("node-office-pc", 2), ("node-legacy-laptop", 1), ("node-mac-studio", 3)]
51
+ PREEMPT_NODE, CRASH_NODE = "node-legacy-laptop", "node-office-pc"
52
+ N = len(SAMPLE_CHUNKS)
53
+ ALL_PHASES = (1, 2, 3, 4)
54
+ PREEMPT_KINDS = ("PREEMPT_RECLAIM", "PREEMPT_IDLE")
55
+ # Reasons the harness itself injects. Anything else seen before phase 4 is a false preemption.
56
+ INJECTED_REASONS = {"trigger_file", "shutdown"}
57
+
58
+ # The hog bounds its own lifetime so that even if this harness is SIGKILLed before its
59
+ # `finally` runs, no busy-loop outlives the test by more than a few seconds. It is still a
60
+ # pure-Python spin (no sleeps, no syscalls in the hot loop) so it reads as 100% user CPU.
61
+ HOG_CODE = "import time, sys\nend = time.monotonic() + float(sys.argv[1])\nwhile time.monotonic() < end: pass\n"
62
+
63
+
64
+ @functools.lru_cache(maxsize=1)
65
+ def worker_help() -> str:
66
+ """`worker_daemon.py --help`, used to feature-detect guard flags instead of hard-coding them.
67
+
68
+ The worker's CLI is evolving (e.g. `--cpu-busy-percent` -> `--max-host-cpu-pct`); probing
69
+ keeps this harness runnable against either version and lets it say which one it used."""
70
+ try:
71
+ return subprocess.run([sys.executable, str(HERE / "worker_daemon.py"), "--help"], capture_output=True,
72
+ text=True, timeout=30, cwd=HERE).stdout
73
+ except (OSError, subprocess.SubprocessError):
74
+ return ""
75
+
76
+
77
+ def worker_supports(flag: str) -> bool:
78
+ return f"{flag} " in worker_help() or f"{flag}\n" in worker_help() or f"{flag}," in worker_help()
79
+
80
+
81
+ def guard_args(cpu_pct: float, cpu_samples: Optional[int]) -> tuple[list[str], str]:
82
+ """Worker argv that arms the foreign-CPU guard at `cpu_pct`, plus a description for the report."""
83
+ if worker_supports("--max-host-cpu-pct"):
84
+ argv, desc = ["--max-host-cpu-pct", f"{cpu_pct:g}"], f"--max-host-cpu-pct {cpu_pct:g}"
85
+ elif worker_supports("--cpu-busy-percent"):
86
+ argv, desc = ["--cpu-busy-percent", f"{cpu_pct:g}"], f"--cpu-busy-percent {cpu_pct:g} (legacy flag)"
87
+ else:
88
+ return [], "none (worker has no CPU guard flag)"
89
+ if cpu_samples and worker_supports("--cpu-busy-samples"):
90
+ argv += ["--cpu-busy-samples", str(cpu_samples)]
91
+ desc += f" --cpu-busy-samples {cpu_samples}"
92
+ return argv, desc
93
+
94
+
95
+ class Grid:
96
+ def __init__(self, args):
97
+ self.args = args
98
+ self.url = f"http://127.0.0.1:{args.port}"
99
+ self.logdir = OUT / "chaos_logs"
100
+ self.logdir.mkdir(exist_ok=True)
101
+ self.output = OUT / args.output
102
+ self.db = self.logdir / "grid_state.db"
103
+ self.worker_token = secrets.token_urlsafe(32)
104
+ self.client_token = secrets.token_urlsafe(32)
105
+ self.http = httpx.Client(base_url=self.url, timeout=5,
106
+ headers={"Authorization": f"Bearer {self.client_token}"})
107
+ self.procs: dict[str, subprocess.Popen] = {}
108
+ self.events: list[dict] = []
109
+ self.since = 0
110
+ self.coordinator_boots = 0
111
+ self.spawns: dict[str, int] = {}
112
+ self.guard_argv, self.guard_desc = guard_args(args.cpu_pct, args.cpu_samples)
113
+
114
+ # ---------------------------------------------------------------- process management
115
+ def spawn(self, name: str, argv: list[str], env: dict[str, str]) -> None:
116
+ self.spawns[name] = self.spawns.get(name, 0) + 1
117
+ log_name = name if self.spawns[name] == 1 else f"{name}-{self.spawns[name]}"
118
+ log = open(self.logdir / f"{log_name}.log", "w")
119
+ self.procs[name] = subprocess.Popen([sys.executable, "-u", *argv], cwd=HERE, stdout=log,
120
+ stderr=subprocess.STDOUT, env={**os.environ, **env})
121
+
122
+ def start_coordinator(self) -> None:
123
+ self.coordinator_boots += 1
124
+ argv = ["coordinator.py", "--port", str(self.args.port), "--output", str(self.output), "--db", str(self.db)]
125
+ if "--no-advertise" in coordinator_help():
126
+ argv.append("--no-advertise") # a throwaway test grid must not show up in LAN discovery
127
+ if getattr(self.args, "relay", False):
128
+ argv.append("--relay")
129
+ self.spawn("coordinator", argv,
130
+ {"GRID_WORKER_TOKEN": self.worker_token, "GRID_CLIENT_TOKEN": self.client_token, "GRID_TOKEN": ""})
131
+ self.wait_for(lambda: httpx.get(f"{self.url}/healthz", timeout=1).status_code == 200, 15, "coordinator up")
132
+
133
+ def start_worker(self, wid: str, tier: int) -> None:
134
+ argv = ["worker_daemon.py", "--id", wid, "--tier", str(tier), "--coordinator", self.url, "--allow-battery"]
135
+ argv += ["--mock"] if self.args.mock else ["--model", self.args.model]
136
+ argv += self.guard_argv
137
+ if worker_supports("--allow-no-idle-sensor"):
138
+ argv.append("--allow-no-idle-sensor") # the harness injects activity via trigger files, not HID
139
+ if self.args.min_free_ram_gb is not None and worker_supports("--min-free-ram-gb"):
140
+ argv += ["--min-free-ram-gb", f"{self.args.min_free_ram_gb:g}"]
141
+ self.spawn(wid, argv, {"GRID_WORKER_TOKEN": self.worker_token, "GRID_TOKEN": "", "NIGHTSHIFT_TOKEN": ""})
142
+
143
+ def start(self) -> None:
144
+ for f in (self.output, self.db, Path(f"{self.db}-wal"), Path(f"{self.db}-shm")):
145
+ f.unlink(missing_ok=True)
146
+ for wid, _ in WORKERS:
147
+ Path(f"/tmp/preempt_{wid}").unlink(missing_ok=True)
148
+ self.start_coordinator()
149
+ for wid, tier in WORKERS:
150
+ self.start_worker(wid, tier)
151
+ self.wait_for(lambda: len(self.status()["workers"]) == len(WORKERS), 60, "all workers registered")
152
+
153
+ def live_workers(self) -> list[str]:
154
+ return [wid for wid, _ in WORKERS if wid in self.procs and self.procs[wid].poll() is None]
155
+
156
+ def respawn_dead_workers(self) -> list[str]:
157
+ """Bring back workers killed by earlier phases so later phases measure the full grid."""
158
+ revived = [(wid, tier) for wid, tier in WORKERS if wid not in self.live_workers()]
159
+ for wid, tier in revived:
160
+ self.start_worker(wid, tier)
161
+ if revived:
162
+ ids = {w for w, _ in revived}
163
+ self.wait_for(lambda: ids <= {w["worker_id"] for w in self.status()["workers"] if w["state"] != "OFFLINE"},
164
+ 60, "revived workers registered")
165
+ return [w for w, _ in revived]
166
+
167
+ def stop(self) -> None:
168
+ for p in self.procs.values():
169
+ if p.poll() is None:
170
+ p.terminate()
171
+ for p in self.procs.values():
172
+ try:
173
+ p.wait(5)
174
+ except subprocess.TimeoutExpired:
175
+ p.kill()
176
+ for wid, _ in WORKERS:
177
+ Path(f"/tmp/preempt_{wid}").unlink(missing_ok=True)
178
+
179
+ # ---------------------------------------------------------------- API helpers
180
+ def status(self) -> dict:
181
+ return self.http.get("/v1/grid/status").raise_for_status().json()
182
+
183
+ def job(self, job_id: str) -> dict:
184
+ return self.http.get(f"/v1/grid/jobs/{job_id}").raise_for_status().json()
185
+
186
+ def submit(self, name: str) -> str:
187
+ r = self.http.post("/v1/grid/mapreduce", json={"job_name": name, "chunks": SAMPLE_CHUNKS, "required_tier": 1})
188
+ return r.raise_for_status().json()["job_id"]
189
+
190
+ def poll_events(self) -> None:
191
+ try:
192
+ new = self.http.get("/v1/grid/events", params={"since_seq": self.since}).raise_for_status().json()
193
+ except httpx.HTTPError:
194
+ return # coordinator restarting
195
+ if new:
196
+ self.since = new[-1]["seq"]
197
+ self.events.extend(new)
198
+
199
+ def wait_event(self, pred, timeout: float, what: str) -> dict:
200
+ deadline = time.time() + timeout
201
+ while time.time() < deadline:
202
+ for e in self.events:
203
+ if pred(e):
204
+ return e
205
+ self.poll_events()
206
+ time.sleep(0.02)
207
+ raise TimeoutError(f"timed out waiting for {what}")
208
+
209
+ @staticmethod
210
+ def wait_for(pred, timeout: float, what: str) -> None:
211
+ deadline = time.time() + timeout
212
+ while time.time() < deadline:
213
+ try:
214
+ if pred():
215
+ return
216
+ except httpx.HTTPError:
217
+ pass
218
+ time.sleep(0.1)
219
+ raise TimeoutError(f"timed out waiting for {what}")
220
+
221
+ def wait_job(self, job_id: str, timeout: float) -> dict:
222
+ deadline = time.time() + timeout
223
+ while time.time() < deadline:
224
+ self.poll_events()
225
+ try:
226
+ st = self.job(job_id)
227
+ if st["done"]:
228
+ return st
229
+ except httpx.HTTPError:
230
+ pass
231
+ time.sleep(0.25)
232
+ raise TimeoutError(f"job {job_id} did not finish")
233
+
234
+ def ev(self, kind: str, **match) -> list[dict]:
235
+ return [e for e in self.events if e["kind"] == kind and all(e.get(k) == v for k, v in match.items())]
236
+
237
+
238
+ @functools.lru_cache(maxsize=1)
239
+ def coordinator_help() -> str:
240
+ try:
241
+ return subprocess.run([sys.executable, str(HERE / "coordinator.py"), "--help"], capture_output=True,
242
+ text=True, timeout=30, cwd=HERE).stdout
243
+ except (OSError, subprocess.SubprocessError):
244
+ return ""
245
+
246
+
247
+ def _r(x: Optional[float]) -> Optional[float]:
248
+ return round(x, 3) if x is not None else None
249
+
250
+
251
+ # -------------------------------------------------------------------- phases
252
+ def phase_preempt(g: Grid) -> dict:
253
+ print("\n=== Phase 1: live preemption of", PREEMPT_NODE, "===")
254
+ job_id = g.submit("phase1-preemption")
255
+ lease = g.wait_event(lambda e: e["kind"] == "LEASED" and e["worker_id"] == PREEMPT_NODE and e["job_id"] == job_id,
256
+ 60, f"lease on {PREEMPT_NODE}")
257
+ time.sleep(g.args.preempt_after) # let it get mid-stream
258
+ trigger = Path(f"/tmp/preempt_{PREEMPT_NODE}")
259
+ t_touch = time.time()
260
+ trigger.touch()
261
+ print(f" touched {trigger} while {PREEMPT_NODE} held chunk #{lease['chunk_index']}")
262
+ task_id = lease["task_id"]
263
+ reclaim = g.wait_event(lambda e: e["kind"] == "PREEMPT_RECLAIM" and e["task_id"] == task_id and e["ts"] >= t_touch, 10, "reclaim")
264
+ relet = g.wait_event(lambda e: e["kind"] == "LEASED" and e["task_id"] == task_id and e["ts"] > reclaim["ts"], 60, "re-lease")
265
+ done = g.wait_event(lambda e: e["kind"] == "COMPLETED" and e["task_id"] == task_id, 300, "recovered chunk completion")
266
+ st = g.wait_job(job_id, 900)
267
+ leaked = [e for e in g.ev("LEASED", worker_id=PREEMPT_NODE) if e["ts"] > t_touch]
268
+ trigger.unlink()
269
+ det = reclaim["data"].get("detected_at")
270
+ return {
271
+ "job": st, "preempted_chunk": lease["chunk_index"], "recovered_by": relet["worker_id"],
272
+ "leases_issued_to_preempted_node_while_user_active": len(leaked),
273
+ "latency_s": {
274
+ "touch_to_detect": round(det - t_touch, 3) if det else None,
275
+ "detect_to_coordinator_reclaim": round(reclaim["ts"] - det, 3) if det else None,
276
+ "touch_to_reclaim": round(reclaim["ts"] - t_touch, 3),
277
+ "reclaim_to_relet": round(relet["ts"] - reclaim["ts"], 3),
278
+ "touch_to_recovered_chunk_completed": round(done["ts"] - t_touch, 3),
279
+ },
280
+ }
281
+
282
+
283
+ def phase_worker_crash(g: Grid) -> dict:
284
+ print("\n=== Phase 2: SIGKILL", CRASH_NODE, "mid-lease (lease-expiry path) ===")
285
+ job_id = g.submit("phase2-worker-crash")
286
+ lease = g.wait_event(lambda e: e["kind"] == "LEASED" and e["worker_id"] == CRASH_NODE and e["job_id"] == job_id,
287
+ 60, f"lease on {CRASH_NODE}")
288
+ time.sleep(0.5)
289
+ t_kill = time.time()
290
+ g.procs[CRASH_NODE].send_signal(signal.SIGKILL)
291
+ g.procs[CRASH_NODE].wait()
292
+ print(f" SIGKILLed {CRASH_NODE} while holding chunk #{lease['chunk_index']}")
293
+ task_id = lease["task_id"]
294
+ expired = g.wait_event(lambda e: e["kind"] == "LEASE_EXPIRED" and e["task_id"] == task_id, 30, "lease expiry")
295
+ relet = g.wait_event(lambda e: e["kind"] == "LEASED" and e["task_id"] == task_id and e["ts"] > expired["ts"], 60, "re-lease")
296
+ done = g.wait_event(lambda e: e["kind"] == "COMPLETED" and e["task_id"] == task_id, 300, "recovered chunk completion")
297
+ offline = g.wait_event(lambda e: e["kind"] == "NODE_OFFLINE" and e["worker_id"] == CRASH_NODE, 30, "offline detection")
298
+ st = g.wait_job(job_id, 900)
299
+ resumed = len(g.ev("COMPLETED", job_id=job_id, worker_id=PREEMPT_NODE))
300
+ return {
301
+ "job": st, "killed_chunk": lease["chunk_index"], "recovered_by": relet["worker_id"],
302
+ "previously_preempted_node_resumed_chunks": resumed,
303
+ "latency_s": {
304
+ "kill_to_lease_expired": round(expired["ts"] - t_kill, 3),
305
+ "expired_to_relet": round(relet["ts"] - expired["ts"], 3),
306
+ "kill_to_recovered_chunk_completed": round(done["ts"] - t_kill, 3),
307
+ "kill_to_node_offline": round(offline["ts"] - t_kill, 3),
308
+ },
309
+ }
310
+
311
+
312
+ def phase_coordinator_crash(g: Grid) -> dict:
313
+ print("\n=== Phase 3: SIGKILL the coordinator mid-job, restart on the same state file ===")
314
+ job_id = g.submit("phase3-coordinator-crash")
315
+ g.wait_event(lambda e: e["kind"] == "COMPLETED" and e["job_id"] == job_id
316
+ and len(g.ev("COMPLETED", job_id=job_id)) >= 4, 300, "a few completions")
317
+ # Crash while at least one chunk is in flight.
318
+ g.wait_for(lambda: g.status()["task_counts"]["LEASED"] > 0, 30, "an in-flight lease")
319
+ g.poll_events()
320
+ completed_before = {e["task_id"] for e in g.ev("COMPLETED", job_id=job_id)}
321
+ leased = {e["task_id"]: e for e in g.ev("LEASED", job_id=job_id)}
322
+ inflight = {t for t in leased if t not in completed_before}
323
+ t_kill = time.time()
324
+ g.procs["coordinator"].send_signal(signal.SIGKILL)
325
+ g.procs["coordinator"].wait()
326
+ print(f" SIGKILLed coordinator: {len(completed_before)}/{N} done, {len(inflight)} chunk(s) in flight")
327
+ time.sleep(g.args.outage)
328
+ t_restart = time.time()
329
+ g.start_coordinator()
330
+ t_ready = time.time()
331
+ recovered = g.wait_event(lambda e: e["kind"] == "RECOVERED" and e["ts"] >= t_restart, 10, "recovery event")
332
+ st = g.wait_job(job_id, 900)
333
+ recs = {r["task_id"]: r for r in map(json.loads, g.output.read_text().splitlines()) if r["job_id"] == job_id}
334
+ by_original = sum(1 for t in inflight if t in recs and recs[t]["worker_id"] == leased[t]["worker_id"]
335
+ and recs[t]["attempts"] == leased[t]["data"]["attempt"])
336
+ first_after = min((e["ts"] for e in g.ev("COMPLETED", job_id=job_id) if e["ts"] > t_restart), default=None)
337
+ return {
338
+ "job": st, "completed_before_crash": len(completed_before), "inflight_at_crash": len(inflight),
339
+ "inflight_completed_by_original_holder": by_original,
340
+ "recovered": recovered["data"],
341
+ "latency_s": {
342
+ "outage_injected": g.args.outage,
343
+ "restart_to_ready": round(t_ready - t_restart, 3),
344
+ "kill_to_first_completion_after_restart": round(first_after - t_kill, 3) if first_after else None,
345
+ },
346
+ }
347
+
348
+
349
+ def spawn_hogs(n: int, max_s: float) -> list[subprocess.Popen]:
350
+ """Start `n` single-core busy loops, each in its own session so a killpg cannot miss one."""
351
+ return [subprocess.Popen([sys.executable, "-c", HOG_CODE, str(max_s + 2)], stdin=subprocess.DEVNULL,
352
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
353
+ for _ in range(n)]
354
+
355
+
356
+ def kill_hogs(hogs: list[subprocess.Popen]) -> None:
357
+ for h in hogs:
358
+ try:
359
+ os.killpg(h.pid, signal.SIGKILL)
360
+ except (ProcessLookupError, PermissionError, OSError):
361
+ try:
362
+ h.kill()
363
+ except OSError:
364
+ pass
365
+ for h in hogs:
366
+ try:
367
+ h.wait(5)
368
+ except subprocess.TimeoutExpired:
369
+ pass
370
+
371
+
372
+ def phase_cpu_spike(g: Grid) -> dict:
373
+ print("\n=== Phase 4: host CPU spike (busy-loop on every core) ===")
374
+ revived = g.respawn_dead_workers()
375
+ if revived:
376
+ print(f" respawned {', '.join(revived)} (killed in an earlier phase) so every node is measured")
377
+ job_id = g.submit("phase4-cpu-spike")
378
+ workers = set(g.live_workers())
379
+
380
+ def all_leasing() -> bool:
381
+ g.poll_events()
382
+ return {e["worker_id"] for e in g.ev("LEASED", job_id=job_id)} >= workers
383
+
384
+ g.wait_for(all_leasing, 90, "every worker leasing")
385
+ n_hogs = g.args.hog_procs or os.cpu_count() or 4
386
+ hogs: list[subprocess.Popen] = []
387
+ preempt: dict[str, dict] = {}
388
+ t_hog = time.time()
389
+ try:
390
+ hogs = spawn_hogs(n_hogs, g.args.hog_max)
391
+ print(f" started {n_hogs} busy-loop processes (cap {g.args.hog_max:g}s); guard: {g.guard_desc}")
392
+ deadline = t_hog + g.args.hog_max
393
+ while time.time() < deadline and set(preempt) < workers:
394
+ g.poll_events()
395
+ for e in g.events:
396
+ if (e["kind"] in PREEMPT_KINDS and e["ts"] >= t_hog and e.get("worker_id") in workers
397
+ and e["data"].get("reason") == "cpu_busy"):
398
+ preempt.setdefault(e["worker_id"], e)
399
+ time.sleep(0.05)
400
+ # Keep the load on a little longer so "no leases while paused" is observed, not assumed.
401
+ hold_until = min(deadline, time.time() + g.args.hog_hold)
402
+ while time.time() < hold_until:
403
+ g.poll_events()
404
+ time.sleep(0.1)
405
+ finally:
406
+ kill_hogs(hogs)
407
+ t_kill = time.time()
408
+ hog_s = t_kill - t_hog
409
+ print(f" killed hog after {hog_s:.1f}s; {len(preempt)}/{len(workers)} worker(s) yielded with reason cpu_busy")
410
+
411
+ def resumed(w: str) -> Optional[dict]:
412
+ return next((e for e in g.ev("LEASED", worker_id=w) if e["ts"] > t_kill), None)
413
+
414
+ resume_deadline = time.time() + g.args.resume_timeout
415
+ while time.time() < resume_deadline and not all(resumed(w) for w in preempt):
416
+ g.poll_events()
417
+ if g.job(job_id)["done"]:
418
+ break
419
+ time.sleep(0.1)
420
+ st = g.wait_job(job_id, 900)
421
+
422
+ per_worker = {}
423
+ for w in sorted(workers):
424
+ p = preempt.get(w)
425
+ r = resumed(w)
426
+ det = p["data"].get("detected_at") if p else None
427
+ leaked = [e for e in g.ev("LEASED", worker_id=w) if p and p["ts"] < e["ts"] <= t_kill]
428
+ per_worker[w] = {
429
+ "preempted": p is not None, "event": p["kind"] if p else None,
430
+ "chunk_reclaimed": p.get("chunk_index") if p and p["kind"] == "PREEMPT_RECLAIM" else None,
431
+ "leases_while_hogged": len(leaked),
432
+ "latency_s": {
433
+ "hog_to_worker_detect": _r(det - t_hog) if det else None,
434
+ "hog_to_coordinator_preempt": _r(p["ts"] - t_hog) if p else None,
435
+ "kill_to_resume_lease": _r(r["ts"] - t_kill) if r else None,
436
+ },
437
+ }
438
+
439
+ def worst(key: str) -> Optional[float]:
440
+ vals = [v["latency_s"][key] for v in per_worker.values() if v["latency_s"][key] is not None]
441
+ return max(vals) if vals else None
442
+
443
+ return {
444
+ "job": st, "hog_processes": n_hogs, "hog_seconds": round(hog_s, 3), "guard": g.guard_desc,
445
+ "workers": per_worker, "revived_workers": revived,
446
+ "latency_s": {
447
+ "max_hog_to_worker_detect": worst("hog_to_worker_detect"),
448
+ "max_hog_to_coordinator_preempt": worst("hog_to_coordinator_preempt"),
449
+ "max_kill_to_resume_lease": worst("kill_to_resume_lease"),
450
+ },
451
+ "_t_hog": t_hog,
452
+ }
453
+
454
+
455
+ def unintended_preemptions(g: Grid, before_ts: float) -> list[dict]:
456
+ """Preemptions the harness did not cause: any reason except trigger-file (phase 1, on
457
+ PREEMPT_NODE only) and shutdown, up to `before_ts` (the start of phase 4's hog)."""
458
+ out = []
459
+ for e in g.events:
460
+ if e["kind"] not in PREEMPT_KINDS or e["ts"] >= before_ts:
461
+ continue
462
+ reason = e["data"].get("reason")
463
+ if reason in INJECTED_REASONS and (reason != "trigger_file" or e.get("worker_id") == PREEMPT_NODE):
464
+ continue
465
+ out.append({"worker_id": e.get("worker_id"), "reason": reason, "kind": e["kind"], "ts": e["ts"]})
466
+ return out
467
+
468
+
469
+ def check_auth(g: Grid) -> dict:
470
+ anon = httpx.post(f"{g.url}/v1/grid/mapreduce", json={"job_name": "x", "chunks": ["x"]}, timeout=5).status_code
471
+ worker_as_client = httpx.get(f"{g.url}/v1/grid/status", timeout=5,
472
+ headers={"Authorization": f"Bearer {g.worker_token}"}).status_code
473
+ forged = httpx.post(f"{g.url}/worker/complete", timeout=5, headers={"Authorization": "Bearer guess"},
474
+ json={"worker_id": "x", "task_id": "x", "lease_id": "x", "result": {}}).status_code
475
+ return {"anonymous_submit": anon, "worker_token_on_client_api": worker_as_client, "forged_completion": forged}
476
+
477
+
478
+ def verify_output(g: Grid, job_ids: list[str]) -> dict:
479
+ recs = [json.loads(l) for l in g.output.read_text().splitlines() if l.strip()] if g.output.exists() else []
480
+ keys = [(r["job_id"], r["chunk_index"]) for r in recs]
481
+ expected = {(j, i) for j in job_ids for i in range(N)}
482
+ return {
483
+ "records": len(recs), "expected": len(expected),
484
+ "missing": sorted(expected - set(keys)), "duplicates": len(keys) - len(set(keys)),
485
+ "total_triples": sum(len(r["triples"]) for r in recs),
486
+ "total_entities": sum(len(r["entities"]) for r in recs),
487
+ }
488
+
489
+
490
+ def throughput(g: Grid, job_ids: list[str]) -> dict:
491
+ per: dict[str, list[float]] = {}
492
+ for e in g.events:
493
+ if e["kind"] == "COMPLETED" and e["job_id"] in job_ids:
494
+ per.setdefault(e["worker_id"], []).append(e["data"].get("tokens_per_sec", 0.0))
495
+ return {w: {"chunks": len(v), "avg_tok_s": round(sum(v) / len(v), 1)} for w, v in sorted(per.items())}
496
+
497
+
498
+ # -------------------------------------------------------------------- report
499
+ PHASE_KEYS = {1: "phase1_preemption", 2: "phase2_worker_crash", 3: "phase3_coordinator_crash", 4: "phase4_cpu_spike"}
500
+ PHASE_FAULTS = {
501
+ 1: f"trigger file on {PREEMPT_NODE} mid-stream",
502
+ 2: f"SIGKILL {CRASH_NODE} mid-lease",
503
+ 3: "SIGKILL coordinator mid-job, restart on SQLite",
504
+ 4: "busy-loop on every core (host CPU spike)",
505
+ }
506
+
507
+
508
+ def job_complete(p: dict) -> bool:
509
+ j = p["job"]
510
+ return j["counts"]["COMPLETED"] == j["total"] and j["counts"]["FAILED"] == 0
511
+
512
+
513
+ def build_checks(report: dict) -> dict[str, bool]:
514
+ phases = {n: report[k] for n, k in PHASE_KEYS.items() if k in report}
515
+ out, auth = report["output_verification"], report["auth"]
516
+ checks = {
517
+ "all chunks completed, none dead-lettered": all(job_complete(p) for p in phases.values()),
518
+ "output: no missing / duplicate records": out["records"] == out["expected"] and not out["missing"]
519
+ and out["duplicates"] == 0,
520
+ "auth rejects anonymous / wrong-role / forged calls": all(v == 401 for v in auth.values()),
521
+ "no unintended preemptions before phase 4 (CPU/RAM guards quiet on grid's own load)":
522
+ not report["unintended_preemptions"],
523
+ }
524
+ if 1 in phases:
525
+ checks["no work sent to a node while its user was active"] = \
526
+ phases[1]["leases_issued_to_preempted_node_while_user_active"] == 0
527
+ if 1 in phases and 2 in phases:
528
+ checks["preempted node resumed harvesting"] = phases[2]["previously_preempted_node_resumed_chunks"] > 0
529
+ relay = report.get("relay") or {}
530
+ if relay.get("enabled"):
531
+ checks["relay: interrupted chunks resumed from their checkpoint"] = relay.get("resumes", 0) > 0
532
+ checks["relay: no resumed attempt failed (resumed output identical to uninterrupted)"] = \
533
+ all(p["job"]["total_failures"] <= (1 if n == 2 else 0) for n, p in phases.items())
534
+ if 4 in phases:
535
+ ws = phases[4]["workers"].values()
536
+ checks["phase 4: every worker yielded with reason cpu_busy"] = all(w["preempted"] for w in ws)
537
+ checks["phase 4: no leases to a CPU-preempted worker while the hog ran"] = \
538
+ all(w["leases_while_hogged"] == 0 for w in ws)
539
+ checks["phase 4: every worker resumed leasing after the hog"] = \
540
+ all(w["latency_s"]["kill_to_resume_lease"] is not None for w in ws)
541
+ return checks
542
+
543
+
544
+ def _s(v) -> str:
545
+ return "–" if v is None else f"{v:.2f} s"
546
+
547
+
548
+ def _recovery_summary(n: int, p: dict) -> str:
549
+ lat = p["latency_s"]
550
+ if n == 1:
551
+ return f"reclaim {_s(lat['touch_to_reclaim'])}, re-lease +{_s(lat['reclaim_to_relet'])}"
552
+ if n == 2:
553
+ return f"lease expired {_s(lat['kill_to_lease_expired'])}, chunk done {_s(lat['kill_to_recovered_chunk_completed'])}"
554
+ if n == 3:
555
+ return (f"ready {_s(lat['restart_to_ready'])} after restart, {p['inflight_completed_by_original_holder']}/"
556
+ f"{p['inflight_at_crash']} in-flight kept")
557
+ return (f"all yielded ≤ {_s(lat['max_hog_to_coordinator_preempt'])}, "
558
+ f"resumed ≤ {_s(lat['max_kill_to_resume_lease'])} after hog")
559
+
560
+
561
+ def render_markdown(report: dict) -> str:
562
+ """Render a chaos report as README-ready Markdown. Pure function of the JSON report so the
563
+ table can be regenerated from any saved `chaos_report*.json` without re-running the grid."""
564
+ checks = report.get("checks", {})
565
+ lines = [f"### Nightshift chaos benchmark ({report.get('mode', '?')}, {len(WORKERS)} workers, "
566
+ f"{N} chunks/phase, CPU guard: {report.get('cpu_guard', '?')})", "",
567
+ "| Phase | Fault injected | Chunks | Wall time | Attempts | Preemptions | Recovery | Result |",
568
+ "|---|---|---|---|---|---|---|---|"]
569
+ lat_rows = []
570
+ for n, key in PHASE_KEYS.items():
571
+ p = report.get(key)
572
+ if not p:
573
+ continue
574
+ j = p["job"]
575
+ lines.append(f"| {n} | {PHASE_FAULTS[n]} | {j['counts']['COMPLETED']}/{j['total']} | {j['elapsed_s']:.1f} s | "
576
+ f"{j['total_attempts']} | {j['total_preemptions']} | {_recovery_summary(n, p)} | "
577
+ f"{'PASS' if job_complete(p) else 'FAIL'} |")
578
+ lat_rows += [f"| {n} | `{k}` | {_s(v)} |" for k, v in p["latency_s"].items()]
579
+ lines += ["", "| Phase | Latency | Value |", "|---|---|---|", *lat_rows]
580
+
581
+ p4 = report.get(PHASE_KEYS[4])
582
+ if p4:
583
+ lines += ["", f"Phase 4: {p4['hog_processes']} busy-loop processes for {p4['hog_seconds']:.1f} s.", "",
584
+ "| Worker | Yield event | Hog → worker detect | Hog → coordinator | Leases while hogged | "
585
+ "Hog killed → next lease |", "|---|---|---|---|---|---|"]
586
+ for w, v in p4["workers"].items():
587
+ lat = v["latency_s"]
588
+ lines.append(f"| {w} | {v['event'] or 'none'} | {_s(lat['hog_to_worker_detect'])} | "
589
+ f"{_s(lat['hog_to_coordinator_preempt'])} | {v['leases_while_hogged']} | "
590
+ f"{_s(lat['kill_to_resume_lease'])} |")
591
+
592
+ out = report.get("output_verification", {})
593
+ lines += ["", f"Output: {out.get('records')}/{out.get('expected')} records, {len(out.get('missing', []))} missing, "
594
+ f"{out.get('duplicates')} duplicates, {out.get('total_triples')} triples. "
595
+ f"Unintended preemptions: {len(report.get('unintended_preemptions', []))}.", "",
596
+ "| Check | Result |", "|---|---|"]
597
+ lines += [f"| {name} | {'PASS' if ok else '**FAIL**'} |" for name, ok in checks.items()]
598
+ return "\n".join(lines) + "\n"
599
+
600
+
601
+ def parse_phases(text: str) -> list[int]:
602
+ try:
603
+ phases = sorted({int(x) for x in text.split(",") if x.strip()})
604
+ except ValueError:
605
+ raise argparse.ArgumentTypeError(f"--phases expects e.g. 1,2,3,4 (got {text!r})")
606
+ if not phases or any(p not in ALL_PHASES for p in phases):
607
+ raise argparse.ArgumentTypeError(f"--phases must be a subset of {','.join(map(str, ALL_PHASES))}")
608
+ return phases
609
+
610
+
611
+ def build_parser() -> argparse.ArgumentParser:
612
+ ap = argparse.ArgumentParser(description="Nightshift end-to-end chaos benchmark", allow_abbrev=False)
613
+ ap.add_argument("--mock", action="store_true")
614
+ ap.add_argument("--model", default="qwen3:4b")
615
+ ap.add_argument("--port", type=int, default=8765)
616
+ ap.add_argument("--phases", type=parse_phases, default=list(ALL_PHASES), help="comma list, default 1,2,3,4")
617
+ ap.add_argument("--output", default="chaos_logs/output_graph.jsonl", help="test sink (kept out of the real output_graph.jsonl)")
618
+ ap.add_argument("--preempt-after", type=float, default=1.0, help="seconds after lease before touching trigger")
619
+ ap.add_argument("--outage", type=float, default=3.0, help="seconds the coordinator stays down in phase 3")
620
+ ap.add_argument("--cpu-pct", type=float, default=65.0, help="worker foreign-CPU guard threshold for the whole run")
621
+ ap.add_argument("--cpu-samples", type=int, default=2, help="consecutive ~1s samples to pause/resume (if supported)")
622
+ ap.add_argument("--min-free-ram-gb", type=float, default=None, help="pass to workers (default: worker default)")
623
+ ap.add_argument("--hog-procs", type=int, default=0, help="phase 4 busy loops (default os.cpu_count())")
624
+ ap.add_argument("--hog-max", type=float, default=20.0, help="hard cap on phase 4 hog duration, seconds")
625
+ ap.add_argument("--hog-hold", type=float, default=3.0, help="keep the hog on this long after all workers yield")
626
+ ap.add_argument("--resume-timeout", type=float, default=30.0, help="phase 4: wait this long for workers to resume")
627
+ ap.add_argument("--relay", action="store_true", help="run the grid with Baton token relay enabled")
628
+ ap.add_argument("--report", default="chaos_report.json")
629
+ ap.add_argument("--markdown", action="store_true", help="print a Markdown benchmark table at the end")
630
+ ap.add_argument("--markdown-out", default=None, metavar="FILE", help="also write the Markdown table to FILE")
631
+ return ap
632
+
633
+
634
+ def main(argv: Optional[list[str]] = None) -> int:
635
+ args = build_parser().parse_args(argv)
636
+ g = Grid(args)
637
+ print(f"CPU guard for all phases: {g.guard_desc}")
638
+ runners = {1: phase_preempt, 2: phase_worker_crash, 3: phase_coordinator_crash, 4: phase_cpu_spike}
639
+ results: dict[int, dict] = {}
640
+ try:
641
+ g.start()
642
+ auth = check_auth(g)
643
+ for n in args.phases:
644
+ results[n] = runners[n](g)
645
+ g.poll_events()
646
+ relay = g.status().get("relay")
647
+ hog_start = results[4].pop("_t_hog") if 4 in results else float("inf")
648
+ jobs = [p["job"]["job_id"] for p in results.values()]
649
+ report = {"mode": "mock" if args.mock else f"live:{args.model}", "phases": args.phases,
650
+ "cpu_guard": g.guard_desc, "auth": auth,
651
+ **{PHASE_KEYS[n]: p for n, p in results.items()},
652
+ "unintended_preemptions": unintended_preemptions(g, hog_start),
653
+ "output_verification": verify_output(g, jobs), "throughput": throughput(g, jobs)}
654
+ if relay is not None:
655
+ report["relay"] = relay
656
+ finally:
657
+ g.stop()
658
+ report["checks"] = checks = build_checks(report)
659
+ (OUT / args.report).write_text(json.dumps(report, indent=2))
660
+
661
+ names = {1: "Phase 1 preemption", 2: "Phase 2 worker crash", 3: "Phase 3 coordinator crash", 4: "Phase 4 CPU spike"}
662
+ for n, p in results.items():
663
+ j = p["job"]
664
+ print(f"\n{names[n]}: {j['counts']['COMPLETED']}/{j['total']} completed in {j['elapsed_s']:.1f}s "
665
+ f"(attempts={j['total_attempts']}, preemptions={j['total_preemptions']}, failures={j['total_failures']})")
666
+ for k, v in p["latency_s"].items():
667
+ print(f" {k:<42} {v}s")
668
+ if 3 in results:
669
+ p3 = results[3]
670
+ print(f"\nPhase 3: {p3['inflight_at_crash']} in flight at crash, {p3['inflight_completed_by_original_holder']} "
671
+ f"completed by their original holder after restart; recovered {p3['recovered']}")
672
+ if 4 in results:
673
+ for w, v in results[4]["workers"].items():
674
+ print(f" {w:<20} {v['event'] or 'NOT PREEMPTED':<16} {v['latency_s']} leases_while_hogged={v['leases_while_hogged']}")
675
+ out = report["output_verification"]
676
+ print(f"\nOutput: {out['records']}/{out['expected']} records, missing={len(out['missing'])}, "
677
+ f"duplicates={out['duplicates']}, triples={out['total_triples']}")
678
+ print("Auth:", auth)
679
+ print("Throughput:", report["throughput"])
680
+ if report.get("relay"):
681
+ r = report["relay"]
682
+ print(f"Relay: enabled={r['enabled']} resumes={r['resumes']} relayed_chunks={r['relayed_chunks']} "
683
+ f"tokens generated={r['tokens_generated']} in results={r['tokens_in_results']} "
684
+ f"salvaged={r['tokens_salvaged']} goodput={r['goodput_pct']}%")
685
+ if report["unintended_preemptions"]:
686
+ print("Unintended preemptions:", report["unintended_preemptions"])
687
+ for name, ok in checks.items():
688
+ print(f" [{'PASS' if ok else 'FAIL'}] {name}")
689
+ ok = all(checks.values())
690
+ print("\nRESULT:", "PASS - zero dropped chunks" if ok else "FAIL")
691
+ md = render_markdown(report)
692
+ if args.markdown_out:
693
+ Path(args.markdown_out).write_text(md)
694
+ if args.markdown:
695
+ print("\n" + md)
696
+ return 0 if ok else 1
697
+
698
+
699
+ if __name__ == "__main__":
700
+ sys.exit(main())