Log in
scripts/mount_vs_clone.py 569 lines · 22.3 KB · python Blame
1
#!/usr/bin/env python3
2
"""Mount-vs-clone: time-to-work and bytes-to-work on a large repo.
3
4
The acquisition-model comparison marketing needs: `oak mount` (lazy
5
hydration) against credible git acquisition strategies on byte-identical
6
mirrors of the same large fixture (scripts/make_large_fixture.py):
7
8
- oak_mount:           oak mount ORG/REPO DEST
9
- git_clone_full:      git clone (full history, full tree)
10
- git_clone_shallow:   git clone --depth 1 (no history, full tree)
11
- git_clone_blobless:  git clone --filter=blob:none (lazy history blobs;
12
                       checkout still hydrates the full working tree)
13
- git_sparse_task:     blobless + --no-checkout + cone sparse-checkout of the
14
                       task path β€” git's strongest lazy mode, paid in extra
15
                       tool calls (recorded; that cost is part of the story)
16
17
Both mirrors are network remotes (oak.space vs github.com), so unlike the
18
core lane's local-file remote rows this is a cross-network comparison: reps
19
are interleaved per rep round, every row records the server, and published
20
medians must carry the different-network caveat. The claim this lane backs is
21
about the acquisition model (lazy vs eager hydration), which at this fixture
22
size dominates network variance β€” verify with the recorded spread before
23
publishing.
24
25
Setup:
26
27
  OAK_BENCH_MIRROR_REPO=oak/bench-large-mirror \\
28
  GIT_BENCH_MIRROR_URL=https://github.com/oakdotspace/bench-large-mirror.git \\
29
  python3 scripts/mount_vs_clone.py --reps 5
30
31
Unconfigured subjects emit skip rows (returncode 77), never silent absence.
32
"""
33
34
from __future__ import annotations
35
36
import argparse
37
import hashlib
38
import platform
39
import random
40
import shlex
41
import shutil
42
import statistics
43
import tempfile
44
from datetime import datetime, timezone
45
from pathlib import Path
46
from typing import Any
47
48
import sys
49
50
sys.path.insert(0, str(Path(__file__).resolve().parent))
51
52
from oakbench import cachectl as oakbench_cachectl
53
from oakbench import remotes as oakbench_remotes
54
from oakbench.diskprobe import bounded_tree_usage
55
from oakbench.environment import ENV_ISOLATION_VERSION, base_env
56
from oakbench.execution import run_timed
57
from oakbench.integrity import parse_manifest
58
from oakbench.reporting import fmt_num
59
from oakbench.results import ResultsStore
60
from oakbench.rows import row_returncode
61
from oakbench.runlock import measurement_lock
62
63
ROOT = Path(__file__).resolve().parents[1]
64
DEFAULT_WORKDIR = Path(tempfile.gettempdir()) / "oak-mount-vs-clone"
65
ADMITTED_OUTPUT_CHARS = 20_000
66
SKIP_RETURNCODE = 77
67
OAK_CACHE_STATE_UNCHECKED = "global_cache_not_purged"
68
UNKNOWN_CACHE_STATE = "unknown"
69
70
VARIANTS = ("oak_mount", "git_clone_full", "git_clone_shallow", "git_clone_blobless", "git_sparse_task")
71
FIRST_READ_TARGET = "README.md"
72
MANIFEST_TARGET = "MANIFEST.sha256"
73
74
75
def parse_args() -> argparse.Namespace:
76
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
77
    parser.add_argument("--variants", default=",".join(VARIANTS), help="Comma-separated acquisition variants.")
78
    parser.add_argument("--reps", type=int, default=5)
79
    parser.add_argument("--task-prefix", default="docs", help="Repo subtree a scoped task reads from.")
80
    parser.add_argument("--task-files", type=int, default=50, help="Files read by the scoped task.")
81
    parser.add_argument("--workdir", type=Path, default=DEFAULT_WORKDIR)
82
    parser.add_argument("--results", type=Path, default=ROOT / "results")
83
    parser.add_argument("--oak-bin", default="oak")
84
    parser.add_argument("--git-bin", default="git")
85
    parser.add_argument("--keep-workdirs", action="store_true")
86
    return parser.parse_args()
