Viewing files at commit f08b4a249972Commit detailsView latest

suites/scale/scalesuite/workspace.py 440 lines · 19.2 KB · python
1
"""Untimed fixture preparation: generate, characterize, ingest, serve.
2
3
Everything here happens OUTSIDE the timed path and is recorded as
4
``ingest.*`` diagnostic rows (setup cost is itself scale evidence: e.g. how
5
long Oak needs to ingest a 100k-commit Git history).
6
7
Layout under ``<work>/<fixture_id>/``::
8
9
    fixture/src.git              generated bare repository (the fixture)
10
    fixture/GENERATOR_MANIFEST.json, fixture/SIZER.json
11
    git/work                     git working repository (all refs local)
12
    git/served/bench/fx.git      bare repository behind the smart-HTTP server
13
    git/clientA, git/clientB     sync clients (clone over HTTP)
14
    oak/work                     oak repository ingested with `oak clone <src.git>`
15
    oak/serve-data               `oak serve` data directory
16
    oak/clientA, oak/clientB     sync clients (clone from the loopback server)
17
18
Transport: both subjects sync over loopback HTTP (git: the stdlib smart-HTTP
19
server in oakbench.gitserver, CGI `git http-backend`; oak: `oak serve`).
20
Rows name the transport; neither side uses a local-file shortcut.
21
"""
22
23
from __future__ import annotations
24
25
import json
26
import os
27
import re
28
import shutil
29
import subprocess
30
import sys
31
import time
32
from dataclasses import dataclass, field
33
from pathlib import Path
34
from typing import Any, Dict, List, Optional, Tuple
35
36
from . import generator as gen
37
from . import sizer as sz
38
from .tiers import Tier
39
40
SERVE_REPO = "bench/fx"
41
TRANSPORT_GIT = "loopback_http_git_http_backend"
42
TRANSPORT_OAK = "loopback_http_oak_serve"
43
44
45
def _say(message: str) -> None:
46
    print(message, file=sys.stderr, flush=True)
47
48
49
class PrepError(RuntimeError):
50
    pass
51
52
53
def run(cmd: List[str], cwd: Optional[Path] = None, env: Optional[Dict[str, str]] = None,
54
        check: bool = True, input_bytes: Optional[bytes] = None, timeout: Optional[float] = None
55
        ) -> subprocess.CompletedProcess:
56
    proc = subprocess.run(cmd, cwd=str(cwd) if cwd else None, env=env, capture_output=True,
57
                          input=input_bytes, timeout=timeout)
58
    if check and proc.returncode != 0:
59
        raise PrepError(f"{' '.join(cmd)} (cwd={cwd}) exited {proc.returncode}: "
60
                        f"{proc.stderr.decode(errors='replace')[-2000:]}")
61
    return proc
62
63
64
def dir_bytes(root: Path) -> int:
65
    return gen.dir_bytes(root) if root.exists() else 0
66
67
68
@dataclass
69
class Expected:
70
    """Oracle facts read from the fixture with git (independent of Oak)."""
71
72
    main_file_count: int
73
    sample: Dict[str, str]  # path -> git blob id at main
74
    probe_path: str
75
    probe_bytes: bytes
76
    probe_lines: int
77
    probe_commit_count: int
78
    main_commits: int
79
    branch: str
80
    branch_paths: List[str]
81
    branch_bytes: Dict[str, bytes]
82
    main_bytes_of_branch_paths: Dict[str, bytes]
83
    conflict: Dict[str, Any]
84
    protected: List[str]
85
    all_paths_sample_size: int
86
87
88
@dataclass
89
class Prepared:
90
    tier: Tier
91
    root: Path
92
    fixture_dir: Path
93
    manifest: Dict[str, Any]
94
    sizer: Dict[str, Any]
95
    expected: Expected
96
    git_bin: str
97
    oak_bin: str
98
    env: Dict[str, str]
99
    phases: Dict[str, float] = field(default_factory=dict)
