Viewing files at commit ada5e175e5bfCommit detailsView latest

suites/scale/scalesuite/generator.py 502 lines · 19.6 KB · python
1
"""Deterministic synthetic repository generator (git fast-import stream).
2
3
One ``GenSpec`` describes a repository shape along the git-sizer dimensions
4
(files, bytes, path depth, history length, refs, big blobs). ``generate()``
5
writes ONE fast-import stream into ONE ``git fast-import`` process, so the XL
6
shapes (1M files, 100k commits, 10k refs, multi-GiB blobs) are generated in
7
minutes, not hours, and without a working tree.
8
9
Determinism contract (tested): identical spec -> identical bytes in the
10
stream -> identical commit ids and ref values on any machine and any Git
11
that speaks fast-import. Everything that could vary is pinned: identity,
12
timestamps (fixed epoch + 60 s per commit), the RNG (``random.Random`` seeded
13
from the spec seed), path layout, content, and ref names. ``refs_sha256``
14
(sha256 over sorted ``<oid> <ref>`` lines) is the fixture identity recorded
15
with every row; ``spec_sha256`` identifies the recipe.
16
17
Content model:
18
- text files: unique per (file, revision) so blobs never dedupe by accident;
19
  a header line plus filler words sliced from a seeded 64 KiB pool;
20
- big binaries ("sparse where possible"): every 1 MiB segment is a 4 KiB
21
  seeded random block followed by zeros. Git packs them small, a chunking
22
  store dedupes the zero runs, and a checkout still writes the full size, so
23
  the working tree pays the real byte count while the fixture store does not.
24
25
Path model: file i lives in leaf directory i // fanout. Leaf directories are
26
addressed by digits over ``depth - 1`` directory levels (base ``fanout`` when
27
that fits, otherwise the smallest base that addresses every leaf). Unused
28
high digits are a zero-padded spine, so ``depth`` 64 yields a long single-child
29
chain above a branching bottom: every tracked file sits at exactly ``depth``
30
path components (git-sizer's "maximum path depth").
31
"""
32
33
from __future__ import annotations
34
35
import hashlib
36
import json
37
import math
38
import os
39
import random
40
import subprocess
41
import time
42
from dataclasses import asdict, dataclass, field
43
from pathlib import Path
44
from typing import Any, BinaryIO, Dict, List, Optional, Tuple
45
46
GENERATOR_VERSION = "scale-gen-2026-09-28.1"
47
IDENTITY = b"Oak Bench <[email protected]>"
48
EPOCH = 1_700_000_000
49
SEGMENT = 1 << 20
50
SEGMENT_RANDOM = 4096
51
POOL_BYTES = 64 * 1024
52
FLUSH_BYTES = 4 << 20
53
MANIFEST_NAME = "GENERATOR_MANIFEST.json"
54
PROBE_EVERY = 7  # the probe path is rewritten by every 7th history commit
55
56
57
@dataclass(frozen=True)
58
class GenSpec:
59
    """A repository recipe. Field names follow git-sizer's dimensions."""
60
61
    name: str
62
    seed: str
63
    files: int = 100
64
    file_bytes: int = 256
65
    depth: int = 3
66
    fanout: int = 32
67
    commits: int = 1
68
    touches_per_commit: int = 4
69
    branches: int = 0
70
    branch_commit_files: int = 2
71
    tags: int = 0
72
    binaries: int = 0
73
    binary_bytes: int = 0
74
    binary_revisions: int = 0
75
    ignore_patterns: Tuple[str, ...] = ()
76
    conflict_files: int = 0
77
    notes: str = ""
78
79
    def validate(self) -> None:
80
        if self.files < 1:
81
            raise ValueError("files must be >= 1")
82
        if self.depth < 1:
83
            raise ValueError("depth must be >= 1 (1 = files at the root)")
84
        if self.fanout < 2:
85
            raise ValueError("fanout must be >= 2")
86
        if self.commits < 1:
87
            raise ValueError("commits must be >= 1")
88
        if self.file_bytes < 32:
89
            raise ValueError("file_bytes must be >= 32 (room for the unique header line)")
90
        if self.binary_revisions and not self.binaries:
91
            raise ValueError("binary_revisions needs binaries >= 1")
92
        if self.binary_revisions > self.commits:
93
            raise ValueError("binary_revisions cannot exceed commits")
94
        if self.conflict_files > self.files:
95
            raise ValueError("conflict_files cannot exceed files")
96
        if self.branch_commit_files > self.files:
97
            raise ValueError("branch_commit_files cannot exceed files")
98
99
    def to_json(self) -> Dict[str, Any]:
100
        data = asdict(self)
101
        data["ignore_patterns"] = list(self.ignore_patterns)
102
        return data
103
104
    def spec_sha256(self) -> str:
105
        payload = json.dumps({"generator_version": GENERATOR_VERSION, "spec": self.to_json()},
106
                             sort_keys=True, separators=(",", ":"))
107
        return hashlib.sha256(payload.encode()).hexdigest()
108
109
    def estimated_bytes(self) -> Dict[str, int]:
110
        """Rough working-tree and store sizes, for disk budgeting before generation."""
111
        text = self.files * self.file_bytes
112
        binary = self.binaries * self.binary_bytes
113
        # APFS/ext4 allocate at least one 4 KiB block per small file.
114
        worktree = self.files * max(4096, self.file_bytes) + binary
115
        store = text + self.commits * self.touches_per_commit * self.file_bytes \
116
            + max(1, self.binary_revisions) * self.binaries * (self.binary_bytes // SEGMENT + 1) * SEGMENT_RANDOM
117
        return {"worktree_bytes": worktree, "store_bytes_estimate": store}
118
119
120
# ---------------------------------------------------------------------------
121
# Layout and content (pure functions: also used by oracles)
122
# ---------------------------------------------------------------------------
123
124
125
def layout(spec: GenSpec) -> Tuple[int, int]:
126
    """(directory levels, digit base) for the spec's path layout."""
127
    dir_levels = spec.depth - 1
128
    if dir_levels == 0:
129
        return 0, spec.fanout
130
    leaves = max(1, math.ceil(spec.files / spec.fanout))
131
    needed = 1 if leaves <= 1 else math.ceil(math.log(leaves, spec.fanout) - 1e-12)
132
    if needed <= dir_levels:
133
        return dir_levels, spec.fanout
134
    base = 2
135
    while base ** dir_levels < leaves:
136
        base += 1
137
    return dir_levels, base
138
139
140
def file_path(spec: GenSpec, index: int, _cache: Optional[Tuple[int, int]] = None) -> str:
141
    dir_levels, base = _cache or layout(spec)
142
    name = f"f{index}.txt"
143
    if dir_levels == 0:
144
        return name
145
    leaf = index // spec.fanout
146
    digits = []
147
    for _ in range(dir_levels):
148
        digits.append(leaf % base)
149
        leaf //= base
150
    parts = [f"d{digit}" for digit in reversed(digits)]
151
    parts.append(name)
152
    return "/".join(parts)
153
154
155
def branch_name(number: int) -> str:
156
    return f"b{number:05d}"
157
158
159
def branch_changes(spec: GenSpec, number: int, _cache: Optional[Tuple[int, int]] = None) -> List[Tuple[int, str]]:
160
    """(file index, path) rewritten by side branch ``number`` (content revision = branch name)."""
161
    cache = _cache or layout(spec)
162
    out: List[Tuple[int, str]] = []
163
    seen = set()
164
    for j in range(spec.branch_commit_files):
165
        index = (number * 7919 + j * 104729 + 1) % spec.files
166
        if index in seen:
167
            continue
168
        seen.add(index)
169
        out.append((index, file_path(spec, index, cache)))
170
    return out
171
172
173
def binary_path(index: int) -> str:
174
    return f"bin/blob{index}.bin"
175
176
177
def _pool(seed: str) -> bytes:
178
    rng = random.Random(f"{seed}:pool")
179
    words = [b"alpha", b"branch", b"commit", b"delta", b"engine", b"field", b"graph", b"hash",
180
             b"index", b"journal", b"kernel", b"ledger", b"merge", b"node", b"oracle", b"patch",
181
             b"query", b"record", b"stream", b"tree", b"update", b"vector", b"worker", b"zone"]
182
    out = bytearray()
183
    column = 0
184
    while len(out) < POOL_BYTES + 8192:
185
        word = words[rng.randrange(len(words))]
186
        out += word
187
        column += len(word) + 1
188
        if column > 60:
189
            out += b"\n"
190
            column = 0
191
        else:
192
            out += b" "
193
    return bytes(out)
194
195
196
class ContentFactory:
197
    """Byte-exact content for any (file, revision); oracles call the same code."""
198
199
    def __init__(self, spec: GenSpec) -> None:
200
        self.spec = spec
201
        self.pool = _pool(spec.seed)
202
203
    def text(self, index: int, revision: str) -> bytes:
204
        header = f"{self.spec.name}:{index}:{revision}\n".encode()
205
        size = max(self.spec.file_bytes, len(header) + 1)
206
        filler = size - len(header)
207
        # Cheap deterministic offset mixing (no per-file hashing).
208
        offset = ((index * 2654435761) ^ (len(revision) * 40503) ^ sum(revision.encode())) % POOL_BYTES
209
        body = self.pool[offset:offset + filler]
210
        if len(body) < filler:
211
            body = (body + self.pool)[:filler]
212
        if body and not body.endswith(b"\n"):
213
            body = body[:-1] + b"\n"
214
        return header + body
215
216
    def binary_segments(self, index: int, revision: int):
217
        """Yield the binary's bytes in <= 1 MiB pieces (never materialized whole)."""
218
        total = self.spec.binary_bytes
219
        segment_no = 0
220
        written = 0
221
        while written < total:
222
            rng = random.Random(f"{self.spec.seed}:bin:{index}:{revision}:{segment_no}")
223
            head = rng.getrandbits(SEGMENT_RANDOM * 8).to_bytes(SEGMENT_RANDOM, "little")
224
            size = min(SEGMENT, total - written)
225
            if size <= SEGMENT_RANDOM:
226
                piece = head[:size]
227
                yield piece
228
            else:
229
                yield head
230
                zeros = size - SEGMENT_RANDOM
231
                while zeros > 0:
232
                    step = min(zeros, 1 << 18)
233
                    yield bytes(step)
234
                    zeros -= step
235
            written += size
236
            segment_no += 1
237
238
239
def binary_sha1_and_sha256(factory: ContentFactory, index: int, revision: int) -> Tuple[str, str]:
240
    """(git blob id, sha256) of a generated binary, streamed."""
241
    total = factory.spec.binary_bytes
242
    git = hashlib.sha1(f"blob {total}\0".encode())
243
    plain = hashlib.sha256()
244
    for piece in factory.binary_segments(index, revision):
245
        git.update(piece)
246
        plain.update(piece)
247
    return git.hexdigest(), plain.hexdigest()
248
249
250
def git_blob_id(data: bytes) -> str:
251
    return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
252
253
254
# ---------------------------------------------------------------------------
255
# Stream writer
256
# ---------------------------------------------------------------------------
257
258
259
class _Stream:
260
    def __init__(self, sink: BinaryIO) -> None:
261
        self.sink = sink
262
        self.buffer: List[bytes] = []
263
        self.size = 0
264
        self.total = 0
265
266
    def write(self, data: bytes) -> None:
267
        self.buffer.append(data)
268
        self.size += len(data)
269
        if self.size >= FLUSH_BYTES:
270
            self.flush()
271
272
    def flush(self) -> None:
273
        if self.buffer:
274
            blob = b"".join(self.buffer)
275
            self.sink.write(blob)
276
            self.total += len(blob)
277
            self.buffer = []
278
            self.size = 0
279
280
    def data(self, payload: bytes) -> None:
281
        self.write(b"data %d\n" % len(payload))
282
        self.write(payload)
283
        self.write(b"\n")
284
285
286
def _commit_header(stream: _Stream, ref: bytes, mark: int, when: int, message: bytes,
287
                   parent: Optional[int]) -> None:
288
    stream.write(b"commit " + ref + b"\n")
289
    stream.write(b"mark :%d\n" % mark)
290
    stamp = b"%d +0000" % when
291
    stream.write(b"author " + IDENTITY + b" " + stamp + b"\n")
292
    stream.write(b"committer " + IDENTITY + b" " + stamp + b"\n")
293
    stream.data(message)
294
    if parent is not None:
295
        stream.write(b"from :%d\n" % parent)
296
297
298
def write_stream(spec: GenSpec, sink: BinaryIO) -> Dict[str, Any]:
299
    """Write the full fast-import stream for ``spec``; return the manifest facts."""
300
    spec.validate()
301
    rng = random.Random(f"{spec.seed}:history")
302
    factory = ContentFactory(spec)
303
    cache = layout(spec)
304
    stream = _Stream(sink)
305
    stream.write(b"feature done\n")
306
    main = b"refs/heads/main"
307
    probe_index = 0
308
    probe_path = file_path(spec, probe_index, cache)
309
    probe_commits = 1  # the initial commit adds it
310
    touched_paths_last: List[str] = []
311
    mark = 0
312
    tick = 0
313
314
    # Commit 1: the full tree.
315
    mark += 1
316
    tick += 1
317
    _commit_header(stream, main, mark, EPOCH + 60 * tick, b"c1 initial tree\n", None)
318
    if spec.ignore_patterns:
319
        stream.write(b"M 100644 inline .gitignore\n")
320
        stream.data(("\n".join(spec.ignore_patterns) + "\n").encode())
321
    for index in range(spec.files):
322
        stream.write(b"M 100644 inline " + file_path(spec, index, cache).encode() + b"\n")
323
        stream.data(factory.text(index, "r1"))
324
    for index in range(spec.binaries):
325
        stream.write(b"M 100644 inline " + binary_path(index).encode() + b"\n")
326
        stream.write(b"data %d\n" % spec.binary_bytes)
327
        for piece in factory.binary_segments(index, 1):
328
            stream.write(piece)
329
        stream.write(b"\n")
330
    commit_marks = [mark]
331
332
    # Commits 2..N: seeded touch sets; every PROBE_EVERY-th also rewrites the probe path.
333
    for number in range(2, spec.commits + 1):
334
        mark += 1
335
        tick += 1
336
        _commit_header(stream, main, mark, EPOCH + 60 * tick, b"c%d\n" % number, mark - 1)
337
        chosen = set()
338
        for _ in range(min(spec.touches_per_commit, spec.files)):
339
            chosen.add(rng.randrange(spec.files))
340
        if number % PROBE_EVERY == 0:
341
            chosen.add(probe_index)
342
        elif probe_index in chosen:
343
            chosen.discard(probe_index)
344
        if probe_index in chosen:
345
            probe_commits += 1
346
        touched_paths_last = []
347
        for index in sorted(chosen):
348
            path = file_path(spec, index, cache)
349
            touched_paths_last.append(path)
350
            stream.write(b"M 100644 inline " + path.encode() + b"\n")
351
            stream.data(factory.text(index, f"r{number}"))
352
        if number <= spec.binary_revisions:
353
            stream.write(b"M 100644 inline " + binary_path(0).encode() + b"\n")
354
            stream.write(b"data %d\n" % spec.binary_bytes)
355
            for piece in factory.binary_segments(0, number):
356
                stream.write(piece)
357
            stream.write(b"\n")
358
        commit_marks.append(mark)
359
    tip = mark
360
361
    # Branches: each forks from the tip with one commit of branch_commit_files rewrites.
362
    branch_sample: Dict[str, Any] = {}
363
    for number in range(spec.branches):
364
        name = branch_name(number)
365
        mark += 1
366
        tick += 1
367
        _commit_header(stream, b"refs/heads/" + name.encode(), mark, EPOCH + 60 * tick,
368
                       b"branch " + name.encode() + b"\n", tip)
369
        changed = []
370
        for index, path in branch_changes(spec, number, cache):
371
            changed.append(path)
372
            stream.write(b"M 100644 inline " + path.encode() + b"\n")
373
            stream.data(factory.text(index, name))
374
        if number == 0:
375
            branch_sample = {"name": name, "changed_paths": sorted(changed)}
376
377
    # Conflict shape: two branches rewrite the same files differently.
378
    conflict: Dict[str, Any] = {}
379
    if spec.conflict_files:
380
        conflict_paths = [file_path(spec, i, cache) for i in range(spec.conflict_files)]
381
        for side in ("bench-ours", "bench-theirs"):
382
            mark += 1
383
            tick += 1
384
            _commit_header(stream, b"refs/heads/" + side.encode(), mark, EPOCH + 60 * tick,
385
                           side.encode() + b"\n", tip)
386
            for i in range(spec.conflict_files):
387
                stream.write(b"M 100644 inline " + conflict_paths[i].encode() + b"\n")
388
                stream.data(factory.text(i, side))
389
        conflict = {"ours": "bench-ours", "theirs": "bench-theirs", "paths": len(conflict_paths),
390
                    "paths_sample": conflict_paths[:20]}
391
392
    # Lightweight tags at seeded history commits.
393
    for number in range(spec.tags):
394
        target = commit_marks[rng.randrange(len(commit_marks))]
395
        stream.write(b"reset refs/tags/t%05d\nfrom :%d\n\n" % (number, target))
396
397
    stream.write(b"done\n")
398
    stream.flush()
399
    return {
400
        "main_commits": spec.commits,
401
        "total_commits": mark,
402
        "probe_path": probe_path,
403
        "probe_path_commit_count": probe_commits,
404
        "last_commit_paths": sorted(touched_paths_last),
405
        "branch_sample": branch_sample,
406
        "conflict": conflict,
407
        "binary_paths": [binary_path(i) for i in range(spec.binaries)],
408
        "stream_bytes": stream.total,
409
    }
410
411
412
# ---------------------------------------------------------------------------
413
# Generation
414
# ---------------------------------------------------------------------------
415
416
417
def git_env() -> Dict[str, str]:
418
    env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
419
    env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, GIT_TERMINAL_PROMPT="0",
420
               LC_ALL="C", TZ="UTC")
