Log in
scripts/make_monorepo_fixture.py 271 lines · 10.4 KB · blame Source
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1
#!/usr/bin/env python3
2
"""Generate deterministic monorepo-shaped git fixtures via fast-import.
3
4
Produces synthetic repositories whose scale knobs (tree width, history depth,
5
ref count) are independent, for the xl/monorepo fixture family declared in
6
config/fixtures.json. The same seed always yields byte-identical history:
7
fixed author/committer identity, timestamps stepping from a fixed epoch,
8
seeded RNG for paths/content/touch-sets, deterministic fast-import marks β€”
9
so commit SHAs are reproducible run-to-run and machine-to-machine.
10
11
The whole history is written as a single fast-import stream piped to one
12
`git fast-import` subprocess (no per-file subprocess calls), which keeps
13
generation tractable at 500k-file / 100k-commit scale. The working tree is
14
left unpopulated; check out refs/heads/main if a materialized tree is needed.
15
16
Layout: file i lives at dir{i//1000}/file{i}.txt. The initial commit carries
17
the full tree; each subsequent commit rewrites a seeded subset (~10 files).
18
A seeded fraction of files (--binary-share) holds incompressible bytes so
19
pack/transfer measurements are not dominated by pathological compression.
20
refs/heads/main is the primary branch; refs/heads/branch-{i} and
21
refs/tags/tag-{i} point at seeded-deterministic commits.
22
23
After import, HISTORY_MANIFEST.json (schema_version 1) records the knobs and
24
the resulting HEAD SHA for fixture-identity checks.
25
26
Exit codes: 0 ok, 1 usage/generation error, 3 git unavailable (skip-row
27
honesty: the caller can record a skip instead of a bogus result).
28
29
Usage:
30
  python3 scripts/make_monorepo_fixture.py --out /tmp/xl-tree \\
31
      --seed oakbench-xl-tree-v1 --files 500000 --commits 1000 --refs 100
32
"""
33
34
from __future__ import annotations
35
36
import argparse
37
import hashlib
38
import json
39
import os
40
import random
41
import shutil
42
import subprocess
43
import sys
44
from pathlib import Path
45
46
GENERATOR_VERSION = "monorepo-v1"
47
IDENTITY = b"Oak Bench <[email protected]>"
48
EPOCH = 1_700_000_000  # first commit timestamp; +60s per commit
49
TOUCHES_PER_COMMIT = 10
50
PRIMARY_BRANCH = b"refs/heads/main"
51
MANIFEST_NAME = "HISTORY_MANIFEST.json"
52
53
QUICK_DEFAULTS = {"files": 50, "commits": 20, "refs": 5}
54
FULL_DEFAULTS = {"files": 10_000, "commits": 1_000, "refs": 50}
55
56
WORDS = (
57
    "handler request response buffer stream parse encode decode commit tree "
58
    "branch merge index cache token batch worker queue retry timeout config "
59
    "schema field record column filter reduce visit walk node edge graph "
60
    "mount hydrate snapshot manifest oracle subject lane scenario operation"
61
).split()
62
63
64
def make_rng(seed: str, *scope: object) -> random.Random:
65
    digest = hashlib.sha256(":".join([seed, *map(str, scope)]).encode()).digest()
66
    return random.Random(int.from_bytes(digest[:8], "big"))
67
68
69
def file_path(i: int) -> str:
70
    return f"dir{i // 1000}/file{i}.txt"
71
72
73
def text_blob(seed: str, i: int, version: int, quick: bool) -> bytes:
74
    rng = make_rng(seed, "text", i, version)
75
    # Log-normal-ish sizes centered near ~3.5 KB so 500k files land near 2 GiB.
76
    lines = 2 if quick else max(8, int(rng.lognormvariate(4.0, 0.6)))
77
    out = [f"# {GENERATOR_VERSION} {file_path(i)} v{version}"]
78
    for n in range(lines):
79
        w = [rng.choice(WORDS) for _ in range(rng.randint(4, 9))]
80
        out.append(f"{w[0]}_{rng.randint(0, 999)} = {w[1]}({', '.join(w[2:])})  # {n}")
81
    return ("\n".join(out) + "\n").encode()
