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 +700 -0
- coordinator.py +1316 -0
- dashboard.py +151 -0
- demo_submit.py +70 -0
- discovery.py +460 -0
- graphrag.py +222 -0
- grid_auth.py +190 -0
- grid_store.py +103 -0
- grid_tls.py +218 -0
- idle_detection.py +920 -0
- ingest.py +355 -0
- mcp_server.py +859 -0
- nightshift.py +792 -0
- nightshift_grid-0.1.1.dist-info/METADATA +341 -0
- nightshift_grid-0.1.1.dist-info/RECORD +23 -0
- nightshift_grid-0.1.1.dist-info/WHEEL +5 -0
- nightshift_grid-0.1.1.dist-info/entry_points.txt +7 -0
- nightshift_grid-0.1.1.dist-info/licenses/LICENSE +202 -0
- nightshift_grid-0.1.1.dist-info/top_level.txt +17 -0
- qdrant_sink.py +553 -0
- reduce_graph.py +756 -0
- relay.py +238 -0
- worker_daemon.py +873 -0
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())
|