100
    kinds: Tuple[str, ...] = ()
101
    git_url: Optional[str] = None
102
    oak_url: Optional[str] = None
103
    oak_base_branch: Optional[str] = None
104
    oak_sync_branch: Optional[str] = None
105
    oak_branches_created: int = 0
106
    oak_seed: Optional[str] = None
107
    oak_seed_refusal: Optional[str] = None
108
    oak_remote_unavailable: Optional[str] = None
109
    oak_identity: Dict[str, Any] = field(default_factory=dict)
110
    git_server: Any = None
111
    oak_server: Optional[subprocess.Popen] = None
112
    notes: List[str] = field(default_factory=list)
113
    # Mutable harness bookkeeping shared by ops.
114
    state: Dict[str, Any] = field(default_factory=dict)
115
116
    def path(self, *parts: str) -> Path:
117
        return self.root.joinpath(*parts)
118
119
    def fixture_fields(self) -> Dict[str, Any]:
120
        spec = self.tier.spec
121
        return {
122
            "tier": self.tier.tier_id,
123
            "tier_kind": self.tier.kind,
124
            "fixture_id": self.tier.fixture_id,
125
            "fixture_generator": "suites/scale/scalesuite/generator.py",
126
            "fixture_version": self.manifest["generator_version"],
127
            "fixture_seed": spec.seed,
128
            "fixture_manifest_sha256": self.manifest["refs_sha256"],
129
            "fixture_spec_sha256": self.manifest["spec_sha256"],
130
            "fixture_head_commit": self.manifest["head_commit"],
131
            "fixture_pin_status": self.manifest.get("fixture_pin_status"),
132
            "fixture_stats": sz.compact(self.sizer),
133
        }
134
135
    def close(self) -> None:
136
        if self.git_server is not None:
137
            try:
138
                self.git_server.stop()
139
            except Exception:  # noqa: BLE001 - best-effort teardown
140
                pass
141
            self.git_server = None
142
        if self.oak_server is not None:
143
            self.oak_server.terminate()
144
            try:
145
                self.oak_server.wait(timeout=10)
146
            except subprocess.TimeoutExpired:
147
                self.oak_server.kill()
148
                self.oak_server.wait()
149
            self.oak_server = None
150
151
152
def _git(prepared_git: str, repo: Path, *args: str, env: Dict[str, str], check: bool = True) -> str:
153
    return run([prepared_git, "-C", str(repo), *args], env=env, check=check).stdout.decode(errors="replace")
154
155
156
def compute_expected(tier: Tier, fixture: Path, manifest: Dict[str, Any], git_bin: str,
157
                     env: Dict[str, str], sample_size: int = 400) -> Expected:
158
    repo = fixture / "src.git"
159
    listing = run([git_bin, "-C", str(repo), "ls-tree", "-r", "-z", "main"], env=env).stdout
160
    entries: List[Tuple[str, str]] = []
161
    for item in listing.split(b"\0"):
162
        if not item:
163
            continue
164
        meta, path = item.split(b"\t", 1)
165
        entries.append((path.decode(), meta.split()[2].decode()))
166
    import random
167
    rng = random.Random(f"{tier.spec.seed}:oracle-sample")
168
    chosen = rng.sample(entries, min(sample_size, len(entries)))
169
    probe = manifest["probe_path"]
170
    probe_bytes = run([git_bin, "-C", str(repo), "cat-file", "blob", f"main:{probe}"], env=env).stdout
171
    branch = manifest["branch_sample"].get("name") or ""
172
    branch_paths = list(manifest["branch_sample"].get("changed_paths") or [])
173
    branch_bytes = {p: run([git_bin, "-C", str(repo), "cat-file", "blob", f"{branch}:{p}"], env=env).stdout
174
                    for p in branch_paths} if branch else {}
175
    main_bytes = {p: run([git_bin, "-C", str(repo), "cat-file", "blob", f"main:{p}"], env=env).stdout
176
                  for p in branch_paths}