82
83
84
def binary_blob(seed: str, i: int, version: int, quick: bool) -> bytes:
85
    rng = make_rng(seed, "binary", i, version)
86
    size = 64 if quick else rng.randint(4_096, 32_768)
87
    return rng.randbytes(size)
88
89
90
def binary_file_set(seed: str, files: int, binary_share: float) -> frozenset[int]:
91
    count = int(round(files * binary_share))
92
    if count <= 0:
93
        return frozenset()
94
    return frozenset(make_rng(seed, "binary-set").sample(range(files), count))
95
96
97
class StreamWriter:
98
    """Emits a deterministic fast-import stream to a binary file object."""
99
100
    def __init__(self, fh, seed: str, quick: bool, binary_files: frozenset[int]):
101
        self.fh = fh
102
        self.seed = seed
103
        self.quick = quick
104
        self.binary_files = binary_files
105
        self.mark = 0
106
        self.commit_marks: list[int] = []
107
108
    def blob_for(self, i: int, version: int) -> int:
109
        if i in self.binary_files:
110
            data = binary_blob(self.seed, i, version, self.quick)
111
        else:
112
            data = text_blob(self.seed, i, version, self.quick)
113
        self.mark += 1
114
        self.fh.write(b"blob\nmark :%d\ndata %d\n" % (self.mark, len(data)))
115
        self.fh.write(data)
116
        self.fh.write(b"\n")
117
        return self.mark
118
119
    def commit(self, index: int, message: str, changes: list[tuple[str, int]]) -> None:
120
        self.mark += 1
121
        ident = IDENTITY + b" %d +0000\n" % (EPOCH + 60 * index)
122
        msg = message.encode()
123
        fh = self.fh
124
        fh.write(b"commit " + PRIMARY_BRANCH + b"\nmark :%d\n" % self.mark)
125
        fh.write(b"author " + ident)
126
        fh.write(b"committer " + ident)
127
        fh.write(b"data %d\n" % len(msg))
128
        fh.write(msg)
129
        fh.write(b"\n")
130
        for path, blob_mark in changes:
131
            fh.write(b"M 100644 :%d %s\n" % (blob_mark, path.encode()))
132
        fh.write(b"\n")
133
        self.commit_marks.append(self.mark)
134
135
    def reset(self, ref: str, commit_mark: int) -> None:
136
        self.fh.write(b"reset %s\nfrom :%d\n\n" % (ref.encode(), commit_mark))
137
138
139
def write_stream(fh, *, seed: str, files: int, commits: int, refs: int,
140
                 tags: int, binary_share: float, quick: bool) -> None:
141
    writer = StreamWriter(fh, seed, quick, binary_file_set(seed, files, binary_share))
142
    fh.write(b"feature done\n")
143
144
    changes = [(file_path(i), writer.blob_for(i, 0)) for i in range(files)]
145
    writer.commit(0, f"initial tree: {files} files", changes)
146
147
    touches = min(TOUCHES_PER_COMMIT, files)
148
    for c in range(1, commits):
149
        subset = sorted(make_rng(seed, "touch", c).sample(range(files), touches))
150
        changes = [(file_path(i), writer.blob_for(i, c)) for i in subset]
151
        writer.commit(c, f"commit {c}: touch {touches} files", changes)
152
153
    marks = writer.commit_marks
154
    for i in range(refs):
155
        target = make_rng(seed, "branch", i).randrange(commits)
156
        writer.reset(f"refs/heads/branch-{i}", marks[target])
157
    for i in range(tags):
158
        target = make_rng(seed, "tag", i).randrange(commits)
159
        writer.reset(f"refs/tags/tag-{i}", marks[target])
160
    fh.write(b"done\n")
161
162
163
def git_env() -> dict[str, str]:
164
    # Isolate from user/system git config so generation is config-independent.
165
    env = dict(os.environ)
166
    env["GIT_CONFIG_GLOBAL"] = os.devnull
167
    env["GIT_CONFIG_NOSYSTEM"] = "1"
168
    return env
169
170
171
def run_git(args: list[str], cwd: Path) -> str:
172
    proc = subprocess.run(["git", *args], cwd=cwd, env=git_env(), check=True,
173
                          capture_output=True, text=True)
174
    return proc.stdout.strip()