87
88
89
def acquire_commands(
90
    variant: str, oak_bin: str, git_bin: str, oak_repo: str, git_url: str, dest: Path, task_prefix: str
91
) -> list[list[str]]:
92
    if variant == "oak_mount":
93
        return [[oak_bin, "mount", oak_repo, str(dest)]]
94
    if variant == "git_clone_full":
95
        return [[git_bin, "clone", "-q", git_url, str(dest)]]
96
    if variant == "git_clone_shallow":
97
        return [[git_bin, "clone", "-q", "--depth", "1", git_url, str(dest)]]
98
    if variant == "git_clone_blobless":
99
        return [[git_bin, "clone", "-q", "--filter=blob:none", git_url, str(dest)]]
100
    if variant == "git_sparse_task":
101
        return [
102
            [git_bin, "clone", "-q", "--filter=blob:none", "--no-checkout", git_url, str(dest)],
103
            [git_bin, "-C", str(dest), "sparse-checkout", "set", "--cone", task_prefix],
104
            [git_bin, "-C", str(dest), "checkout", "-q"],
105
        ]
106
    raise SystemExit(f"unknown variant {variant!r}")
107
108
109
def scoped_task_files(dest: Path, prefix: str, limit: int) -> list[str]:
110
    """Deterministic task file list: sorted walk of the prefix, names only.
111
112
    Directory listing does not hydrate file content on a lazy mount, so
113
    building this list does not pre-pay the bytes the task step measures.
114
    """
115
    base = dest / prefix
116
    if not base.is_dir():
117
        return []
118
    found = sorted(
119
        str(p.relative_to(dest)) for p in base.rglob("*") if p.is_file()
120
    )
121
    return found[:limit]
122
123
124
def read_manifest(dest: Path) -> tuple[dict[str, str], str | None]:
125
    manifest = dest / MANIFEST_TARGET
126
    try:
127
        return parse_manifest(manifest.read_text()), None
128
    except OSError as exc:
129
        return {}, str(exc)
130
131
132
def _safe_relative_path(rel: str) -> Path | None:
133
    candidate = Path(rel)
134
    if candidate.is_absolute() or ".." in candidate.parts:
135
        return None
136
    return candidate
137
138
139
def content_verification_fields(dest: Path, paths: list[str], manifest: dict[str, str]) -> dict[str, Any]:
140
    failures: list[dict[str, str]] = []
141
    verified = 0
142
    verified_bytes = 0
143
    actual_hashes: dict[str, str] = {}
144
    expected_hashes: dict[str, str] = {}
145
    missing_expected: list[str] = []
146
147
    for rel in paths:
148
        expected = manifest.get(rel)
149
        if not expected:
150
            missing_expected.append(rel)
151
            continue
152
        safe_rel = _safe_relative_path(rel)
153
        if safe_rel is None:
154
            failures.append({"path": rel, "reason": "unsafe_relative_path"})
155
            continue
156
        path = dest / safe_rel
157
        try:
158
            data = path.read_bytes()
159
        except OSError as exc:
160
            failures.append({"path": rel, "expected_sha256": expected, "reason": str(exc)})
161
            continue
162
        actual = hashlib.sha256(data).hexdigest()
163
        actual_hashes[rel] = actual
164
        expected_hashes[rel] = expected
165
        verified_bytes += len(data)
166
        if actual == expected:
167
            verified += 1
168
        else:
169
            failures.append({"path": rel, "expected_sha256": expected, "actual_sha256": actual})
170
171
    ok = bool(paths) and verified == len(paths) and not failures and not missing_expected
172
    status = "passed" if ok else "failed"
173
    if missing_expected:
174
        status = "missing_manifest_entries"
175
    return {
176
        "content_check_method": "manifest_sha256",
177
        "content_check_status": status,
178
        "content_verified": ok,
179
        "content_expected_files": len(paths),
180
        "content_verified_files": verified,
181
        "content_verified_bytes": verified_bytes,
182
        "content_expected_sha256": expected_hashes,
183
        "content_actual_sha256": actual_hashes,
184
        "content_missing_manifest_entries": missing_expected,
185
        "content_mismatches": failures,
186
        "checks": {"ok": ok, "content_verified": ok},
187
    }
