fix(browser): sweep orphan artifact files at store construction
Artifact receipts live only in memory, so files left behind by a dead process were unreachable but persisted forever despite the advertised 300s TTL — a retention failure on the surface meant to be ephemeral (blocker 4 of andrexibiza's #91535 review). A fresh ArtifactStore now removes every artifact-id-shaped file and stale *.tmp with no index entry (at construction the index is empty, so all such files are orphans). Non-artifact-shaped names are untouched. Regression: store -> recreate store over same root -> orphan+tmp gone, unrelated file kept.
This commit is contained in:
@@ -215,6 +215,44 @@ class ArtifactStore:
|
||||
self._clock = clock if clock is not None else time.time
|
||||
self._lock = threading.RLock()
|
||||
self._entries: dict[str, _ArtifactEntry] = {}
|
||||
# Restart-safe retention: receipts live only in memory, so files
|
||||
# left behind by a previous process are unreachable but would
|
||||
# otherwise persist forever. Sweep every artifact-id-shaped file
|
||||
# (plus stale temps) that has no index entry — at construction the
|
||||
# index is empty, so anything on disk is an orphan from a dead
|
||||
# process and past its advertised TTL by definition.
|
||||
self._sweep_orphan_files()
|
||||
|
||||
def _sweep_orphan_files(self) -> int:
|
||||
"""Delete on-disk artifact files with no live index entry.
|
||||
|
||||
Called at construction (empty index ⇒ everything on disk is an
|
||||
orphan from a previous process). Only files whose names match the
|
||||
server-minted 32-hex id shape or the ``*.tmp`` staging suffix are
|
||||
touched; anything else in the directory is left alone.
|
||||
"""
|
||||
removed = 0
|
||||
try:
|
||||
candidates = list(self._root.iterdir())
|
||||
except OSError:
|
||||
return 0
|
||||
with self._lock:
|
||||
live = set(self._entries)
|
||||
for path in candidates:
|
||||
if not path.is_file():
|
||||
continue
|
||||
name = path.name
|
||||
is_temp = name.endswith(".tmp")
|
||||
if not is_temp and not _ARTIFACT_ID_RE.fullmatch(name):
|
||||
continue
|
||||
if not is_temp and name in live:
|
||||
continue
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
|
||||
Reference in New Issue
Block a user