421
    return env
422
423
424
def refs_digest(git_bin: str, repo: Path) -> Tuple[str, int]:
425
    out = subprocess.run([git_bin, "-C", str(repo), "for-each-ref", "--format=%(objectname) %(refname)"],
426
                         check=True, capture_output=True, env=git_env()).stdout
427
    lines = sorted(line for line in out.splitlines() if line)
428
    return hashlib.sha256(b"\n".join(lines) + b"\n").hexdigest(), len(lines)
429
430
431
def generate(spec: GenSpec, out_dir: Path, git_bin: str = "git") -> Dict[str, Any]:
432
    """Generate ``out_dir/src.git`` (bare) plus ``GENERATOR_MANIFEST.json``.
433
434
    Idempotent: an existing fixture whose manifest names the same spec_sha256
435
    and still resolves to the recorded refs digest is reused, not rebuilt.
436
    """
437
    spec.validate()
438
    out_dir = Path(out_dir)
439
    repo = out_dir / "src.git"
440
    manifest_path = out_dir / MANIFEST_NAME
441
    if manifest_path.exists() and repo.exists():
442
        existing = json.loads(manifest_path.read_text())
443
        if existing.get("spec_sha256") == spec.spec_sha256():
444
            digest, _ = refs_digest(git_bin, repo)