188
189
190
def skipped_content_verification_fields(paths: list[str], reason: str) -> dict[str, Any]:
191
    return {
192
        "content_check_method": "manifest_sha256",
193
        "content_check_status": "skipped",
194
        "content_verified": False,
195
        "content_expected_files": len(paths),
196
        "content_verified_files": 0,
197
        "content_verified_bytes": 0,
198
        "content_skip_reason": reason,
199
        "checks": {"ok": False, "content_verified": False, "skip_reason": reason},
200
    }
201
202
203
def apply_content_verification(row: dict[str, Any], fields: dict[str, Any]) -> None:
204
    row.update(fields)
205
    if row.get("returncode") == 0 and not fields.get("content_verified"):
206
        row["returncode"] = 1
207
208
209
def disk_row_fields(dest: Path, variant: str) -> dict[str, Any]:
210
    total = bounded_tree_usage(dest)
211
    fields: dict[str, Any] = {
212
        "visible_tree_bytes": total["visible_tree_bytes"],
213
        "allocated_tree_bytes": total["allocated_tree_bytes"],
214
        "disk_entry_count": total["disk_entry_count"],
215
        "disk_usage_source": "stat_st_blocks_bounded_walk",
216
    }
217
    git_dir = dest / ".git"
218
    if git_dir.is_dir():
219
        store = bounded_tree_usage(git_dir)
220
        fields["vcs_store_allocated_bytes"] = store["allocated_tree_bytes"]
221
        fields["worktree_allocated_bytes"] = max(
222
            0, total["allocated_tree_bytes"] - store["allocated_tree_bytes"]
223
        )
224
    else:
225
        # Oak mount: per-dest store and any global chunk cache are not under
226
        # dest/.git; unmeasured here means null, never zero (ADR-0002).
227
        fields["vcs_store_allocated_bytes"] = None
228
        fields["worktree_allocated_bytes"] = total["allocated_tree_bytes"]
229
        fields["oak_global_cache_bytes"] = None
230
        fields["oak_global_cache_state"] = OAK_CACHE_STATE_UNCHECKED
231
    return fields
232
233
234
def cache_state_fields(variant: str, purge_result: dict[str, Any] | None = None) -> dict[str, Any]:
235
    fields = {
236
        "requested_cache_state": "cold",
237
        **oakbench_cachectl.cache_fields("cold", purge_result),
238
    }
239
    if variant == "oak_mount":
240
        # OS page cache purging is not enough to prove a cold Oak mount:
241
        # Oak's global chunk cache is outside this harness' control.
242
        if fields["cache_state"] == "cold":
243
            fields["cache_state"] = UNKNOWN_CACHE_STATE
244
            fields["cache_state_reason"] = (
245
                f"{fields['cache_state_reason']}; {OAK_CACHE_STATE_UNCHECKED}"
246
            )
247
        fields["oak_global_cache_state"] = OAK_CACHE_STATE_UNCHECKED
248
    return fields
249
250
251
def base_row(metadata: dict[str, Any], variant: str, kind: str, scenario: str, op: str, rep: int) -> dict[str, Any]:
252
    return {
253
        **metadata,
254
        "subject": variant,
255
        "subject_kind": kind,
256
        "scenario": scenario,
257
        "operation": op,
258
        "run": rep,
259
    }
260
261
262
def command_row(
263
    metadata: dict[str, Any],
264
    variant: str,
265
    kind: str,
266
    scenario: str,
267
    op: str,
268
    rep: int,
269
    command: list[str],
270
    cwd: Path,
271
    since_start_ms: float,
272
    expected_returncodes: tuple[int, ...] = (0,),
273
) -> dict[str, Any]:
274
    capture = run_timed(command, cwd, base_env(), ADMITTED_OUTPUT_CHARS)
275
    row = {
276
        **base_row(metadata, variant, kind, scenario, op, rep),
277
        "elapsed_ms": round(capture.elapsed_ms, 3),
278
        "since_scenario_start_ms": round(since_start_ms + capture.elapsed_ms, 3),
279
        "returncode": capture.returncode,
280
        "command": command,
281
        "stdout_bytes": capture.stdout_bytes,
282
        "stderr_bytes": capture.stderr_bytes,
283
        "peak_rss_bytes": capture.peak_rss_bytes,
284
        "tool_call_count": 1,
285
    }
286
    if capture.returncode not in expected_returncodes:
287
        row["stderr"] = capture.stderr_text[-4000:]