177
    protected = sorted({probe, *branch_paths, *(manifest.get("conflict", {}).get("paths_sample") or []),
178
                        *manifest.get("binary_paths", []), ".gitignore"})
179
    return Expected(
180
        main_file_count=len(entries), sample=dict(chosen), probe_path=probe, probe_bytes=probe_bytes,
181
        probe_lines=probe_bytes.count(b"\n"), probe_commit_count=manifest["probe_path_commit_count"],
182
        main_commits=manifest["main_commits"], branch=branch, branch_paths=branch_paths,
183
        branch_bytes=branch_bytes, main_bytes_of_branch_paths=main_bytes,
184
        conflict=manifest.get("conflict") or {}, protected=protected, all_paths_sample_size=len(chosen))
185
186
187
def write_ignored_extras(work: Path, tier: Tier) -> None:
188
    if not tier.ignored_extras:
189
        return
190
    base = work / "build" / "cache"
191
    for index in range(tier.ignored_extras):
192
        target = base / f"g{index // 500}" / f"x{index}.o"
193
        target.parent.mkdir(parents=True, exist_ok=True)
194
        target.write_bytes(b"ignored build product %d\n" % index)
195
196
197
def _one_line_error(text: str) -> str:
198
    try:
199
        return str(json.loads(text.strip().splitlines()[0])["error"]["message"])[:400]
200
    except (ValueError, KeyError, IndexError, TypeError):
201
        return " ".join(text.split())[:400]
202
203
204
def _snapshot_seed_repo(p: "Prepared", src: Path, dest: Path) -> Path:
205
    """A bare git repo whose main is ONE parentless commit with the fixture's main tree."""
206
    git, env = p.git_bin, dict(p.env)
207
    env.update(GIT_AUTHOR_DATE="1700000000 +0000", GIT_COMMITTER_DATE="1700000000 +0000")
208
    if dest.exists():
209
        shutil.rmtree(dest)
210
    run([git, "init", "-q", "--bare", "-b", "main", str(dest)], env=env)
211
    run([git, "-C", str(dest), "fetch", "-q", str(src), "main:refs/heads/fixture-main"], env=env)
212
    tree = _git(git, dest, "rev-parse", "refs/heads/fixture-main^{tree}", env=env).strip()
213
    commit = run([git, "-C", str(dest), "commit-tree", tree, "-m", "snapshot of fixture main"], env=env
214
                 ).stdout.decode().strip()
215
    _git(git, dest, "update-ref", "refs/heads/main", commit, env=env)
216
    _git(git, dest, "update-ref", "-d", "refs/heads/fixture-main", env=env)
217
    return dest
218
219
220
def _oak_serve(oak_bin: str, data_dir: Path, env: Dict[str, str], log_path: Path) -> Tuple[subprocess.Popen, str]:
221
    data_dir.mkdir(parents=True, exist_ok=True)
222
    log = log_path.open("wb")
223
    proc = subprocess.Popen([oak_bin, "serve", "--port", "0", "--dir", str(data_dir)], stdout=log,
224
                            stderr=subprocess.STDOUT, env=env)
225
    deadline = time.time() + 30
226
    while time.time() < deadline:
227
        text = log_path.read_text(errors="replace")
228
        match = re.search(r"listening on (http://127\.0\.0\.1:\d+)", text)
229
        if match:
230
            return proc, match.group(1)
231
        if proc.poll() is not None:
232
            break
233
        time.sleep(0.1)
234
    proc.terminate()
235
    raise PrepError(f"oak serve did not report a listening port: {log_path.read_text(errors='replace')[-500:]}")
236
237
238
def oak_status(oak_bin: str, cwd: Path, env: Dict[str, str]) -> Dict[str, Any]:
239
    out = run([oak_bin, "status", "--json"], cwd=cwd, env=env).stdout
240
    return json.loads(out)
