Viewing files at commit ada5e175e5bfCommit detailsView latest

suites/scale/scalesuite/workspace.py 438 lines · 19.1 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_stats": sz.compact(self.sizer),
132
        }
133
134
    def close(self) -> None:
135
        if self.git_server is not None:
136
            try:
137
                self.git_server.stop()
138
            except Exception:  # noqa: BLE001 - best-effort teardown
139
                pass
140
            self.git_server = None
141
        if self.oak_server is not None:
142
            self.oak_server.terminate()
143
            try:
144
                self.oak_server.wait(timeout=10)
145
            except subprocess.TimeoutExpired:
146
                self.oak_server.kill()
147
                self.oak_server.wait()
148
            self.oak_server = None
149
150
151
def _git(prepared_git: str, repo: Path, *args: str, env: Dict[str, str], check: bool = True) -> str:
152
    return run([prepared_git, "-C", str(repo), *args], env=env, check=check).stdout.decode(errors="replace")
153
154
155
def compute_expected(tier: Tier, fixture: Path, manifest: Dict[str, Any], git_bin: str,
156
                     env: Dict[str, str], sample_size: int = 400) -> Expected:
157
    repo = fixture / "src.git"
158
    listing = run([git_bin, "-C", str(repo), "ls-tree", "-r", "-z", "main"], env=env).stdout
159
    entries: List[Tuple[str, str]] = []
160
    for item in listing.split(b"\0"):
161
        if not item:
162
            continue
163
        meta, path = item.split(b"\t", 1)
164
        entries.append((path.decode(), meta.split()[2].decode()))
165
    import random
166
    rng = random.Random(f"{tier.spec.seed}:oracle-sample")
167
    chosen = rng.sample(entries, min(sample_size, len(entries)))
168
    probe = manifest["probe_path"]
169
    probe_bytes = run([git_bin, "-C", str(repo), "cat-file", "blob", f"main:{probe}"], env=env).stdout
170
    branch = manifest["branch_sample"].get("name") or ""
171
    branch_paths = list(manifest["branch_sample"].get("changed_paths") or [])
172
    branch_bytes = {p: run([git_bin, "-C", str(repo), "cat-file", "blob", f"{branch}:{p}"], env=env).stdout
173
                    for p in branch_paths} if branch else {}
174
    main_bytes = {p: run([git_bin, "-C", str(repo), "cat-file", "blob", f"main:{p}"], env=env).stdout
175
                  for p in branch_paths}
176
    protected = sorted({probe, *branch_paths, *(manifest.get("conflict", {}).get("paths_sample") or []),
177
                        *manifest.get("binary_paths", []), ".gitignore"})
178
    return Expected(
179
        main_file_count=len(entries), sample=dict(chosen), probe_path=probe, probe_bytes=probe_bytes,
180
        probe_lines=probe_bytes.count(b"\n"), probe_commit_count=manifest["probe_path_commit_count"],
181
        main_commits=manifest["main_commits"], branch=branch, branch_paths=branch_paths,
182
        branch_bytes=branch_bytes, main_bytes_of_branch_paths=main_bytes,
183
        conflict=manifest.get("conflict") or {}, protected=protected, all_paths_sample_size=len(chosen))
184
185
186
def write_ignored_extras(work: Path, tier: Tier) -> None:
187
    if not tier.ignored_extras:
188
        return
189
    base = work / "build" / "cache"
190
    for index in range(tier.ignored_extras):
191
        target = base / f"g{index // 500}" / f"x{index}.o"
192
        target.parent.mkdir(parents=True, exist_ok=True)
193
        target.write_bytes(b"ignored build product %d\n" % index)
194
195
196
def _one_line_error(text: str) -> str:
197
    try:
198
        return str(json.loads(text.strip().splitlines()[0])["error"]["message"])[:400]
199
    except (ValueError, KeyError, IndexError, TypeError):
200
        return " ".join(text.split())[:400]
201
202
203
def _snapshot_seed_repo(p: "Prepared", src: Path, dest: Path) -> Path:
204
    """A bare git repo whose main is ONE parentless commit with the fixture's main tree."""
205
    git, env = p.git_bin, dict(p.env)
206
    env.update(GIT_AUTHOR_DATE="1700000000 +0000", GIT_COMMITTER_DATE="1700000000 +0000")
207
    if dest.exists():
208
        shutil.rmtree(dest)
209
    run([git, "init", "-q", "--bare", "-b", "main", str(dest)], env=env)