445
            if digest == existing.get("refs_sha256"):
446
                existing["reused"] = True
447
                return existing
448
    if repo.exists():
449
        import shutil
450
        shutil.rmtree(repo)
451
    out_dir.mkdir(parents=True, exist_ok=True)
452
    env = git_env()
453
    subprocess.run([git_bin, "init", "-q", "--bare", "-b", "main", str(repo)], check=True, env=env)
454
    for key, value in (("gc.auto", "0"), ("core.bigFileThreshold", "512m")):
455
        subprocess.run([git_bin, "-C", str(repo), "config", key, value], check=True, env=env)
456
    started = time.perf_counter()
457
    proc = subprocess.Popen([git_bin, "-C", str(repo), "fast-import", "--quiet", "--done"],
458
                            stdin=subprocess.PIPE, env=env)
459
    assert proc.stdin is not None
460
    try:
461
        facts = write_stream(spec, proc.stdin)
462
    finally:
463
        proc.stdin.close()
464
    returncode = proc.wait()
465
    if returncode != 0:
466
        raise RuntimeError(f"git fast-import failed with exit {returncode}")
467
    elapsed = time.perf_counter() - started
468
    subprocess.run([git_bin, "-C", str(repo), "symbolic-ref", "HEAD", "refs/heads/main"], check=True, env=env)