241
242
243
def prepare(tier: Tier, work_root: Path, *, git_bin: str, oak_bin: str, env: Dict[str, str],
244
            kinds: Tuple[str, ...] = ("git", "oak"), oak_branch_cap: Optional[int] = None,
245
            log=None) -> Prepared:
246
    """Generate (or reuse) the fixture and build every workspace the ops need."""
247
    root = work_root / tier.fixture_id
248
    fixture = root / "fixture"
249
    phases: Dict[str, float] = {}
250
251
    def phase(name: str, started: float) -> None:
252
        phases[name] = round(time.perf_counter() - started, 3)
253
        (log or _say)(f"[scale] {tier.tier_id}: {name} {phases[name]:.1f}s")
254
255
    started = time.perf_counter()
256
    manifest = gen.generate(tier.spec, fixture, git_bin)
257
    manifest["fixture_pin_status"] = gen.check_pin(manifest)  # raises on a pin mismatch
258
    phases["fixture.generate"] = manifest["generation_elapsed_s"] if not manifest.get("reused") else 0.0
259
    phases["fixture.generate_reused"] = 1.0 if manifest.get("reused") else 0.0
260
    sizer_path = fixture / "SIZER.json"
261
    if sizer_path.exists() and json.loads(sizer_path.read_text()).get("refs_sha256") == manifest["refs_sha256"]:
262
        sizer = json.loads(sizer_path.read_text())["stats"]
263
    else:
264
        t0 = time.perf_counter()
265
        sizer = sz.measure(fixture / "src.git", git_bin)
266
        sizer_path.write_text(json.dumps({"refs_sha256": manifest["refs_sha256"], "stats": sizer}, indent=2) + "\n")
267
        phase("fixture.sizer", t0)
268
    expected = compute_expected(tier, fixture, manifest, git_bin, env)
269
    prepared = Prepared(tier=tier, root=root, fixture_dir=fixture, manifest=manifest, sizer=sizer,
270
                        expected=expected, git_bin=git_bin, oak_bin=oak_bin, env=env, phases=phases,
271
                        kinds=kinds)
272
    src = fixture / "src.git"
273
    try:
274
        if "git" in kinds:
275
            _prepare_git(prepared, src, phase)
276
        if "oak" in kinds:
277
            _prepare_oak(prepared, src, phase, oak_branch_cap)
278
    except Exception:
279
        prepared.close()
280
        raise
281
    phases["prepare.total"] = round(time.perf_counter() - started, 3)
282
    return prepared
283
284
285
def _prepare_git(p: Prepared, src: Path, phase) -> None:
286
    git, env = p.git_bin, p.env
287
    gdir = p.path("git")
288
    if gdir.exists():
289
        shutil.rmtree(gdir)
290
    gdir.mkdir(parents=True)
291
    work = gdir / "work"
292
    t0 = time.perf_counter()
293
    # All fixture refs become local branches (like the oak work repo, which
294
    # creates them locally), objects hardlinked from the read-only fixture.
295
    run([git, "clone", "-q", "--bare", str(src), str(work / ".git")], env=env)
296
    for key, value in (("core.bare", "false"), ("gc.auto", "0"), ("maintenance.auto", "false"),
297
                       ("user.name", "Oak Bench"), ("user.email", "[email protected]")):
298
        _git(git, work, "config", key, value, env=env)
299
    _git(git, work, "reset", "-q", "--hard", "main", env=env)
300
    write_ignored_extras(work, p.tier)
301
    phase("ingest.git_worktree", t0)
302
303
    t0 = time.perf_counter()
304
    served = gdir / "served" / (SERVE_REPO + ".git")
305
    served.parent.mkdir(parents=True)
306
    run([git, "clone", "-q", "--bare", str(src), str(served)], env=env)
307
    _git(git, served, "config", "http.receivepack", "true", env=env)
308
    _git(git, served, "config", "gc.auto", "0", env=env)
309
    import sys
310
    scripts = str(Path(__file__).resolve().parents[3] / "scripts")