288
    return row
289
290
291
def skip_row(metadata: dict[str, Any], variant: str, kind: str, scenario: str, rep: int, reason: str) -> dict[str, Any]:
292
    return {
293
        **base_row(metadata, variant, kind, scenario, "acquire.cmd", rep),
294
        "elapsed_ms": 0.0,
295
        "returncode": SKIP_RETURNCODE,
296
        "command": [],
297
        "skipped": True,
298
        "skip_reason": reason,
299
        "tool_call_count": 0,
300
    }
301
302
303
def teardown(variant: str, oak_bin: str, dest: Path) -> None:
304
    if variant == "oak_mount":
305
        run_timed([oak_bin, "mount", "end", str(dest), "-f"], dest.parent, base_env(), 2_000)
306
    shutil.rmtree(dest, ignore_errors=True)
307
308
309
def run_variant(
310
    args: argparse.Namespace,
311
    metadata: dict[str, Any],
312
    variant: str,
313
    rep: int,
314
    oak_remote: Any,
315
    git_remote: Any,
316
    run_root: Path,
317
) -> list[dict[str, Any]]:
318
    kind = "oak" if variant == "oak_mount" else "git"
319
    scenario = "acquire_cold"
320
    remote = oak_remote if kind == "oak" else git_remote
321
    if not remote.resolved:
322
        return [skip_row(metadata, variant, kind, scenario, rep, remote.skip_reason or "remote unresolved")]
323
324
    row_remote_fields = remote.row_fields()
325
    purge_result = oakbench_cachectl.purge_fs_caches()
326
    row_cache_fields = cache_state_fields(variant, purge_result)
327
    dest = run_root / f"{variant}-rep{rep}"
328
    dest.parent.mkdir(parents=True, exist_ok=True)
329
    shutil.rmtree(dest, ignore_errors=True)
330
331
    rows: list[dict[str, Any]] = []
332
    since = 0.0
333
    commands = acquire_commands(
334
        variant, args.oak_bin, args.git_bin, oak_remote.repo or "", git_remote.repo or "", dest, args.task_prefix
335
    )
336
    acquisition_failed = False
337
    for index, command in enumerate(commands):
338
        op = "acquire.cmd" if len(commands) == 1 else f"acquire.cmd_{index}"
339
        row = command_row(metadata, variant, kind, scenario, op, rep, command, dest.parent, since)
340
        row.update(row_remote_fields)
341
        row.update(row_cache_fields)
342
        rows.append(row)
343
        since = row["since_scenario_start_ms"]
344
        if row["returncode"] != 0:
345
            acquisition_failed = True
346
            break
347
348
    if not acquisition_failed:
349
        ready_row = command_row(
350
            metadata, variant, kind, scenario, "acquire.list_root", rep, ["/bin/ls", str(dest)], dest.parent, since
351
        )
352
        rows.append(ready_row)
353
        since = ready_row["since_scenario_start_ms"]
354
        time_to_task_ready_ms = since
355
356
        first_read = command_row(
357
            metadata, variant, kind, scenario, "acquire.first_read", rep,
358
            ["/bin/cat", FIRST_READ_TARGET], dest, since,
359
        )
360
        rows.append(first_read)
361
        since = first_read["since_scenario_start_ms"]
362
        time_to_first_read_ms = since
363
364
        manifest_read = command_row(
365
            metadata, variant, kind, scenario, "acquire.manifest_read", rep,
366
            ["/bin/cat", MANIFEST_TARGET], dest, since,
367
        )
368
        rows.append(manifest_read)
369
        since = manifest_read["since_scenario_start_ms"]
370
        manifest, manifest_error = read_manifest(dest)
371
        if manifest_error:
372
            manifest_read["content_check_method"] = "manifest_sha256"
373
            manifest_read["content_check_status"] = "manifest_unreadable"
374
            manifest_read["content_verified"] = False
375
            manifest_read["checks"] = {"ok": False, "error": manifest_error}
376
            if manifest_read["returncode"] == 0:
377
                manifest_read["returncode"] = 1
378
        else:
379
            manifest_read["content_check_method"] = "manifest_sha256"
380
            manifest_read["content_check_status"] = "manifest_loaded"
381
            manifest_read["content_manifest_entries"] = len(manifest)
382
            manifest_read["content_verified"] = True
