Source code for grid_rbd.gc_cache

"""Garbage-collect the per-host robot cache (2026-10-10, user-ratified policy).

    python -m grid_rbd.gc_cache [--cache-dir DIR] [--execute] [--objects-days N]
    grid_rbd.gc_cache()                      # dry run, returns the report
    grid_rbd.gc_cache(execute=True)          # delete

Garbage = store/<content_key>/ entries that NO manifest name points to, plus
bykey/ pointers whose entry is gone (or is being removed), plus dead `.gen_*`
staging dirs. Nothing else: an entry a name still binds is kept however old
it is, and per-unit objects (objects/) are kept forever — `--objects-days N`
opts in to dropping objects not built or linked from in N days. Dry run is
the default; `--execute` / `execute=True` deletes. The scan + delete runs
under the manifest lock so a concurrent register_robot cannot bind a name to
an entry while it is being removed.

Why names drift: re-registering a name after any codegen / bindings / toolchain
change binds it to a NEW content key and leaves the old entry behind (the
store is content-addressed and never overwrites). A test suite does this for
hundreds of names, so most of a dev box's store is unreferenced.
"""
from __future__ import annotations

import argparse
import fcntl
import os
import shutil
import sys
import time
from pathlib import Path
from typing import Any

from ._cache import default_cache_dir, load_manifest


def _tree_bytes(path: Path) -> int:
    total = 0
    for root, _dirs, files in os.walk(path):
        for f in files:
            try:
                total += (Path(root) / f).stat().st_size
            except OSError:
                pass
    return total


[docs] def gc_cache(cache_dir: str | Path | None = None, *, execute: bool = False, objects_days: float | None = None) -> dict[str, Any]: """Scan (and with ``execute=True`` delete) the cache garbage described in the module docstring. Returns a report dict; sizes in bytes.""" cache_dir = Path(cache_dir).expanduser() if cache_dir else default_cache_dir() report: dict[str, Any] = { "cache_dir": str(cache_dir), "executed": execute, "entries_kept": 0, "entries_removed": [], "entries_bytes": 0, "pointers_removed": 0, "staging_removed": 0, "objects_removed": 0, "objects_bytes": 0, } store = cache_dir / "store" if not store.is_dir(): return report cache_dir.mkdir(parents=True, exist_ok=True) with (cache_dir / "manifest.lock").open("a+") as lock: fcntl.flock(lock.fileno(), fcntl.LOCK_EX) try: referenced = {e.get("cache_key") for e in load_manifest(cache_dir).get("robots", {}).values()} entries = [d for d in store.iterdir() if d.is_dir() and not d.name.startswith(".")] garbage = [d for d in entries if d.name not in referenced] report["entries_kept"] = len(entries) - len(garbage) for d in garbage: report["entries_bytes"] += _tree_bytes(d) report["entries_removed"].append(d.name) if execute: shutil.rmtree(d, ignore_errors=True) gone = {d.name for d in garbage} bykey = cache_dir / "bykey" if bykey.is_dir(): for p in bykey.iterdir(): try: target = p.read_text().strip() except OSError: continue if target in gone or not (store / target / "robot.so").exists(): report["pointers_removed"] += 1 if execute: p.unlink(missing_ok=True) from . import _sweep_dead_staging_dirs staging = list(store.glob(".gen_*_*")) report["staging_removed"] = _sweep_dead_staging_dirs(store) if execute else len(staging) finally: fcntl.flock(lock.fileno(), fcntl.LOCK_UN) objects = cache_dir / "objects" if objects_days is not None and objects_days > 0 and objects.is_dir(): cutoff = time.time() - objects_days * 86400 for o in objects.glob("wrapper_*.o"): try: st = o.stat() except OSError: continue if st.st_mtime < cutoff: report["objects_removed"] += 1 report["objects_bytes"] += st.st_size if execute: o.unlink(missing_ok=True) return report
def format_report(r: dict[str, Any]) -> str: verb = "removed" if r["executed"] else "would remove" gib = lambda b: f"{b / 2**30:.2f} GiB" lines = [f"grid_rbd cache {r['cache_dir']}: {verb}", f" {len(r['entries_removed'])} store entries no name binds ({gib(r['entries_bytes'])}); {r['entries_kept']} kept", f" {r['pointers_removed']} dangling bykey pointers, {r['staging_removed']} dead staging dirs"] if r["objects_removed"]: lines.append(f" {r['objects_removed']} cached unit objects ({gib(r['objects_bytes'])})") if not r["executed"]: lines.append(" (dry run — pass --execute to delete)") return "\n".join(lines) def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(prog="python -m grid_rbd.gc_cache", description=__doc__.split("\n\n")[1]) ap.add_argument("--cache-dir", default=None) ap.add_argument("--execute", action="store_true", help="delete (default: dry run)") ap.add_argument("--objects-days", type=float, default=None, help="also drop per-unit objects not built/linked from in N days (default: keep forever)") a = ap.parse_args(argv) print(format_report(gc_cache(a.cache_dir, execute=a.execute, objects_days=a.objects_days))) return 0 if __name__ == "__main__": sys.exit(main())