97 lines
3.4 KiB
Python
97 lines
3.4 KiB
Python
"""Failure artifacts for the fault and race suites (US07-04).
|
|
|
|
A randomized concurrency failure that leaves nothing behind is a failure nobody
|
|
can diagnose: the temporary library is deleted, the database goes with it, and the
|
|
seed that produced the interleaving is gone. So when a test fails, everything
|
|
needed to reproduce and read it is copied out of the temporary directory:
|
|
|
|
<artifacts>/<test id>/
|
|
seeds.json recorded properties (``race_seed``) and the failing test id
|
|
manifest.json every file under the temporary directory: path, size, sha256
|
|
files/... the databases (with -wal/-shm), journals, and logs themselves
|
|
|
|
The manifest covers the whole tree — including files too large or too private to
|
|
copy — so a missing or unexpected file is still visible afterwards. Copying is
|
|
bounded by ``MAX_COPY_BYTES``: artifacts must not turn a failing CI run into a
|
|
disk-full one.
|
|
|
|
Set ``PHOTO_PIPELINE_TEST_ARTIFACTS`` to choose the destination; the default is
|
|
``.artifacts/`` in the repository root.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
DEFAULT_DIR = REPO / ".artifacts"
|
|
MAX_COPY_BYTES = 25 * 1024 * 1024
|
|
# Databases (and their write-ahead logs), journals exported as files, and logs.
|
|
COPY_SUFFIXES = (".db", ".db-wal", ".db-shm", ".sqlite", ".log", ".json", ".jsonl", ".argv")
|
|
|
|
|
|
def artifacts_dir() -> Path:
|
|
return Path(os.environ.get("PHOTO_PIPELINE_TEST_ARTIFACTS", DEFAULT_DIR))
|
|
|
|
|
|
def _slug(test_id: str) -> str:
|
|
return re.sub(r"[^A-Za-z0-9._-]+", "_", test_id)[:120]
|
|
|
|
|
|
def manifest(root: Path) -> list[dict]:
|
|
"""Every file under ``root``: relative path, byte size, and SHA-256.
|
|
|
|
The filesystem state at the moment of failure — what was moved, what was left
|
|
behind, what was half-written.
|
|
"""
|
|
entries = []
|
|
for path in sorted(root.rglob("*")):
|
|
if not path.is_file() or path.is_symlink():
|
|
continue
|
|
try:
|
|
body = path.read_bytes()
|
|
except OSError as error:
|
|
entries.append({"path": str(path.relative_to(root)), "error": str(error)})
|
|
continue
|
|
entries.append(
|
|
{
|
|
"path": str(path.relative_to(root)),
|
|
"bytes": len(body),
|
|
"sha256": hashlib.sha256(body).hexdigest(),
|
|
}
|
|
)
|
|
return entries
|
|
|
|
|
|
def collect(root: Path, test_id: str, *, properties: dict | None = None) -> Path:
|
|
"""Copy the evidence for one failed test out of ``root``. Returns its directory."""
|
|
destination = artifacts_dir() / _slug(test_id)
|
|
files = destination / "files"
|
|
files.mkdir(parents=True, exist_ok=True)
|
|
|
|
entries = manifest(root)
|
|
(destination / "manifest.json").write_text(json.dumps(entries, indent=1))
|
|
(destination / "seeds.json").write_text(
|
|
json.dumps({"test": test_id, "properties": properties or {}}, indent=1)
|
|
)
|
|
|
|
budget = MAX_COPY_BYTES
|
|
for path in sorted(root.rglob("*")):
|
|
if not path.is_file() or path.is_symlink():
|
|
continue
|
|
if not path.name.endswith(COPY_SUFFIXES):
|
|
continue
|
|
size = path.stat().st_size
|
|
if size > budget:
|
|
continue # the manifest still records it; the copy is what is skipped
|
|
target = files / path.relative_to(root)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(path, target)
|
|
budget -= size
|
|
return destination
|