210
    run([git, "-C", str(dest), "fetch", "-q", str(src), "main:refs/heads/fixture-main"], env=env)
211
    tree = _git(git, dest, "rev-parse", "refs/heads/fixture-main^{tree}", env=env).strip()
212
    commit = run([git, "-C", str(dest), "commit-tree", tree, "-m", "snapshot of fixture main"], env=env
213
                 ).stdout.decode().strip()
214
    _git(git, dest, "update-ref", "refs/heads/main", commit, env=env)
215
    _git(git, dest, "update-ref", "-d", "refs/heads/fixture-main", env=env)
216
    return dest
217
218
219
def _oak_serve(oak_bin: str, data_dir: Path, env: Dict[str, str], log_path: Path) -> Tuple[subprocess.Popen, str]:
220
    data_dir.mkdir(parents=True, exist_ok=True)
221
    log = log_path.open("wb")
222
    proc = subprocess.Popen([oak_bin, "serve", "--port", "0", "--dir", str(data_dir)], stdout=log,
223
                            stderr=subprocess.STDOUT, env=env)
224
    deadline = time.time() + 30
225
    while time.time() < deadline:
226
        text = log_path.read_text(errors="replace")
227
        match = re.search(r"listening on (http://127\.0\.0\.1:\d+)", text)
228
        if match:
229
            return proc, match.group(1)
230
        if proc.poll() is not None:
231
            break
232
        time.sleep(0.1)
233
    proc.terminate()
234
    raise PrepError(f"oak serve did not report a listening port: {log_path.read_text(errors='replace')[-500:]}")
235
236
237
def oak_status(oak_bin: str, cwd: Path, env: Dict[str, str]) -> Dict[str, Any]:
238
    out = run([oak_bin, "status", "--json"], cwd=cwd, env=env).stdout
239
    return json.loads(out)
240
241
242
def prepare(tier: Tier, work_root: Path, *, git_bin: str, oak_bin: str, env: Dict[str, str],
243
            kinds: Tuple[str, ...] = ("git", "oak"), oak_branch_cap: Optional[int] = None,
244
            log=None) -> Prepared:
245
    """Generate (or reuse) the fixture and build every workspace the ops need."""
246
    root = work_root / tier.fixture_id
247
    fixture = root / "fixture"
248
    phases: Dict[str, float] = {}
249
250
    def phase(name: str, started: float) -> None:
251
        phases[name] = round(time.perf_counter() - started, 3)
252
        (log or _say)(f"[scale] {tier.tier_id}: {name} {phases[name]:.1f}s")
253
254
    started = time.perf_counter()
255
    manifest = gen.generate(tier.spec, fixture, git_bin)
256
    phases["fixture.generate"] = manifest["generation_elapsed_s"] if not manifest.get("reused") else 0.0
257
    phases["fixture.generate_reused"] = 1.0 if manifest.get("reused") else 0.0
258
    sizer_path = fixture / "SIZER.json"
259
    if sizer_path.exists() and json.loads(sizer_path.read_text()).get("refs_sha256") == manifest["refs_sha256"]:
260
        sizer = json.loads(sizer_path.read_text())["stats"]
261
    else:
262
        t0 = time.perf_counter()
263
        sizer = sz.measure(fixture / "src.git", git_bin)
264
        sizer_path.write_text(json.dumps({"refs_sha256": manifest["refs_sha256"], "stats": sizer}, indent=2) + "\n")
265
        phase("fixture.sizer", t0)
266
    expected = compute_expected(tier, fixture, manifest, git_bin, env)
267
    prepared = Prepared(tier=tier, root=root, fixture_dir=fixture, manifest=manifest, sizer=sizer,
268
                        expected=expected, git_bin=git_bin, oak_bin=oak_bin, env=env, phases=phases,
269
                        kinds=kinds)
270
    src = fixture / "src.git"
271
    try:
272
        if "git" in kinds:
273
            _prepare_git(prepared, src, phase)
274
        if "oak" in kinds:
275
            _prepare_oak(prepared, src, phase, oak_branch_cap)
276
    except Exception:
277
        prepared.close()
278
        raise
279
    phases["prepare.total"] = round(time.perf_counter() - started, 3)
280
    return prepared
281
282
283
def _prepare_git(p: Prepared, src: Path, phase) -> None:
284
    git, env = p.git_bin, p.env
285
    gdir = p.path("git")
286
    if gdir.exists():
287
        shutil.rmtree(gdir)
288
    gdir.mkdir(parents=True)
289
    work = gdir / "work"