311
    if scripts not in sys.path:
312
        sys.path.insert(0, scripts)
313
    from oakbench.gitserver import GitHTTPServer
314
    p.git_server = GitHTTPServer(gdir / "served", git_bin=git).start()
315
    p.git_url = f"{p.git_server.url}/{SERVE_REPO}.git"
316
    phase("ingest.git_serve_seed", t0)
317
318
    if not p.tier.sync:
319
        return
320
    t0 = time.perf_counter()
321
    for name in ("clientA", "clientB"):
322
        run([git, "clone", "-q", p.git_url, str(gdir / name)], env=env)
323
        for key, value in (("gc.auto", "0"), ("maintenance.auto", "false"), ("pull.rebase", "false")):
324
            _git(git, gdir / name, "config", key, value, env=env)
325
    phase("ingest.git_sync_clients", t0)
326
327
328
def _commit_file(p: Prepared, kind: str, repo: Path, rel: str, content: bytes) -> None:
329
    target = repo / rel
330
    target.parent.mkdir(parents=True, exist_ok=True)
331
    target.write_bytes(content)
332
    if kind == "git":
333
        _git(p.git_bin, repo, "add", "-A", env=p.env)
334
        _git(p.git_bin, repo, "commit", "-q", "--no-verify", "-m", f"harness {rel}", env=p.env)
335
    else:
336
        run([p.oak_bin, "commit", "--json"], cwd=repo, env=p.env)
337
338
339
def _prepare_oak(p: Prepared, src: Path, phase, branch_cap: Optional[int]) -> None:
340
    oak, env = p.oak_bin, p.env
341
    odir = p.path("oak")
342
    if odir.exists():
343
        shutil.rmtree(odir)
344
    odir.mkdir(parents=True)
345
    work = odir / "work"
346
    t0 = time.perf_counter()
347
    # `oak clone <path>.git` is Oak's own git ingest: it runs `git clone` and
348
    # replays the HEAD history into Oak storage (other refs are not imported).
349
    ingest_env = dict(env)
350
    ingest_env["PATH"] = str(Path(p.git_bin).parent) + os.pathsep + env.get("PATH", "")
351
    run([oak, "clone", str(src), str(work)], env=ingest_env, input_bytes=b"")
352
    shutil.rmtree(work / ".git", ignore_errors=True)
353
    phase("ingest.oak_import", t0)
354
    p.oak_base_branch = oak_status(oak, work, env)["branch"]
355
356
    t0 = time.perf_counter()
357
    spec = p.tier.spec
358
    factory = gen.ContentFactory(spec)
359
    cache = gen.layout(spec)
360
    total = spec.branches if branch_cap is None else min(spec.branches, branch_cap)
361
    created = 0
362
    for number in range(total):
363
        name = gen.branch_name(number)
364
        run([oak, "switch", "-c", name], cwd=work, env=env)
365
        for index, path in gen.branch_changes(spec, number, cache):
366
            (work / path).write_bytes(factory.text(index, name))
367
        run([oak, "commit", "--json"], cwd=work, env=env)
368
        created += 1
369
    if spec.conflict_files:
370
        for side in ("bench-ours", "bench-theirs"):
371
            run([oak, "switch", "-c", side], cwd=work, env=env)
372
            for i in range(spec.conflict_files):
373
                (work / gen.file_path(spec, i, cache)).write_bytes(factory.text(i, side))
374
            run([oak, "commit", "--json"], cwd=work, env=env)
375
    run([oak, "switch", p.oak_base_branch], cwd=work, env=env)
376
    p.oak_branches_created = created
377
    if total < spec.branches:
378
        p.notes.append(f"oak branches capped at {total} of {spec.branches} (--oak-branch-cap)")
379
    write_ignored_extras(work, p.tier)
380
    phase("ingest.oak_branches", t0)
381
382
    if not p.tier.sync:
383
        p.oak_remote_unavailable = "tier_excludes_sync_clients_disk_budget"
