Viewing files at commit b76ab8b42dfdCommit detailsView latest

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