Coverage for src/footman/_gc.py: 97%
92 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
1"""Cache garbage collection — the detached child a run spawns at most daily.
3The cache is derived state top to bottom (completion manifests rebuild on
4any execution-path run; timing history regrows), which is what makes
5collecting it casually safe: the worst possible outcome of any deletion is
6a rebuild. Two rules, in order:
81. **The directory is gone.** Manifests bake in the ``cwd`` they describe;
9 if that path no longer exists, the pair (manifest + timing history) is
10 leftovers from a deleted project — collected at any age. Only a file
11 footman recognises as its own manifest is read this way; a task author is
12 invited to keep state in ``cache_dir()``, and one of those files with a
13 ``cwd`` key of its own is nobody's leftovers.
142. **The pair is idle.** Untouched for `IDLE_DAYS`, nobody even TAB-completes
15 there any more (background refreshes keep a visited manifest's mtime
16 fresh) — collected. Manifests from before the ``cwd`` key, and anything
17 in the directory footman did not write, rely on this rule alone.
183. **The fetch room lives by reference, and only its clocks by age.**
19 ``fetch/`` holds one small manifest per URL naming a content-addressed
20 data file. The manifest is the clock: a cache serve touches it
21 (`_fetch._touch`), so an idle one means nothing asked in `IDLE_DAYS` and
22 it goes. A live manifest pins the data file it names at any age; a data
23 file no manifest names is unreachable — fetch resolves only through
24 manifests — so it is garbage immediately, however young. ``.part``
25 files (downloads in flight, referenced by nothing yet) keep the one
26 remaining clock: a killed process's leftover ages out on `PART_DAYS`,
27 while a live download keeps its own mtime fresh by writing. The old
28 ``<key>.bin`` + ``.meta.json`` pairs age exactly as they always did.
30The invoking directory's own pair is never touched, and every failure is
31silent — a concurrently-reading completion child on Windows may hold a file
32open, and a collector must never be louder than what it collects.
33"""
35from __future__ import annotations
37import contextlib
38import json
39import sys
40import time
41from pathlib import Path
43IDLE_DAYS = 90
44PART_DAYS = 1
45STAMP = "gc.stamp"
48def collect(cache_dir: Path, skip_stem: str = "") -> int:
49 """Apply the rules to *cache_dir*; returns the number of files removed.
51 *skip_stem* is the invoking directory's manifest stem (its hash) — that
52 pair is in active use and never touched.
53 """
54 now = time.time()
55 removed = 0
57 def unlink(path: Path) -> None:
58 nonlocal removed
59 try:
60 path.unlink()
61 removed += 1
62 except OSError:
63 pass
65 for manifest_path in cache_dir.glob("*.json"):
66 name = manifest_path.name
67 if name.endswith(".times.json"):
68 continue # handled with its manifest, or as an orphan below
69 stem = name[: -len(".json")]
70 if stem == skip_stem:
71 continue
72 times_path = cache_dir / f"{stem}.times.json"
73 try:
74 data = json.loads(manifest_path.read_text("utf-8"))
75 except (OSError, ValueError):
76 data = None
77 cwd = _baked_cwd(data)
78 if cwd and not Path(cwd).exists():
79 doomed = True # rule 1: leftovers of a deleted project
80 else:
81 doomed = _idle(now, manifest_path, times_path) # rule 2
82 if doomed:
83 unlink(manifest_path)
84 unlink(times_path)
86 # Timing files whose manifest twin is already gone: age them alone.
87 for times_path in cache_dir.glob("*.times.json"):
88 stem = times_path.name[: -len(".times.json")]
89 if stem == skip_stem or (cache_dir / f"{stem}.json").exists():
90 continue
91 if _idle(now, times_path):
92 unlink(times_path)
94 # Rule 3, the fetch room. The manifest is the clock and the reference:
95 # an idle manifest goes (nobody asked in IDLE_DAYS — a serve touches
96 # it), and a live one pins the data file it names. Completed data needs
97 # no clock of its own — a data file no manifest names is unreachable
98 # (fetch resolves only through manifests), so it is garbage the moment
99 # it is orphaned, however young. Only a download in flight keeps a
100 # clock, because nothing references it *yet*.
101 fetch_room = cache_dir / "fetch"
102 referenced: set[str] = set()
103 for manifest in fetch_room.glob("*.json"):
104 if manifest.name.endswith(".meta.json"):
105 continue # the old layout's sidecar, aged with its pair below
106 try:
107 data = json.loads(manifest.read_text("utf-8"))
108 except (OSError, ValueError):
109 data = None
110 data_name = data.get("data") if isinstance(data, dict) else None
111 if _idle(now, manifest):
112 unlink(manifest)
113 elif isinstance(data_name, str): 113 ↛ 103line 113 didn't jump to line 103 because the condition on line 113 was always true
114 referenced.add(data_name)
115 for body in fetch_room.glob("*.bin"):
116 if "-" in body.stem:
117 # New layout: content-addressed, reachable only via a manifest.
118 if body.name not in referenced:
119 unlink(body)
120 continue
121 # Old layout (`<key>.bin` + sidecar): the age rule it always had.
122 sidecar = fetch_room / f"{body.stem}.meta.json"
123 if _idle(now, body, sidecar):
124 unlink(body)
125 unlink(sidecar)
126 for sidecar in fetch_room.glob("*.meta.json"):
127 stem = sidecar.name[: -len(".meta.json")]
128 if (fetch_room / f"{stem}.bin").exists():
129 continue
130 if _idle(now, sidecar): 130 ↛ 126line 130 didn't jump to line 126 because the condition on line 130 was always true
131 unlink(sidecar)
132 # A download in flight keeps its own mtime fresh; one that stopped
133 # writing a day ago belongs to a process that is not coming back.
134 for part in fetch_room.glob("*.part"):
135 if _idle(now, part, days=PART_DAYS):
136 unlink(part)
138 return removed
141def _baked_cwd(data: object) -> str:
142 """The directory *data* describes, or `""` if it is not one of ours.
144 Rule 1 reads meaning out of a file and no age protects what it condemns,
145 so it may only speak for a file footman wrote. A task is invited to keep
146 its own state in `cache_dir()`; one of those that happens to carry a `cwd`
147 key of its own is judged by age alone, like an unparseable file. `tree`
148 and `hash` have been in every manifest since the first schema.
149 """
150 if not isinstance(data, dict) or "tree" not in data or "hash" not in data:
151 return ""
152 cwd = data.get("cwd")
153 return cwd if isinstance(cwd, str) else ""
156def _idle(now: float, *paths: Path, days: float = IDLE_DAYS) -> bool:
157 """Whether the newest of *paths* is older than the idle window."""
158 newest = 0.0
159 for path in paths:
160 with contextlib.suppress(OSError):
161 newest = max(newest, path.stat().st_mtime)
162 return newest > 0 and (now - newest) > days * 86400
165def main() -> None:
166 """Entry for the detached child: argv is (cache_dir, skip_stem)."""
167 if len(sys.argv) < 2:
168 return
169 skip = sys.argv[2] if len(sys.argv) > 2 else ""
170 collect(Path(sys.argv[1]), skip)