290
    t0 = time.perf_counter()
291
    # All fixture refs become local branches (like the oak work repo, which
292
    # creates them locally), objects hardlinked from the read-only fixture.
293
    run([git, "clone", "-q", "--bare", str(src), str(work / ".git")], env=env)
294
    for key, value in (("core.bare", "false"), ("gc.auto", "0"), ("maintenance.auto", "false"),
295
                       ("user.name", "Oak Bench"), ("user.email", "[email protected]")):
296
        _git(git, work, "config", key, value, env=env)
297
    _git(git, work, "reset", "-q", "--hard", "main", env=env)
298
    write_ignored_extras(work, p.tier)
299
    phase("ingest.git_worktree", t0)
300
301
    t0 = time.perf_counter()
302
    served = gdir / "served" / (SERVE_REPO + ".git")
303
    served.parent.mkdir(parents=True)
304
    run([git, "clone", "-q", "--bare", str(src), str(served)], env=env)
305
    _git(git, served, "config", "http.receivepack", "true", env=env)
306
    _git(git, served, "config", "gc.auto", "0", env=env)
307
    import sys
308
    scripts = str(Path(__file__).resolve().parents[3] / "scripts")
309
    if scripts not in sys.path:
310
        sys.path.insert(0, scripts)
311
    from oakbench.gitserver import GitHTTPServer
312
    p.git_server = GitHTTPServer(gdir / "served", git_bin=git).start()
313
    p.git_url = f"{p.git_server.url}/{SERVE_REPO}.git"
314
    phase("ingest.git_serve_seed", t0)
315
316
    if not p.tier.sync:
317
        return
318
    t0 = time.perf_counter()
319
    for name in ("clientA", "clientB"):
320
        run([git, "clone", "-q", p.git_url, str(gdir / name)], env=env)
321
        for key, value in (("gc.auto", "0"), ("maintenance.auto", "false"), ("pull.rebase", "false")):
322
            _git(git, gdir / name, "config", key, value, env=env)
323
    phase("ingest.git_sync_clients", t0)
324
325
326
def _commit_file(p: Prepared, kind: str, repo: Path, rel: str, content: bytes) -> None:
327
    target = repo / rel
328
    target.parent.mkdir(parents=True, exist_ok=True)
329
    target.write_bytes(content)
330
    if kind == "git":
331
        _git(p.git_bin, repo, "add", "-A", env=p.env)
332
        _git(p.git_bin, repo, "commit", "-q", "--no-verify", "-m", f"harness {rel}", env=p.env)
333
    else:
334
        run([p.oak_bin, "commit", "--json"], cwd=repo, env=p.env)
335
336
337
def _prepare_oak(p: Prepared, src: Path, phase, branch_cap: Optional[int]) -> None:
338
    oak, env = p.oak_bin, p.env
339
    odir = p.path("oak")
340
    if odir.exists():
341
        shutil.rmtree(odir)
342
    odir.mkdir(parents=True)
343
    work = odir / "work"
344
    t0 = time.perf_counter()
345
    # `oak clone <path>.git` is Oak's own git ingest: it runs `git clone` and
346
    # replays the HEAD history into Oak storage (other refs are not imported).
347
    ingest_env = dict(env)
348
    ingest_env["PATH"] = str(Path(p.git_bin).parent) + os.pathsep + env.get("PATH", "")
349
    run([oak, "clone", str(src), str(work)], env=ingest_env, input_bytes=b"")
350
    shutil.rmtree(work / ".git", ignore_errors=True)
351
    phase("ingest.oak_import", t0)
352
    p.oak_base_branch = oak_status(oak, work, env)["branch"]
353
354
    t0 = time.perf_counter()
355
    spec = p.tier.spec
356
    factory = gen.ContentFactory(spec)
357
    cache = gen.layout(spec)
358
    total = spec.branches if branch_cap is None else min(spec.branches, branch_cap)
359
    created = 0
360
    for number in range(total):
361
        name = gen.branch_name(number)
362
        run([oak, "switch", "-c", name], cwd=work, env=env)
363
        for index, path in gen.branch_changes(spec, number, cache):
364
            (work / path).write_bytes(factory.text(index, name))
365
        run([oak, "commit", "--json"], cwd=work, env=env)
366
        created += 1
367
    if spec.conflict_files:
368
        for side in ("bench-ours", "bench-theirs"):
369
            run([oak, "switch", "-c", side], cwd=work, env=env)
370
            for i in range(spec.conflict_files):