469
    head = subprocess.run([git_bin, "-C", str(repo), "rev-parse", "refs/heads/main", "refs/heads/main^{tree}"],
470
                          check=True, capture_output=True, text=True, env=env).stdout.split()
471
    digest, ref_count = refs_digest(git_bin, repo)
472
    version = subprocess.run([git_bin, "--version"], capture_output=True, text=True, env=env).stdout.strip()
473
    manifest = {
474
        "schema_version": 1,
475
        "generator": "suites/scale/scalesuite/generator.py",
476
        "generator_version": GENERATOR_VERSION,
477
        "spec": spec.to_json(),
478
        "spec_sha256": spec.spec_sha256(),
479
        "refs_sha256": digest,
480
        "ref_count": ref_count,
481
        "head_commit": head[0],
482
        "head_tree": head[1],
483
        "generation_elapsed_s": round(elapsed, 3),
484
        "generation_git_version": version,
485
        "store_bytes": dir_bytes(repo),
486
        **facts,
487
    }
488
    manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
489
    manifest["reused"] = False
490
    return manifest
491
492
493
def dir_bytes(root: Path) -> int:
494
    """Allocated bytes under root (st_blocks; sparse-aware), no symlink following."""
495
    total = 0
496
    for base, dirs, files in os.walk(root):
497
        for name in files:
498
            try:
499
                total += os.lstat(os.path.join(base, name)).st_blocks * 512
500
            except OSError:
501
                pass
502
    return total