Source code for rath.persistence.gc
"""Unified retention / garbage collection across the persistence planes.
Each plane already had its own ``prune_*`` (sessions, local + remote sandboxes,
local memory stores), but there was no single entry point and — importantly —
nothing pruned the **memory commits archive** (``memory/local/<uuid>/session/
<sid>/commits/<ts>/``), which grew without bound on every ``commit_memory``.
``gc(older_than=..., dry_run=...)`` gives one opt-in sweep over all of them and
returns a :class:`GCReport` of what was (or would be) removed. ``dry_run=True``
(the default) reports without deleting. Every removed path is verified to live
under the resolved data root before deletion, so a bug can never delete outside
``.openrath/``.
"""
from __future__ import annotations
import logging
import shutil
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import UUID
__all__ = ["GCReport", "gc"]
logger = logging.getLogger(__name__)
[docs]
@dataclass(slots=True)
class GCReport:
"""What a :func:`gc` sweep removed (or would remove, in dry-run)."""
sessions: list[UUID] = field(default_factory=list)
local_sandboxes: list[UUID] = field(default_factory=list)
remote_sandboxes: list[UUID] = field(default_factory=list)
memory_stores: list[UUID] = field(default_factory=list)
memory_commits: list[Path] = field(default_factory=list)
dry_run: bool = True
def _data_root() -> Path:
from rath.config.paths import resolve_config_dir
return resolve_config_dir().resolve()
def _is_within(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root)
return True
except ValueError:
return False
def _collect_old_commits(root: Path, cutoff: datetime) -> list[Path]:
"""Find ``.../commits/<ts>/`` dirs older than ``cutoff`` under the memory plane."""
from rath.memory.persistence.paths import local_memory_root
mem_root = local_memory_root()
if not mem_root.is_dir():
return []
found: list[Path] = []
# memory/local/<store>/session/<sid>/commits/<ts>/
for commits_dir in mem_root.glob("*/session/*/commits/*"):
if not commits_dir.is_dir():
continue
if not _is_within(commits_dir, root): # defense-in-depth
continue
try:
mtime = datetime.fromtimestamp(commits_dir.stat().st_mtime, tz=timezone.utc)
except OSError:
continue
if mtime < cutoff:
found.append(commits_dir)
return sorted(found)
[docs]
def gc(*, older_than: timedelta, dry_run: bool = True) -> GCReport:
"""Sweep prunable artifacts older than ``older_than`` across all planes.
With ``dry_run=True`` (default) nothing is deleted — the report lists what
*would* be removed. With ``dry_run=False`` the existing per-plane prune
helpers run and the memory commits archive is trimmed. Deletion is confined
to the resolved data root.
"""
root = _data_root()
cutoff = datetime.now(timezone.utc) - older_than
report = GCReport(dry_run=dry_run)
# --- memory commits archive (new; previously unbounded) ------------------
commit_dirs = _collect_old_commits(root, cutoff)
report.memory_commits = commit_dirs
if not dry_run:
for d in commit_dirs:
if _is_within(d, root):
shutil.rmtree(d, ignore_errors=True)
# --- other planes --------------------------------------------------------
if dry_run:
report.sessions = _dry_sessions(cutoff)
report.local_sandboxes = _dry_local_sandboxes(cutoff)
report.remote_sandboxes = _dry_remote_sandboxes(cutoff)
report.memory_stores = _dry_memory_stores(cutoff)
else:
from rath.backend.persistence.registry import PersistentSandboxRegistry
from rath.memory.persistence.registry import PersistentMemoryRegistry
from rath.session.persistence.loader import prune_sessions
report.sessions = prune_sessions(older_than=older_than)
sandbox_reg = PersistentSandboxRegistry()
report.local_sandboxes = sandbox_reg.prune_local(older_than=older_than)
report.remote_sandboxes = sandbox_reg.prune_remote(older_than=older_than)
report.memory_stores = PersistentMemoryRegistry().prune_local(
older_than=older_than
)
return report
# --- dry-run enumerators (mirror each prune's cutoff without deleting) -------
def _dry_sessions(cutoff: datetime) -> list[UUID]:
from rath.session.persistence.loader import list_persisted_sessions
out: list[UUID] = []
for meta in list_persisted_sessions():
created = meta.created_at
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if created < cutoff:
out.append(meta.id)
return out
def _dry_local_sandboxes(cutoff: datetime) -> list[UUID]:
from rath.backend.persistence.paths import local_sandbox_dir
from rath.backend.persistence.registry import PersistentSandboxRegistry
out: list[UUID] = []
for sid in PersistentSandboxRegistry().list_local():
try:
mtime = datetime.fromtimestamp(
local_sandbox_dir(sid).stat().st_mtime, tz=timezone.utc
)
except OSError:
continue
if mtime < cutoff:
out.append(sid)
return out
def _dry_remote_sandboxes(cutoff: datetime) -> list[UUID]:
from rath.backend.persistence.registry import PersistentSandboxRegistry
reg = PersistentSandboxRegistry()
out: list[UUID] = []
for rec in reg.list_remote():
last = rec.last_used_at
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
if last < cutoff:
out.append(rec.id)
return out
def _dry_memory_stores(cutoff: datetime) -> list[UUID]:
from rath.memory.persistence.paths import local_store_dir
from rath.memory.persistence.registry import PersistentMemoryRegistry
out: list[UUID] = []
for sid in PersistentMemoryRegistry().list_local():
try:
mtime = datetime.fromtimestamp(
local_store_dir(sid).stat().st_mtime, tz=timezone.utc
)
except OSError:
continue
if mtime < cutoff:
out.append(sid)
return out