383
            manifest_read["checks"] = {"ok": True, "manifest_entries": len(manifest)}
384
385
        if manifest:
386
            apply_content_verification(
387
                first_read,
388
                content_verification_fields(dest, [FIRST_READ_TARGET], manifest),
389
            )
390
        else:
391
            apply_content_verification(
392
                first_read,
393
                skipped_content_verification_fields(
394
                    [FIRST_READ_TARGET], manifest_error or "manifest_empty_or_unparseable"
395
                ),
396
            )
397
398
        post_acquire = {
399
            **base_row(metadata, variant, kind, scenario, "disk.usage.post_acquire", rep),
400
            "elapsed_ms": 0.0,
401
            "returncode": 0,
402
            "command": [],
403
            "tool_call_count": 0,
404
            **disk_row_fields(dest, variant),
405
        }
406
        rows.append(post_acquire)
407
408
        task_files = scoped_task_files(dest, args.task_prefix, args.task_files)
409
        if task_files:
410
            quoted = " ".join(shlex.quote(f) for f in task_files)
411
            task_row = command_row(
412
                metadata, variant, kind, scenario, "task.scoped_reads", rep,
413
                ["/bin/zsh", "-c", f"cat {quoted} > /dev/null"], dest, since,
414
            )
415
            task_row["task_prefix"] = args.task_prefix
416
            task_row["task_files_read"] = len(task_files)
417
            if manifest:
418
                apply_content_verification(
419
                    task_row,
420
                    content_verification_fields(dest, task_files, manifest),
421
                )
422
            else:
423
                apply_content_verification(
424
                    task_row,
425
                    skipped_content_verification_fields(
426
                        task_files, manifest_error or "manifest_empty_or_unparseable"
427
                    ),
428
                )
429
            rows.append(task_row)
430
            since = task_row["since_scenario_start_ms"]
431
        else:
432
            rows.append({
433
                **base_row(metadata, variant, kind, scenario, "task.scoped_reads", rep),
434
                "elapsed_ms": 0.0,
435
                "returncode": 1,
436
                "command": [],
437
                "tool_call_count": 0,
438
                "task_prefix": args.task_prefix,
439
                "task_files_read": 0,
440
                "notes": "task prefix missing from acquired tree",
441
                **skipped_content_verification_fields([], "task_prefix_missing"),
442
            })
443
444
        post_task = {
445
            **base_row(metadata, variant, kind, scenario, "disk.usage.post_task", rep),
446
            "elapsed_ms": 0.0,
447
            "returncode": 0,
448
            "command": [],
449
            "tool_call_count": 0,
450
            **disk_row_fields(dest, variant),
451
        }
452
        rows.append(post_task)
453
454
        rows.append({
455
            **base_row(metadata, variant, kind, scenario, "acquire.total", rep),
456
            **row_remote_fields,
457
            "elapsed_ms": round(since, 3),
458
            "returncode": 1 if any(r.get("returncode") not in (0,) for r in rows) else 0,
459
            "command": [r["command"] for r in rows if r.get("command")],
460
            "tool_call_count": sum(int(r.get("tool_call_count", 0)) for r in rows),
461
            "acquire_tool_calls": len(commands),
462
            "time_to_task_ready_ms": round(time_to_task_ready_ms, 3),
463
            "time_to_first_read_ms": round(time_to_first_read_ms, 3),
464
            "post_acquire_allocated_bytes": post_acquire["allocated_tree_bytes"],
465
            "post_task_allocated_bytes": post_task["allocated_tree_bytes"],
466
            "task_hydrated_delta_bytes": post_task["allocated_tree_bytes"] - post_acquire["allocated_tree_bytes"],
467
        })
468
469
    teardown(variant, args.oak_bin, dest)
470
    return rows
471
472
473
def summary_text(rows: list[dict[str, Any]], variants: list[str], task_prefix: str) -> str:
474
    def med(variant: str, field: str) -> float | None:
475
        values = [
476
            float(r[field]) for r in rows
477
            if r.get("subject") == variant and r.get("operation") == "acquire.total"
478
            and r.get(field) is not None and row_returncode(r) == 0
479
        ]
480
        return statistics.median(values) if values else None