371
                (work / gen.file_path(spec, i, cache)).write_bytes(factory.text(i, side))
372
            run([oak, "commit", "--json"], cwd=work, env=env)
373
    run([oak, "switch", p.oak_base_branch], cwd=work, env=env)
374
    p.oak_branches_created = created
375
    if total < spec.branches:
376
        p.notes.append(f"oak branches capped at {total} of {spec.branches} (--oak-branch-cap)")
377
    write_ignored_extras(work, p.tier)
378
    phase("ingest.oak_branches", t0)
379
380
    if not p.tier.sync:
381
        p.oak_remote_unavailable = "tier_excludes_sync_clients_disk_budget"
382
        return
383
    t0 = time.perf_counter()
384
    p.oak_server, p.oak_url = _oak_serve(oak, odir / "serve-data", env, odir / "serve.log")
385
    # Seed from a SEPARATE import: pushing from the work repo would give it a
386
    # remote, after which Oak refreshes main from the server during local ops
387
    # (network inside timed local ops; and with a snapshot seed, an unrelated
388
    # main replaces the imported one). The git work repo never contacts its
389
    # origin during local ops either.
390
    ingest_env = dict(env)
391
    ingest_env["PATH"] = str(Path(p.git_bin).parent) + os.pathsep + env.get("PATH", "")
392
    full_seed = odir / "seed-full"
393
    run([oak, "clone", str(src), str(full_seed)], env=ingest_env, input_bytes=b"")
394
    shutil.rmtree(full_seed / ".git", ignore_errors=True)
395
    seeded = run([oak, "push", "-r", p.oak_url, "--repo", SERVE_REPO, "--json"], cwd=full_seed, env=env,
396
                 check=False)
397
    shutil.rmtree(full_seed, ignore_errors=True)
398
    if seeded.returncode == 0:
399
        p.oak_seed = "full_history"
400
        phase("ingest.oak_serve_seed", t0)
401
    else:
402
        # Oak's staged push has fixed per-operation caps (oak_core::protocol
403
        # STAGED_*: 1M resolved manifest entries summed over every pushed
404
        # commit, 50k blobs, 100k commits, ...), so a history that exceeds them
405
        # cannot be published at all. Record the refusal, then seed the
406
        # server with a one-commit snapshot of main (like `git clone
407
        # --depth 1` content) so the sync ops still run, labelled as such.
408
        message = (seeded.stdout + seeded.stderr).decode(errors="replace")
409
        p.oak_seed_refusal = _one_line_error(message)
410
        p.notes.append(f"oak serve could not accept the full history: {p.oak_seed_refusal}")
411
        phase("ingest.oak_serve_seed_full_refused", t0)
412
        t0 = time.perf_counter()
413
        snapshot = _snapshot_seed_repo(p, src, odir / "seed-src.git")
414
        seed_work = odir / "seed-work"
415
        run([oak, "clone", str(snapshot), str(seed_work)], env=ingest_env, input_bytes=b"")
416
        shutil.rmtree(seed_work / ".git", ignore_errors=True)
417
        second = run([oak, "push", "-r", p.oak_url, "--repo", SERVE_REPO, "--json"], cwd=seed_work, env=env,
418
                     check=False)
419
        shutil.rmtree(seed_work, ignore_errors=True)
420
        shutil.rmtree(snapshot, ignore_errors=True)
421
        if second.returncode != 0:
422
            p.oak_remote_unavailable = "oak_push_admission_cap: " + _one_line_error(
423
                (second.stdout + second.stderr).decode(errors="replace"))
424
            p.notes.append(f"oak snapshot seed also refused; oak sync ops skipped: {p.oak_remote_unavailable}")
425
            phase("ingest.oak_serve_seed_snapshot_refused", t0)
426
            return
427
        p.oak_seed = "snapshot_1_commit"
428
        phase("ingest.oak_serve_seed_snapshot", t0)
429
430
    t0 = time.perf_counter()
431
    client_a = odir / "clientA"
432
    run([oak, "clone", "-r", p.oak_url, SERVE_REPO, str(client_a)], env=env)
433
    _commit_file(p, "oak", client_a, "sync/seed.txt", b"seed\n")
434
    run([oak, "push", "--json"], cwd=client_a, env=env)
435
    p.oak_sync_branch = oak_status(oak, client_a, env)["branch"]
436
    run([oak, "clone", "-r", p.oak_url, SERVE_REPO, str(odir / "clientB"), "--branch", p.oak_sync_branch], env=env)
437
    phase("ingest.oak_sync_clients", t0)
438