175
176
177
def generate(out: Path, *, seed: str, files: int, commits: int, refs: int,
178
             tags: int, binary_share: float, quick: bool) -> dict[str, object]:
179
    run_git(["init", "-q"], out)
180
    importer = subprocess.Popen(
181
        ["git", "fast-import", "--quiet", "--done"],
182
        cwd=out, env=git_env(), stdin=subprocess.PIPE, stderr=subprocess.PIPE,
183
    )
184
    assert importer.stdin is not None
185
    try:
186
        write_stream(importer.stdin, seed=seed, files=files, commits=commits,
187
                     refs=refs, tags=tags, binary_share=binary_share, quick=quick)
188
    except BrokenPipeError:
189
        pass
190
    finally:
191
        try:
192
            importer.stdin.close()
193
        except BrokenPipeError:
194
            pass
195
    stderr = importer.stderr.read()
196
    importer.stderr.close()
197
    importer.wait()
198
    if importer.returncode != 0:
199
        sys.stderr.write(stderr.decode(errors="replace"))
200
        raise RuntimeError(f"git fast-import failed with exit code {importer.returncode}")
201
202
    run_git(["symbolic-ref", "HEAD", PRIMARY_BRANCH.decode()], out)
203
    head_sha = run_git(["rev-parse", "HEAD"], out)
204
    manifest = {
205
        "schema_version": 1,
206
        "seed": seed,
207
        "files": files,
208
        "commits": commits,
209
        "refs": refs,
210
        "tags": tags,
211
        "head_sha": head_sha,
212
        "generator_version": GENERATOR_VERSION,
213
    }
214
    (out / MANIFEST_NAME).write_text(json.dumps(manifest, indent=2) + "\n")
215
    return manifest
216
217
218
def parse_args() -> argparse.Namespace:
219
    ap = argparse.ArgumentParser(
220
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
221
    ap.add_argument("--out", type=Path, required=True, help="target directory (git init + fast-import)")
222
    ap.add_argument("--files", type=int, default=None)
223
    ap.add_argument("--commits", type=int, default=None)
224
    ap.add_argument("--refs", type=int, default=None)
225
    ap.add_argument("--tags", type=int, default=0)
226
    ap.add_argument("--seed", required=True)
227
    ap.add_argument("--binary-share", type=float, default=0.02,
228
                    help="fraction of files with seeded binary content (default 0.02)")
229
    ap.add_argument("--quick", action="store_true", help="tiny sizes for tests")
230
    return ap.parse_args()
231
232
233
def main() -> int:
234
    args = parse_args()
235
    if shutil.which("git") is None:
236
        print("make_monorepo_fixture: git not found on PATH; cannot generate "
237
              "fixture β€” record this run as skipped, not failed", file=sys.stderr)
238
        return 3
239
240
    defaults = QUICK_DEFAULTS if args.quick else FULL_DEFAULTS
241
    files = args.files if args.files is not None else defaults["files"]
242
    commits = args.commits if args.commits is not None else defaults["commits"]
243
    refs = args.refs if args.refs is not None else defaults["refs"]
244
245
    if files < 1 or commits < 1:
246
        print("--files and --commits must be >= 1", file=sys.stderr)
247
        return 1
248
    if refs < 0 or args.tags < 0:
249
        print("--refs and --tags must be >= 0", file=sys.stderr)
250
        return 1
251
    if not 0.0 <= args.binary_share <= 1.0:
252
        print("--binary-share must be within [0, 1]", file=sys.stderr)
253
        return 1
254
255
    out = args.out
256
    out.mkdir(parents=True, exist_ok=True)
257
    if any(out.iterdir()):
258
        print(f"refusing to write into non-empty {out}", file=sys.stderr)
259
        return 1
260
261
    manifest = generate(out, seed=args.seed, files=files, commits=commits,
262
                        refs=refs, tags=args.tags,
263
                        binary_share=args.binary_share, quick=args.quick)
264
    print(f"{GENERATOR_VERSION} seed={args.seed} files={files} commits={commits} "
265
          f"refs={refs} tags={args.tags} -> {out}")
266
    print(f"HEAD {manifest['head_sha']}")
267
    return 0
268
269
270
if __name__ == "__main__":
271
    raise SystemExit(main())