481
482
    lines = [
483
        "# Mount vs Clone (acquire_cold)",
484
        "",
485
        "Different networks by construction (oak.space vs github.com): medians "
486
        "support acquisition-model claims, not transport benchmarking. Reps are "
487
        "interleaved; verify spread in the raw rows before publishing.",
488
        f"Scoped task: reads under `{task_prefix}/` (task_files_limit in row metadata).",
489
        "",
490
        "| Variant | n | Time to first read (ms) | Time task-ready (ms) | Acquire calls | Allocated after acquire (MB) | Task hydration delta (MB) |",
491
        "| --- | ---: | ---: | ---: | ---: | ---: | ---: |",
492
    ]
493
    for variant in variants:
494
        n = sum(
495
            1 for r in rows
496
            if r.get("subject") == variant and r.get("operation") == "acquire.total" and row_returncode(r) == 0
497
        )
498
        ttfr = med(variant, "time_to_first_read_ms")
499
        ready = med(variant, "time_to_task_ready_ms")
500
        calls = med(variant, "acquire_tool_calls")
501
        alloc = med(variant, "post_acquire_allocated_bytes")
502
        delta = med(variant, "task_hydrated_delta_bytes")
503
        mb = 1024 * 1024
504
        lines.append(
505
            f"| `{variant}` | {n} | {fmt_num(ttfr, 0)} | {fmt_num(ready, 0)} | {fmt_num(calls, 0)} | "
506
            f"{fmt_num(alloc / mb if alloc is not None else None, 1)} | "
507
            f"{fmt_num(delta / mb if delta is not None else None, 1)} |"
508
        )
509
    skips = [r for r in rows if r.get("returncode") == SKIP_RETURNCODE]
510
    if skips:
511
        lines += ["", f"Skipped rows: {len(skips)} (see skip_reason; skips are work items)."]
512
    return "\n".join(lines) + "\n"
513
514
515
def rows_contain_failures(rows: list[dict[str, Any]]) -> bool:
516
    return any(
517
        row.get("returncode") not in {0, SKIP_RETURNCODE}
518
        and not row.get("skipped")
519
        for row in rows
520
    )
521
522
523
def main() -> int:
524
    args = parse_args()
525
    variants = [v.strip() for v in args.variants.split(",") if v.strip()]
526
    unknown = [v for v in variants if v not in VARIANTS]
527
    if unknown:
528
        raise SystemExit(f"unknown variants: {', '.join(unknown)}")
529
530
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
531
    run_root = args.workdir / "runs" / timestamp
532
    oak_remote = oakbench_remotes.resolve_oak_remote("mirror")
533
    git_remote = oakbench_remotes.resolve_git_github_remote(oakbench_remotes.GIT_MIRROR_ENV)
534
535
    metadata = {
536
        "bench_id": timestamp,
537
        "profile": "mount-vs-clone",
538
        "timestamp_utc": timestamp,
539
        "host": platform.node(),
540
        "platform": platform.platform(),
541
        "machine": platform.machine(),
542
        "python": platform.python_version(),
543
        "env_isolation_version": ENV_ISOLATION_VERSION,
544
        "task_prefix": args.task_prefix,
545
        "task_files_limit": args.task_files,
546
    }
547
548
    rows: list[dict[str, Any]] = []
549
    with measurement_lock("mount_vs_clone") as lock_info:
550
        metadata["measurement_lock_wait_ms"] = lock_info.wait_ms
551
        metadata["measurement_lock"] = "held" if lock_info.enabled else "disabled"
552
        for rep in range(args.reps):
553
            order = list(variants)
554
            random.Random(f"{timestamp}:{rep}").shuffle(order)
555
            for variant in order:
556
                print(f"[run] rep={rep} variant={variant}", flush=True)
557
                rows.extend(run_variant(args, metadata, variant, rep, oak_remote, git_remote, run_root))
558
559
    store = ResultsStore(args.results, lane="mount", filename_suffix="acquire")
560
    raw_path, summary_path = store.write(timestamp, rows, summary_text(rows, variants, args.task_prefix))
561
    if not args.keep_workdirs:
562
        shutil.rmtree(run_root, ignore_errors=True)
563
    print(f"[result] {raw_path}")
564
    print(f"[summary] {summary_path}")
565
    return 1 if rows_contain_failures(rows) else 0
566
567
568
if __name__ == "__main__":
569
    raise SystemExit(main())