384
        return
385
    t0 = time.perf_counter()
386
    p.oak_server, p.oak_url = _oak_serve(oak, odir / "serve-data", env, odir / "serve.log")
387
    # Seed from a SEPARATE import: pushing from the work repo would give it a
388
    # remote, after which Oak refreshes main from the server during local ops
389
    # (network inside timed local ops; and with a snapshot seed, an unrelated
390
    # main replaces the imported one). The git work repo never contacts its
391
    # origin during local ops either.
392
    ingest_env = dict(env)
393
    ingest_env["PATH"] = str(Path(p.git_bin).parent) + os.pathsep + env.get("PATH", "")
394
    full_seed = odir / "seed-full"
395
    run([oak, "clone", str(src), str(full_seed)], env=ingest_env, input_bytes=b"")
396
    shutil.rmtree(full_seed / ".git", ignore_errors=True)
397
    seeded = run([oak, "push", "-r", p.oak_url, "--repo", SERVE_REPO, "--json"], cwd=full_seed, env=env,
398
                 check=False)
399
    shutil.rmtree(full_seed, ignore_errors=True)
400
    if seeded.returncode == 0:
401
        p.oak_seed = "full_history"
402
        phase("ingest.oak_serve_seed", t0)
403
    else:
404
        # Oak's staged push has fixed per-operation caps (oak_core::protocol
405
        # STAGED_*: 1M resolved manifest entries summed over every pushed
406
        # commit, 50k blobs, 100k commits, ...), so a history that exceeds them
407
        # cannot be published at all. Record the refusal, then seed the
408
        # server with a one-commit snapshot of main (like `git clone
409
        # --depth 1` content) so the sync ops still run, labelled as such.
410
        message = (seeded.stdout + seeded.stderr).decode(errors="replace")
411
        p.oak_seed_refusal = _one_line_error(message)
412
        p.notes.append(f"oak serve could not accept the full history: {p.oak_seed_refusal}")
413
        phase("ingest.oak_serve_seed_full_refused", t0)
414
        t0 = time.perf_counter()
415
        snapshot = _snapshot_seed_repo(p, src, odir / "seed-src.git")
416
        seed_work = odir / "seed-work"
417
        run([oak, "clone", str(snapshot), str(seed_work)], env=ingest_env, input_bytes=b"")
418
        shutil.rmtree(seed_work / ".git", ignore_errors=True)
419
        second = run([oak, "push", "-r", p.oak_url, "--repo", SERVE_REPO, "--json"], cwd=seed_work, env=env,
420
                     check=False)
421
        shutil.rmtree(seed_work, ignore_errors=True)
422
        shutil.rmtree(snapshot, ignore_errors=True)
423
        if second.returncode != 0:
424
            p.oak_remote_unavailable = "oak_push_admission_cap: " + _one_line_error(
425
                (second.stdout + second.stderr).decode(errors="replace"))
426
            p.notes.append(f"oak snapshot seed also refused; oak sync ops skipped: {p.oak_remote_unavailable}")
427
            phase("ingest.oak_serve_seed_snapshot_refused", t0)
428
            return
429
        p.oak_seed = "snapshot_1_commit"
430
        phase("ingest.oak_serve_seed_snapshot", t0)
431
432
    t0 = time.perf_counter()
433
    client_a = odir / "clientA"
434
    run([oak, "clone", "-r", p.oak_url, SERVE_REPO, str(client_a)], env=env)
435
    _commit_file(p, "oak", client_a, "sync/seed.txt", b"seed\n")
436
    run([oak, "push", "--json"], cwd=client_a, env=env)
437
    p.oak_sync_branch = oak_status(oak, client_a, env)["branch"]
438
    run([oak, "clone", "-r", p.oak_url, SERVE_REPO, str(odir / "clientB"), "--branch", p.oak_sync_branch], env=env)
439
    phase("ingest.oak_sync_clients", t0)
440