88e86b3faccd
Rebuild benchmarks as a clean single-root reposi
2 months ago
| 1 | #!/usr/bin/env python3
|
| 2 | """netshape lane: git vs oak network operations through the same shaped pipe.
|
| 3 |
|
| 4 | Both subjects are served from local disk through one Linux netns + veth +
|
| 5 | tc/netem pipe (profiles from config/netshapes.json), which is what makes a
|
| 6 | cross-subject network comparison legitimate. Before any campaign rows, a
|
| 7 | self-test row asserts the pipe's achieved RTT is within +/-15% of the
|
| 8 | configured profile β the pipe is an instrument, and it is tested like one.
|
| 9 |
|
| 10 | Skip-row honesty (ADR-0002):
|
| 11 | - On hosts that cannot shape packets (macOS, missing ip/tc, no
|
| 12 | CAP_NET_ADMIN), every requested run emits a returncode-77 skip row with
|
| 13 | reason "netshape_unavailable:<reason>" and the process exits 3. Never an
|
| 14 | error: an unshaped host is a coverage gap, not a crash.
|
| 15 | - The oak subject needs OAK_BENCH_LOCAL_SERVER_CMD (a self-hostable oak
|
| 16 | server is a flagged product dependency). Absent, oak runs emit skip rows
|
| 17 | with reason "oak_local_server_missing". The skip is the pressure: the row
|
| 18 | stream itself records that oak cannot yet be benchmarked on a local shaped
|
| 19 | pipe.
|
| 20 |
|
| 21 | Transport honesty: every row carries remote_transport "network_shaped" plus
|
| 22 | netshape_profile; shaped rows are comparable only same-profile/same-server
|
| 23 | (oakbench.remotes.shaped_comparison_legal).
|
| 24 |
|
| 25 | Bytes on wire: the gitserver/oak server sit behind oakbench.byteproxy's
|
| 26 | CountingProxy bound to the shaped pipe's host address, so measured rows carry
|
| 27 | wire byte counts. Null means unmeasured.
|
| 28 | """
|
| 29 |
|
| 30 | from __future__ import annotations
|
| 31 |
|
| 32 | import argparse
|
| 33 | import json
|
| 34 | import os
|
| 35 | import platform as platform_module
|
| 36 | import shlex
|
| 37 | import shutil
|
| 38 | import socket
|
| 39 | import subprocess
|
| 40 | import sys
|
| 41 | import tempfile
|
| 42 | import time
|
| 43 | from datetime import datetime, timezone
|
| 44 | from pathlib import Path
|
| 45 | from typing import Any, Callable, Optional
|
| 46 |
|
| 47 | from oakbench import netshape as oakbench_netshape
|
| 48 | from oakbench import remotes as oakbench_remotes
|
| 49 | from oakbench.byteproxy import CountingProxy
|
| 50 | from oakbench.environment import ENV_ISOLATION_VERSION, base_env as oakbench_base_env
|
| 51 | from oakbench.execution import run_timed
|
| 52 | from oakbench.gitserver import GitHTTPServer
|
| 53 | from oakbench.rows import RowContractViolation, validate_rows
|
| 54 |
|
| 55 | ROOT = Path(__file__).resolve().parents[1]
|
| 56 |
|
| 57 | EXIT_OK = 0
|
| 58 | EXIT_FAILURES = 1
|
| 59 | EXIT_USAGE = 2
|
| 60 | EXIT_ALL_SKIPPED = 3
|
| 61 | SKIP_RETURNCODE = 77
|
| 62 |
|
| 63 | SCENARIOS = (
|
| 64 | "netshape_clone_cold",
|
| 65 | "netshape_pull_uptodate",
|
| 66 | "netshape_push_small_delta",
|
| 67 | "netshape_fetch_negotiation_refs100k",
|
| 68 | # _cold is part of the identity (ADR-0005): the measured unit is a cold
|
| 69 | # network acquire plus the first file-history query, like
|
| 70 | # netshape_clone_cold encodes its cache state in the name.
|
| 71 | "netshape_log_follow_cold",
|
| 72 | "netshape_mount_cold",
|
| 73 | "netshape_task_loop",
|
| 74 | )
|
| 75 | SUBJECT_KINDS = {"git": "git", "oak": "oak"}
|
| 76 | SERVER_NAME = "netshape-local"
|
| 77 | OAK_LOCAL_SERVER_ENV = "OAK_BENCH_LOCAL_SERVER_CMD"
|
| 78 | OAK_SERVED_REPO = "bench"
|
| 79 | TASK_LOOP_ITERATIONS = 3
|
| 80 |
|
| 81 | EPILOG = f"""\
|
| 82 | oak subject: a self-hostable oak server is a flagged product dependency. Set
|
| 83 | {OAK_LOCAL_SERVER_ENV} to an argv command template ({{host}} {{port}} {{root}}
|
| 84 | placeholders) that serves a repo named '{OAK_SERVED_REPO}' from {{root}} at
|
| 85 | http://{{host}}:{{port}}. When it is absent, oak runs emit skip rows with
|
| 86 | reason "oak_local_server_missing" β the skip is the pressure: the row stream
|
| 87 | records that oak cannot yet be benchmarked on a local shaped pipe.
|
| 88 |
|
| 89 | This lane needs Linux + ip/tc + CAP_NET_ADMIN (run as root). Anywhere else it
|
| 90 | emits skip rows (reason "netshape_unavailable:<reason>") and exits 3.
|
| 91 | """
|
| 92 |
|
| 93 |
|
| 94 | def base_env() -> dict[str, str]:
|
| 95 | return oakbench_base_env(
|
| 96 | author_name="Oak Netshape",
|
| 97 | author_email="[email protected]",
|
| 98 | oak_author="oak-netshape",
|
| 99 | )
|
| 100 |
|
| 101 |
|
| 102 | def local_server_argv(template: str, *, host: str, port: int, root: Path) -> list[str]:
|
| 103 | rendered = template.format(host=host, port=port, root=str(root))
|
| 104 | argv = shlex.split(rendered)
|
| 105 | if not argv:
|
| 106 | raise ValueError(f"{OAK_LOCAL_SERVER_ENV} rendered to an empty command")
|
| 107 | return argv
|
| 108 |
|
| 109 |
|
| 110 | def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
|
| 111 | parser = argparse.ArgumentParser(
|
| 112 | description=__doc__,
|
| 113 | epilog=EPILOG,
|
| 114 | formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 115 | )
|
| 116 | parser.add_argument("--profile", default="lan-1ms", help="netshape profile id from config/netshapes.json")
|
| 117 | parser.add_argument("--scenario", choices=SCENARIOS, default="netshape_clone_cold")
|
| 118 | parser.add_argument("--subjects", default="git,oak", help="comma-separated: git,oak")
|
| 119 | parser.add_argument("--results", type=Path, default=ROOT / "results" / "netshape",
|
| 120 | help="results directory, or an explicit .jsonl file path")
|
| 121 | parser.add_argument("--runs", type=int, default=3)
|
| 122 | parser.add_argument("--netshapes", type=Path, default=oakbench_netshape.DEFAULT_NETSHAPES)
|
| 123 | parser.add_argument("--refs", type=int, default=100_000,
|
| 124 | help="ref count seeded for the fetch-negotiation scenario")
|
| 125 | parser.add_argument("--git-bin", default="git")
|
| 126 | parser.add_argument("--oak-bin", default="oak")
|
| 127 | parser.add_argument("--admitted-output-chars", type=int, default=20_000)
|
| 128 | return parser.parse_args(argv)
|
| 129 |
|
| 130 |
|
| 131 | def metadata(bench_id: str, args: argparse.Namespace) -> dict[str, Any]:
|
| 132 | return {
|
| 133 | "bench_id": bench_id,
|
| 134 | "profile": "netshape",
|
| 135 | "timestamp_utc": bench_id,
|
| 136 | "host": platform_module.node(),
|
| 137 | "platform": platform_module.platform(),
|
| 138 | "machine": platform_module.machine(),
|
| 139 | "python": platform_module.python_version(),
|
| 140 | "env_isolation_version": ENV_ISOLATION_VERSION,
|
| 141 | "netshape_profile": args.profile,
|
| 142 | "remote_transport": oakbench_remotes.TRANSPORT_NETWORK_SHAPED,
|
| 143 | "remote_server": SERVER_NAME,
|
| 144 | }
|
| 145 |
|
| 146 |
|
| 147 | def skip_row(
|
| 148 | meta: dict[str, Any],
|
| 149 | scenario: str,
|
| 150 | subject: str,
|
| 151 | run_index: int,
|
| 152 | reason: str,
|
| 153 | operation: str = "scenario.skip",
|
| 154 | ) -> dict[str, Any]:
|
| 155 | return {
|
| 156 | **meta,
|
| 157 | "scenario": scenario,
|
| 158 | "operation": operation,
|
| 159 | "run": run_index,
|
| 160 | "subject": subject,
|
| 161 | "subject_kind": SUBJECT_KINDS.get(subject, subject),
|
| 162 | "elapsed_ms": 0.0,
|
| 163 | "returncode": SKIP_RETURNCODE,
|
| 164 | "command": [],
|
| 165 | "skipped": True,
|
| 166 | "skip_reason": reason,
|
| 167 | }
|
| 168 |
|
| 169 |
|
| 170 | def write_rows(results_arg: Path, bench_id: str, rows: list[dict[str, Any]]) -> Path:
|
| 171 | """Write JSONL, then validate against the shared row contract.
|
| 172 |
|
| 173 | rows.py owns no 'netshape' lane yet and this slice may not modify it, so
|
| 174 | netshape rows opt into the core key contract (a strict superset of what
|
| 175 | they carry) to keep skip-row coherence machine-checked.
|
| 176 | """
|
| 177 | if results_arg.suffix == ".jsonl":
|
| 178 | path = results_arg
|
| 179 | else:
|
| 180 | path = results_arg / f"netshape-{bench_id}.jsonl"
|
| 181 | path.parent.mkdir(parents=True, exist_ok=True)
|
| 182 | with path.open("w") as fh:
|
| 183 | for row in rows:
|
| 184 | fh.write(json.dumps(row, sort_keys=True) + "\n")
|
| 185 | errors = validate_rows(rows, "core")
|
| 186 | if errors:
|
| 187 | preview = "\n ".join(errors[:20])
|
| 188 | raise RowContractViolation(
|
| 189 | f"Row contract violation (netshape lane). Rows were written to {path} "
|
| 190 | f"but the emitter has a bug:\n {preview}"
|
| 191 | )
|
| 192 | return path
|
| 193 |
|
| 194 |
|
| 195 | # --------------------------------------------------------------------------
|
| 196 | # Linux-with-privileges measurement path. Untestable on this macOS dev host
|
| 197 | # by design (skip-row honesty above); kept simple and obviously correct.
|
| 198 | # --------------------------------------------------------------------------
|
| 199 |
|
| 200 |
|
| 201 | def run_untimed(command: list[str], cwd: Optional[Path] = None, timeout: float = 600.0) -> subprocess.CompletedProcess:
|
| 202 | return subprocess.run(
|
| 203 | command,
|
| 204 | cwd=cwd,
|
| 205 | env=base_env(),
|
| 206 | stdout=subprocess.PIPE,
|
| 207 | stderr=subprocess.PIPE,
|
| 208 | text=True,
|
| 209 | timeout=timeout,
|
| 210 | check=False,
|
| 211 | )
|
| 212 |
|
| 213 |
|
| 214 | def timed_row(
|
| 215 | meta: dict[str, Any],
|
| 216 | args: argparse.Namespace,
|
| 217 | subject: str,
|
| 218 | run_index: int,
|
| 219 | operation: str,
|
| 220 | command: list[str],
|
| 221 | cwd: Path,
|
| 222 | proxy: Optional[CountingProxy],
|
| 223 | extra: Optional[dict[str, Any]] = None,
|
| 224 | ) -> dict[str, Any]:
|
| 225 | if proxy is not None:
|
| 226 | proxy.reset_counts()
|
| 227 | capture = run_timed(command, cwd, base_env(), args.admitted_output_chars)
|
| 228 | row: dict[str, Any] = {
|
| 229 | **meta,
|
| 230 | "scenario": args.scenario,
|
| 231 | "operation": operation,
|
| 232 | "run": run_index,
|
| 233 | "subject": subject,
|
| 234 | "subject_kind": SUBJECT_KINDS.get(subject, subject),
|
| 235 | "elapsed_ms": round(capture.elapsed_ms, 3),
|
| 236 | "returncode": capture.returncode,
|
| 237 | "command": command,
|
| 238 | "stdout_bytes": capture.stdout_bytes,
|
| 239 | "stderr_bytes": capture.stderr_bytes,
|
| 240 | # Null means unmeasured (ADR-0002): byte counts and first-payload
|
| 241 | # timing exist only when the operation went through the counting
|
| 242 | # proxy, and the keys are always present so a missing measurement is
|
| 243 | # an explicit null, never an absent key.
|
| 244 | "wire_bytes_client_to_server": None,
|
| 245 | "wire_bytes_server_to_client": None,
|
| 246 | "wire_bytes_total": None,
|
| 247 | "wire_connections": None,
|
| 248 | "ttfd_ms": None,
|
| 249 | "ttfd_source": None,
|
| 250 | "first_payload_bytes_server_to_client": None,
|
| 251 | }
|
| 252 | if capture.returncode != 0 and capture.stderr_text:
|
| 253 | row["stderr"] = capture.stderr_text[-4000:]
|
| 254 | if proxy is not None:
|
| 255 | stats = proxy.snapshot()
|
| 256 | row.update(
|
| 257 | {
|
| 258 | "wire_bytes_client_to_server": stats.client_to_upstream_bytes,
|
| 259 | "wire_bytes_server_to_client": stats.upstream_to_client_bytes,
|
| 260 | "wire_bytes_total": stats.total_bytes,
|
| 261 | "wire_connections": stats.connections,
|
| 262 | "ttfd_ms": stats.first_upstream_to_client_ms,
|
| 263 | "ttfd_source": (
|
| 264 | "byteproxy_first_upstream_to_client_byte"
|
| 265 | if stats.first_upstream_to_client_ms is not None
|
| 266 | else None
|
| 267 | ),
|
| 268 | "first_payload_bytes_server_to_client": stats.first_payload_bytes_server_to_client,
|
| 269 | }
|
| 270 | )
|
| 271 | if extra:
|
| 272 | row.update(extra)
|
| 273 | return row
|
| 274 |
|
| 275 |
|
| 276 | def selftest_row(meta: dict[str, Any], args: argparse.Namespace, selftest: dict[str, Any]) -> dict[str, Any]:
|
| 277 | return {
|
| 278 | **meta,
|
| 279 | **selftest,
|
| 280 | "scenario": args.scenario,
|
| 281 | "run": 0,
|
| 282 | "subject": "harness",
|
| 283 | "subject_kind": "harness",
|
| 284 | "elapsed_ms": float(selftest["measured_rtt_ms"]),
|
| 285 | "returncode": 0 if selftest["within_tolerance"] else 1,
|
| 286 | "command": ["netshape:selftest", args.profile],
|
| 287 | }
|
| 288 |
|
| 289 |
|
| 290 | def make_served_git_repo(
|
| 291 | git_bin: str,
|
| 292 | workdir: Path,
|
| 293 | served_root: Path,
|
| 294 | refs_count: int,
|
| 295 | history_commits: int = 1,
|
| 296 | ) -> Path:
|
| 297 | """Untimed precondition: a small source repo, served as a bare repo with
|
| 298 | http.receivepack enabled; optionally seeded with many refs."""
|
| 299 | src = workdir / "git-src"
|
| 300 | run_untimed([git_bin, "-c", "init.defaultBranch=main", "init", str(src)])
|
| 301 | (src / "README.md").write_text("# netshape fixture\n")
|
| 302 | (src / "src").mkdir(exist_ok=True)
|
| 303 | (src / "src" / "main.py").write_text("print('netshape')\n")
|
| 304 | run_untimed([git_bin, "add", "-A"], cwd=src)
|
| 305 | run_untimed([git_bin, "commit", "-m", "netshape fixture"], cwd=src)
|
| 306 | for index in range(1, max(1, history_commits)):
|
| 307 | with (src / "src" / "main.py").open("a") as fh:
|
| 308 | fh.write(f"print('netshape history {index}')\n")
|
| 309 | run_untimed([git_bin, "add", "src/main.py"], cwd=src)
|
| 310 | run_untimed([git_bin, "commit", "-m", f"history {index}"], cwd=src)
|
| 311 | bare = served_root / "repo.git"
|
| 312 | run_untimed([git_bin, "clone", "--bare", str(src), str(bare)])
|
| 313 | run_untimed([git_bin, "-C", str(bare), "config", "http.receivepack", "true"])
|
| 314 | if refs_count > 0:
|
| 315 | head = run_untimed([git_bin, "-C", str(bare), "rev-parse", "HEAD"]).stdout.strip()
|
| 316 | lines = "".join(f"create refs/bench/r{i} {head}\n" for i in range(refs_count))
|
| 317 | subprocess.run(
|
| 318 | [git_bin, "-C", str(bare), "update-ref", "--stdin"],
|
| 319 | input=lines,
|
| 320 | env=base_env(),
|
| 321 | text=True,
|
| 322 | stdout=subprocess.DEVNULL,
|
| 323 | stderr=subprocess.DEVNULL,
|
| 324 | check=False,
|
| 325 | )
|
| 326 | return bare
|
| 327 |
|
| 328 |
|
| 329 | def timed_sequence_row(
|
| 330 | meta: dict[str, Any],
|
| 331 | args: argparse.Namespace,
|
| 332 | subject: str,
|
| 333 | run_index: int,
|
| 334 | operation: str,
|
| 335 | commands: list[list[str]],
|
| 336 | cwd: Path,
|
| 337 | proxy: Optional[CountingProxy],
|
| 338 | extra: Optional[dict[str, Any]] = None,
|
| 339 | ) -> dict[str, Any]:
|
| 340 | """Time a small command sequence as one semantic operation.
|
| 341 |
|
| 342 | Used when the measured unit is "cold network acquire until X is available";
|
| 343 | splitting the clone into an untimed precondition would hide the network bytes
|
| 344 | that make the operation meaningful.
|
| 345 | """
|
| 346 | if proxy is not None:
|
| 347 | proxy.reset_counts()
|
| 348 | stdout_bytes = 0
|
| 349 | stderr_bytes = 0
|
| 350 | stderr_tail = ""
|
| 351 | returncode = 0
|
| 352 | start = time.perf_counter()
|
| 353 | for command in commands:
|
| 354 | proc = subprocess.run(
|
| 355 | command,
|
| 356 | cwd=cwd,
|
| 357 | env=base_env(),
|
| 358 | stdout=subprocess.PIPE,
|
| 359 | stderr=subprocess.PIPE,
|
| 360 | timeout=1800.0,
|
| 361 | check=False,
|
| 362 | )
|
| 363 | stdout_bytes += len(proc.stdout or b"")
|
| 364 | stderr = proc.stderr or b""
|
| 365 | stderr_bytes += len(stderr)
|
| 366 | if stderr:
|
| 367 | stderr_tail = (stderr_tail + stderr.decode("utf-8", errors="replace"))[-4000:]
|
| 368 | if proc.returncode != 0:
|
| 369 | returncode = proc.returncode
|
| 370 | break
|
| 371 | elapsed_ms = (time.perf_counter() - start) * 1000
|
| 372 | row: dict[str, Any] = {
|
| 373 | **meta,
|
| 374 | "scenario": args.scenario,
|
| 375 | "operation": operation,
|
| 376 | "run": run_index,
|
| 377 | "subject": subject,
|
| 378 | "subject_kind": SUBJECT_KINDS.get(subject, subject),
|
| 379 | "elapsed_ms": round(elapsed_ms, 3),
|
| 380 | "returncode": returncode,
|
| 381 | "command": commands,
|
| 382 | "stdout_bytes": stdout_bytes,
|
| 383 | "stderr_bytes": stderr_bytes,
|
| 384 | # Null means unmeasured (ADR-0002): the ttfd/first-payload keys are
|
| 385 | # always present; null records that the proxy never observed an
|
| 386 | # upstream-to-client payload byte, never a zero.
|
| 387 | "wire_bytes_client_to_server": None,
|
| 388 | "wire_bytes_server_to_client": None,
|
| 389 | "wire_bytes_total": None,
|
| 390 | "wire_connections": None,
|
| 391 | "ttfd_ms": None,
|
| 392 | "ttfd_source": None,
|
| 393 | "first_payload_bytes_server_to_client": None,
|
| 394 | }
|
| 395 | if stderr_tail:
|
| 396 | row["stderr"] = stderr_tail
|
| 397 | if proxy is not None:
|
| 398 | stats = proxy.snapshot()
|
| 399 | row.update(
|
| 400 | {
|
| 401 | "wire_bytes_client_to_server": stats.client_to_upstream_bytes,
|
| 402 | "wire_bytes_server_to_client": stats.upstream_to_client_bytes,
|
| 403 | "wire_bytes_total": stats.total_bytes,
|
| 404 | "wire_connections": stats.connections,
|
| 405 | "ttfd_ms": stats.first_upstream_to_client_ms,
|
| 406 | "ttfd_source": (
|
| 407 | "byteproxy_first_upstream_to_client_byte"
|
| 408 | if stats.first_upstream_to_client_ms is not None
|
| 409 | else None
|
| 410 | ),
|
| 411 | "first_payload_bytes_server_to_client": stats.first_payload_bytes_server_to_client,
|
| 412 | }
|
| 413 | )
|
| 414 | if extra:
|
| 415 | row.update(extra)
|
| 416 | return row
|
| 417 |
|
| 418 |
|
| 419 | def free_port() -> int:
|
| 420 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
| 421 | sock.bind(("127.0.0.1", 0))
|
| 422 | return int(sock.getsockname()[1])
|
| 423 |
|
| 424 |
|
| 425 | def wait_for_port(host: str, port: int, timeout_s: float = 30.0) -> bool:
|
| 426 | deadline = time.monotonic() + timeout_s
|
| 427 | while time.monotonic() < deadline:
|
| 428 | try:
|
| 429 | with socket.create_connection((host, port), timeout=1.0):
|
| 430 | return True
|
| 431 | except OSError:
|
| 432 | time.sleep(0.2)
|
| 433 | return False
|
| 434 |
|
| 435 |
|
| 436 | def seed_oak_served_repo(
|
| 437 | oak_bin: str,
|
| 438 | server_url: str,
|
| 439 | workdir: Path,
|
| 440 | runner: Callable[..., subprocess.CompletedProcess] = run_untimed,
|
| 441 | history_commits: int = 1,
|
| 442 | ) -> tuple[bool, str]:
|
| 443 | """Untimed precondition: OAK_BENCH_LOCAL_SERVER_CMD serves a freshly
|
| 444 | created EMPTY root, so the served repo must be seeded before any measured
|
| 445 | clone/pull/push has something to talk to (the task_loop lane's
|
| 446 | ensure_git_remote_seeded idiom, oak-shaped: init a tiny workdir, commit
|
| 447 | one file, push it to the server URL).
|
| 448 |
|
| 449 | Returns (ok, reason). On failure the reason names the failed stage β
|
| 450 | "oak_server_seed_failed:<stage>" β and the caller must emit per-run skip
|
| 451 | rows instead of measured rows (ADR-0002: an unseedable server is a
|
| 452 | coverage gap, never fabricated measurements). ``runner`` is injectable so
|
| 453 | the helper is testable on hosts that cannot run the Linux path.
|
| 454 | """
|
| 455 | seed = workdir / "oak-seed-src"
|
| 456 | seed.mkdir(parents=True, exist_ok=True)
|
| 457 | (seed / "README.md").write_text("# netshape oak fixture (server seed)\n")
|
| 458 | (seed / "src").mkdir(exist_ok=True)
|
| 459 | (seed / "src" / "main.py").write_text("print('netshape oak')\n")
|
| 460 | stages = (
|
| 461 | ("init", [oak_bin, "init", "."]),
|
| 462 | ("commit", [oak_bin, "commit", "--no-verify"]),
|
| 463 | ("push", [oak_bin, "push", server_url]),
|
| 464 | )
|
| 465 | for stage, command in stages:
|
| 466 | proc = runner(command, cwd=seed)
|
| 467 | if proc.returncode != 0:
|
| 468 | return False, f"oak_server_seed_failed:{stage}"
|
| 469 | for index in range(1, max(1, history_commits)):
|
| 470 | with (seed / "src" / "main.py").open("a") as fh:
|
| 471 | fh.write(f"print('netshape oak history {index}')\n")
|
| 472 | proc = runner([oak_bin, "commit", "--no-verify"], cwd=seed)
|
| 473 | if proc.returncode != 0:
|
| 474 | return False, "oak_server_seed_failed:history_commit"
|
| 475 | proc = runner([oak_bin, "push", server_url], cwd=seed)
|
| 476 | if proc.returncode != 0:
|
| 477 | return False, "oak_server_seed_failed:history_push"
|
| 478 | return True, ""
|
| 479 |
|
| 480 |
|
| 481 | def precondition_clone_failed_rows(
|
| 482 | meta: dict[str, Any],
|
| 483 | scenario: str,
|
| 484 | subject: str,
|
| 485 | run_index: int,
|
| 486 | command: list[str],
|
| 487 | proc: subprocess.CompletedProcess,
|
| 488 | dest: Path,
|
| 489 | dependent_operations: list[str],
|
| 490 | ) -> Optional[list[dict[str, Any]]]:
|
| 491 | """Guard for dest-dependent steps behind an untimed precondition clone.
|
| 492 |
|
| 493 | Returns None when the clone produced a usable dest. Otherwise: one
|
| 494 | failure row recording the clone, then a skip row per dependent step
|
| 495 | (reason "prior_step_failed:clone") β a failed clone is recorded data and
|
| 496 | the rest of that run is an honest gap, never a crash into a missing dest.
|
| 497 | """
|
| 498 | if proc.returncode == 0 and dest.is_dir():
|
| 499 | return None
|
| 500 | failure: dict[str, Any] = {
|
| 501 | **meta,
|
| 502 | "scenario": scenario,
|
| 503 | "operation": f"{subject}.clone",
|
| 504 | "run": run_index,
|
| 505 | "subject": subject,
|
| 506 | "subject_kind": SUBJECT_KINDS.get(subject, subject),
|
| 507 | # The precondition clone is untimed; 0.0 records the failure, not a
|
| 508 | # latency measurement.
|
| 509 | "elapsed_ms": 0.0,
|
| 510 | "returncode": proc.returncode if proc.returncode != 0 else 1,
|
| 511 | "command": command,
|
| 512 | "note": "untimed precondition clone failed; elapsed_ms is not a measurement",
|
| 513 | }
|
| 514 | stderr_text = (proc.stderr or "").strip() if isinstance(proc.stderr, str) else ""
|
| 515 | if not stderr_text and proc.returncode == 0:
|
| 516 | stderr_text = f"clone exited 0 but dest missing: {dest}"
|
| 517 | if stderr_text:
|
| 518 | failure["stderr"] = stderr_text[-4000:]
|
| 519 | rows = [failure]
|
| 520 | for operation in dependent_operations:
|
| 521 | rows.append(
|
| 522 | skip_row(meta, scenario, subject, run_index, "prior_step_failed:clone", operation=operation)
|
| 523 | )
|
| 524 | return rows
|
| 525 |
|
| 526 |
|
| 527 | def run_git_subject(
|
| 528 | args: argparse.Namespace,
|
| 529 | meta: dict[str, Any],
|
| 530 | rows: list[dict[str, Any]],
|
| 531 | shaper: oakbench_netshape.NetnsShaper,
|
| 532 | workdir: Path,
|
| 533 | ) -> None:
|
| 534 | git_bin = shutil.which(args.git_bin)
|
| 535 | if git_bin is None:
|
| 536 | for run_index in range(args.runs):
|
| 537 | rows.append(skip_row(meta, args.scenario, "git", run_index, f"missing_binary:{args.git_bin}"))
|
| 538 | return
|
| 539 | if args.scenario == "netshape_mount_cold":
|
| 540 | for run_index in range(args.runs):
|
| 541 | rows.append(skip_row(meta, args.scenario, "git", run_index, "mount_scenario_oak_only"))
|
| 542 | return
|
| 543 |
|
| 544 | served_root = workdir / "git-served"
|
| 545 | served_root.mkdir(parents=True, exist_ok=True)
|
| 546 | refs_count = args.refs if args.scenario == "netshape_fetch_negotiation_refs100k" else 0
|
| 547 | history_commits = 8 if args.scenario == "netshape_log_follow_cold" else 1
|
| 548 | make_served_git_repo(git_bin, workdir, served_root, refs_count, history_commits=history_commits)
|
| 549 | server = GitHTTPServer(served_root).start()
|
| 550 | proxy = CountingProxy("127.0.0.1", server.server_port, listen_host=oakbench_netshape.HOST_ADDR).start()
|
| 551 | url = f"http://{oakbench_netshape.HOST_ADDR}:{proxy.address[1]}/repo.git"
|
| 552 | ns = shaper.name
|
| 553 | try:
|
| 554 | for run_index in range(args.runs):
|
| 555 | dest = workdir / f"git-run-{run_index}"
|
| 556 |
|
| 557 | def in_ns(command: list[str]) -> list[str]:
|
| 558 | return oakbench_netshape.netns_exec(ns, command)
|
| 559 |
|
| 560 | def precondition_clone(dependent_operations: list[str], timeout: float = 600.0) -> bool:
|
| 561 | command = in_ns([git_bin, "clone", url, str(dest)])
|
| 562 | proc = run_untimed(command, timeout=timeout)
|
| 563 | failed = precondition_clone_failed_rows(
|
| 564 | meta, args.scenario, "git", run_index, command, proc, dest, dependent_operations,
|
| 565 | )
|
| 566 | if failed is not None:
|
| 567 | rows.extend(failed)
|
| 568 | return False
|
| 569 | return True
|
| 570 |
|
| 571 | if args.scenario == "netshape_clone_cold":
|
| 572 | rows.append(timed_row(meta, args, "git", run_index, "git.clone",
|
| 573 | in_ns([git_bin, "clone", url, str(dest)]), workdir, proxy))
|
| 574 | elif args.scenario == "netshape_pull_uptodate":
|
| 575 | if not precondition_clone(["git.pull"]):
|
| 576 | continue
|
| 577 | rows.append(timed_row(meta, args, "git", run_index, "git.pull",
|
| 578 | in_ns([git_bin, "-C", str(dest), "pull"]), workdir, proxy))
|
| 579 | elif args.scenario == "netshape_push_small_delta":
|
| 580 | if not precondition_clone(["git.push"]):
|
| 581 | continue
|
| 582 | (dest / "delta.txt").write_text(f"small delta run {run_index}\n")
|
| 583 | run_untimed([git_bin, "-C", str(dest), "add", "-A"])
|
| 584 | run_untimed([git_bin, "-C", str(dest), "commit", "-m", f"delta {run_index}"])
|
| 585 | rows.append(timed_row(meta, args, "git", run_index, "git.push",
|
| 586 | in_ns([git_bin, "-C", str(dest), "push", "origin", "HEAD"]), workdir, proxy))
|
| 587 | elif args.scenario == "netshape_fetch_negotiation_refs100k":
|
| 588 | if not precondition_clone(["git.fetch"], timeout=1800.0):
|
| 589 | continue
|
| 590 | rows.append(timed_row(meta, args, "git", run_index, "git.fetch",
|
| 591 | in_ns([git_bin, "-C", str(dest), "fetch", "origin"]), workdir, proxy,
|
| 592 | extra={"refs_seeded": refs_count}))
|
| 593 | elif args.scenario == "netshape_log_follow_cold":
|
| 594 | clone_cmd = in_ns([git_bin, "clone", "--filter=blob:none", url, str(dest)])
|
| 595 | log_cmd = in_ns([git_bin, "-C", str(dest), "log", "--follow", "--", "src/main.py"])
|
| 596 | rows.append(timed_sequence_row(
|
| 597 | meta,
|
| 598 | args,
|
| 599 | "git",
|
| 600 | run_index,
|
| 601 | "git.log.follow",
|
| 602 | [clone_cmd, log_cmd],
|
| 603 | workdir,
|
| 604 | proxy,
|
| 605 | extra={
|
| 606 | "history_path": "src/main.py",
|
| 607 | "history_commits_seeded": history_commits,
|
| 608 | "ttfd_semantics": "cold_network_acquire_then_file_history_query",
|
| 609 | },
|
| 610 | ))
|
| 611 | elif args.scenario == "netshape_task_loop":
|
| 612 | if not precondition_clone(["loop.push"] * TASK_LOOP_ITERATIONS):
|
| 613 | continue
|
| 614 | for iteration in range(TASK_LOOP_ITERATIONS):
|
| 615 | (dest / "loop.txt").write_text(f"iteration {iteration} run {run_index}\n")
|
| 616 | run_untimed([git_bin, "-C", str(dest), "add", "-A"])
|
| 617 | run_untimed([git_bin, "-C", str(dest), "commit", "-m", f"loop {iteration}"])
|
| 618 | rows.append(timed_row(meta, args, "git", run_index, "loop.push",
|
| 619 | in_ns([git_bin, "-C", str(dest), "push", "origin", "HEAD"]),
|
| 620 | workdir, proxy, extra={"iteration": iteration}))
|
| 621 | finally:
|
| 622 | proxy.stop()
|
| 623 | server.stop()
|
| 624 |
|
| 625 |
|
| 626 | def run_oak_subject(
|
| 627 | args: argparse.Namespace,
|
| 628 | meta: dict[str, Any],
|
| 629 | rows: list[dict[str, Any]],
|
| 630 | shaper: oakbench_netshape.NetnsShaper,
|
| 631 | workdir: Path,
|
| 632 | ) -> None:
|
| 633 | server_cmd = os.environ.get(OAK_LOCAL_SERVER_ENV, "").strip()
|
| 634 | if not server_cmd:
|
| 635 | for run_index in range(args.runs):
|
| 636 | rows.append(skip_row(
|
| 637 | meta, args.scenario, "oak", run_index,
|
| 638 | f"oak_local_server_missing: set {OAK_LOCAL_SERVER_ENV} to a self-hostable "
|
| 639 | "oak server command ({host} {port} {root} placeholders)",
|
| 640 | ))
|
| 641 | return
|
| 642 | oak_bin = shutil.which(args.oak_bin)
|
| 643 | if oak_bin is None:
|
| 644 | for run_index in range(args.runs):
|
| 645 | rows.append(skip_row(meta, args.scenario, "oak", run_index, f"missing_binary:{args.oak_bin}"))
|
| 646 | return
|
| 647 |
|
| 648 | served_root = workdir / "oak-served"
|
| 649 | served_root.mkdir(parents=True, exist_ok=True)
|
| 650 | port = free_port()
|
| 651 | try:
|
| 652 | server_argv = local_server_argv(
|
| 653 | server_cmd,
|
| 654 | host="127.0.0.1",
|
| 655 | port=port,
|
| 656 | root=served_root,
|
| 657 | )
|
| 658 | server_proc = subprocess.Popen(
|
| 659 | server_argv,
|
| 660 | env=base_env(),
|
| 661 | stdout=subprocess.DEVNULL,
|
| 662 | stderr=subprocess.DEVNULL,
|
| 663 | )
|
| 664 | except (OSError, ValueError) as exc:
|
| 665 | for run_index in range(args.runs):
|
| 666 | rows.append(skip_row(meta, args.scenario, "oak", run_index, f"oak_local_server_failed_to_start:{exc}"))
|
| 667 | return
|
| 668 | proxy: Optional[CountingProxy] = None
|
| 669 | try:
|
| 670 | if not wait_for_port("127.0.0.1", port):
|
| 671 | for run_index in range(args.runs):
|
| 672 | rows.append(skip_row(meta, args.scenario, "oak", run_index, "oak_local_server_failed_to_start"))
|
| 673 | return
|
| 674 | # Untimed precondition: the server starts over an EMPTY served_root, so
|
| 675 | # the repo the measured runs clone/pull/push must be seeded first
|
| 676 | # (directly against the server, off the shaped pipe β setup is never
|
| 677 | # the measurement). A failed seed is a per-run skip, never a crash.
|
| 678 | history_commits = 8 if args.scenario == "netshape_log_follow_cold" else 1
|
| 679 | seeded, seed_reason = seed_oak_served_repo(
|
| 680 | oak_bin,
|
| 681 | f"http://127.0.0.1:{port}/{OAK_SERVED_REPO}",
|
| 682 | workdir,
|
| 683 | history_commits=history_commits,
|
| 684 | )
|
| 685 | if not seeded:
|
| 686 | for run_index in range(args.runs):
|
| 687 | rows.append(skip_row(meta, args.scenario, "oak", run_index, seed_reason))
|
| 688 | return
|
| 689 | proxy = CountingProxy("127.0.0.1", port, listen_host=oakbench_netshape.HOST_ADDR).start()
|
| 690 | url = f"http://{oakbench_netshape.HOST_ADDR}:{proxy.address[1]}/{OAK_SERVED_REPO}"
|
| 691 | ns = shaper.name
|
| 692 | for run_index in range(args.runs):
|
| 693 | dest = workdir / f"oak-run-{run_index}"
|
| 694 |
|
| 695 | def in_ns(command: list[str]) -> list[str]:
|
| 696 | return oakbench_netshape.netns_exec(ns, command)
|
| 697 |
|
| 698 | def precondition_clone(dependent_operations: list[str]) -> bool:
|
| 699 | command = in_ns([oak_bin, "clone", url, str(dest)])
|
| 700 | proc = run_untimed(command)
|
| 701 | failed = precondition_clone_failed_rows(
|
| 702 | meta, args.scenario, "oak", run_index, command, proc, dest, dependent_operations,
|
| 703 | )
|
| 704 | if failed is not None:
|
| 705 | rows.extend(failed)
|
| 706 | return False
|
| 707 | return True
|
| 708 |
|
| 709 | if args.scenario == "netshape_clone_cold":
|
| 710 | rows.append(timed_row(meta, args, "oak", run_index, "oak.clone",
|
| 711 | in_ns([oak_bin, "clone", url, str(dest)]), workdir, proxy))
|
| 712 | elif args.scenario == "netshape_pull_uptodate":
|
| 713 | if not precondition_clone(["oak.pull"]):
|
| 714 | continue
|
| 715 | rows.append(timed_row(meta, args, "oak", run_index, "oak.pull",
|
| 716 | in_ns([oak_bin, "pull"]), dest, proxy))
|
| 717 | elif args.scenario == "netshape_push_small_delta":
|
| 718 | if not precondition_clone(["oak.push"]):
|
| 719 | continue
|
| 720 | (dest / "delta.txt").write_text(f"small delta run {run_index}\n")
|
| 721 | run_untimed([oak_bin, "commit", "--no-verify"], cwd=dest)
|
| 722 | rows.append(timed_row(meta, args, "oak", run_index, "oak.push",
|
| 723 | in_ns([oak_bin, "push"]), dest, proxy))
|
| 724 | elif args.scenario == "netshape_fetch_negotiation_refs100k":
|
| 725 | if not precondition_clone(["oak.fetch"]):
|
| 726 | continue
|
| 727 | rows.append(timed_row(meta, args, "oak", run_index, "oak.fetch",
|
| 728 | in_ns([oak_bin, "fetch"]), dest, proxy))
|
| 729 | elif args.scenario == "netshape_log_follow_cold":
|
| 730 | clone_cmd = in_ns([oak_bin, "clone", url, str(dest)])
|
| 731 | log_cmd = in_ns([oak_bin, "log", "src/main.py"])
|
| 732 | rows.append(timed_sequence_row(
|
| 733 | meta,
|
| 734 | args,
|
| 735 | "oak",
|
| 736 | run_index,
|
| 737 | "oak.log.follow",
|
| 738 | [clone_cmd, log_cmd],
|
| 739 | workdir,
|
| 740 | proxy,
|
| 741 | extra={
|
| 742 | "history_path": "src/main.py",
|
| 743 | "history_commits_seeded": history_commits,
|
| 744 | "ttfd_semantics": "cold_network_acquire_then_file_history_query",
|
| 745 | },
|
| 746 | ))
|
| 747 | elif args.scenario == "netshape_mount_cold":
|
| 748 | rows.append(timed_row(meta, args, "oak", run_index, "oak.mount",
|
| 749 | in_ns([oak_bin, "mount", url, str(dest)]), workdir, proxy))
|
| 750 | if dest.exists():
|
| 751 | run_untimed([oak_bin, "mount", "end", str(dest)])
|
| 752 | elif args.scenario == "netshape_task_loop":
|
| 753 | if not precondition_clone(["loop.push"] * TASK_LOOP_ITERATIONS):
|
| 754 | continue
|
| 755 | for iteration in range(TASK_LOOP_ITERATIONS):
|
| 756 | (dest / "loop.txt").write_text(f"iteration {iteration} run {run_index}\n")
|
| 757 | run_untimed([oak_bin, "commit", "--no-verify"], cwd=dest)
|
| 758 | rows.append(timed_row(meta, args, "oak", run_index, "loop.push",
|
| 759 | in_ns([oak_bin, "push"]), dest, proxy,
|
| 760 | extra={"iteration": iteration}))
|
| 761 | finally:
|
| 762 | if proxy is not None:
|
| 763 | proxy.stop()
|
| 764 | server_proc.terminate()
|
| 765 | try:
|
| 766 | server_proc.wait(timeout=10.0)
|
| 767 | except subprocess.TimeoutExpired:
|
| 768 | server_proc.kill()
|
| 769 |
|
| 770 |
|
| 771 | def run_shaped_campaign(
|
| 772 | args: argparse.Namespace,
|
| 773 | profile: oakbench_netshape.NetshapeProfile,
|
| 774 | subjects: list[str],
|
| 775 | meta: dict[str, Any],
|
| 776 | rows: list[dict[str, Any]],
|
| 777 | ) -> None:
|
| 778 | workdir = Path(tempfile.mkdtemp(prefix="oak-netshape-"))
|
| 779 | shaper = oakbench_netshape.NetnsShaper()
|
| 780 | shaper.create(profile)
|
| 781 | try:
|
| 782 | selftest = shaper.self_test(profile)
|
| 783 | rows.append(selftest_row(meta, args, selftest))
|
| 784 | if not selftest["within_tolerance"]:
|
| 785 | # The instrument failed its own calibration; nothing measured
|
| 786 | # through it is evidence.
|
| 787 | for subject in subjects:
|
| 788 | for run_index in range(args.runs):
|
| 789 | rows.append(skip_row(
|
| 790 | meta, args.scenario, subject, run_index,
|
| 791 | f"netshape_selftest_out_of_tolerance: configured "
|
| 792 | f"{selftest['configured_rtt_ms']}ms measured {selftest['measured_rtt_ms']}ms",
|
| 793 | ))
|
| 794 | return
|
| 795 | for subject in subjects:
|
| 796 | if subject == "git":
|
| 797 | run_git_subject(args, meta, rows, shaper, workdir)
|
| 798 | else:
|
| 799 | run_oak_subject(args, meta, rows, shaper, workdir)
|
| 800 | finally:
|
| 801 | shaper.teardown()
|
| 802 | shutil.rmtree(workdir, ignore_errors=True)
|
| 803 |
|
| 804 |
|
| 805 | def exit_code_for_rows(rows: list[dict[str, Any]]) -> int:
|
| 806 | """Exit decision over the emitted row stream (pure, unit-testable).
|
| 807 |
|
| 808 | Failures (any returncode other than 0/77, on ANY row including the
|
| 809 | self-test) dominate. The all-skipped verdict is then computed over
|
| 810 | SUBJECT MEASUREMENT rows only: diagnostic rows (operation
|
| 811 | "netshape.selftest", subject_kind "harness") prove the instrument worked,
|
| 812 | not that anything was measured, so a green self-test plus all-skipped
|
| 813 | subject rows β or a self-test with no subject rows at all β is still
|
| 814 | EXIT_ALL_SKIPPED.
|
| 815 | """
|
| 816 | failures = [row for row in rows if row.get("returncode") not in (0, SKIP_RETURNCODE)]
|
| 817 | if failures:
|
| 818 | return EXIT_FAILURES
|
| 819 | subject_rows = [
|
| 820 | row for row in rows
|
| 821 | if row.get("operation") != "netshape.selftest" and row.get("subject_kind") != "harness"
|
| 822 | ]
|
| 823 | if rows and not subject_rows:
|
| 824 | return EXIT_ALL_SKIPPED # nothing was measured, only diagnostics
|
| 825 | if subject_rows and all(row.get("skipped") for row in subject_rows):
|
| 826 | return EXIT_ALL_SKIPPED
|
| 827 | return EXIT_OK
|
| 828 |
|
| 829 |
|
| 830 | def main(argv: Optional[list[str]] = None) -> int:
|
| 831 | args = parse_args(argv)
|
| 832 | try:
|
| 833 | profiles = oakbench_netshape.load_netshapes(args.netshapes)
|
| 834 | except oakbench_netshape.NetshapeConfigError as exc:
|
| 835 | print(f"error: {exc}", file=sys.stderr)
|
| 836 | return EXIT_USAGE
|
| 837 | if args.profile not in profiles:
|
| 838 | known = ", ".join(sorted(profiles))
|
| 839 | print(f"error: unknown netshape profile {args.profile!r}; known: {known}", file=sys.stderr)
|
| 840 | return EXIT_USAGE
|
| 841 | subjects = [item.strip() for item in args.subjects.split(",") if item.strip()]
|
| 842 | unknown_subjects = [item for item in subjects if item not in SUBJECT_KINDS]
|
| 843 | if not subjects or unknown_subjects:
|
| 844 | print(f"error: --subjects must be a non-empty subset of git,oak; got {args.subjects!r}", file=sys.stderr)
|
| 845 | return EXIT_USAGE
|
| 846 | if args.runs < 1:
|
| 847 | print("error: --runs must be >= 1", file=sys.stderr)
|
| 848 | return EXIT_USAGE
|
| 849 |
|
| 850 | bench_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
| 851 | meta = metadata(bench_id, args)
|
| 852 | rows: list[dict[str, Any]] = []
|
| 853 |
|
| 854 | available, reason = oakbench_netshape.netshape_available()
|
| 855 | if not available:
|
| 856 | for subject in subjects:
|
| 857 | for run_index in range(args.runs):
|
| 858 | rows.append(skip_row(meta, args.scenario, subject, run_index, f"netshape_unavailable:{reason}"))
|
| 859 | else:
|
| 860 | run_shaped_campaign(args, profiles[args.profile], subjects, meta, rows)
|
| 861 |
|
| 862 | path = write_rows(args.results, bench_id, rows)
|
| 863 | print(f"[result] {path}")
|
| 864 |
|
| 865 | return exit_code_for_rows(rows)
|
| 866 |
|
| 867 |
|
| 868 | if __name__ == "__main__":
|
| 869 | raise SystemExit(main())
|