Source code for app.storage.workspace
"""Workspace GC + storage accounting.
GC policy (Phase 5). Old completed jobs (status in
``succeeded``, ``failed``, ``cancelled``, ``cancelled_dirty``) past
``ttl_days`` are swept in this order:
1. Drop the job's ``dense/`` directory first.
2. Drop the job's ``snapshots/`` directory next (job-scoped output).
3. Drop the job's ``sparse/`` directory last.
4. Drop the job's ``log.jsonl`` / ``events.jsonl`` only if
``drop_db_rows`` is set.
Reconstruction-level artifacts are NOT touched by job GC because
reconstructions can outlive the job that produced them and may be
referenced by other (still active) jobs. A separate ``gc_orphan_*``
job sweeps reconstructions whose ``dataset_snapshot_hash`` no longer
matches any current dataset.
Pinned jobs (``Job.pinned=True``) are skipped at every step.
Caller passes a ``now`` for testability; otherwise ``datetime.now(UTC)``.
"""
from __future__ import annotations
import contextlib
import shutil
from datetime import UTC, datetime, timedelta
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.paths import Paths
from app.db.models import Job
def _now() -> datetime:
return datetime.now(UTC)
[docs]
def workspace_total_bytes(root: Path) -> int:
if not root.exists():
return 0
total = 0
for p in root.rglob("*"):
try:
if p.is_file():
total += p.stat().st_size
except OSError:
continue
return total
[docs]
async def gc_completed_jobs(
session: AsyncSession,
*,
ttl_days: int,
now: datetime | None = None,
drop_db_rows: bool = False,
) -> dict:
"""Sweep completed-and-old jobs. Returns a summary dict for tests."""
settings = get_settings()
paths = Paths(settings)
cutoff = (now or _now()) - timedelta(days=ttl_days)
rows = (
(
await session.execute(
select(Job).where(
Job.status.in_(("succeeded", "failed", "cancelled", "cancelled_dirty")),
Job.finished_at.is_not(None),
Job.finished_at < cutoff,
Job.pinned.is_(False),
)
)
)
.scalars()
.all()
)
summary = {
"considered": len(rows),
"dense_dropped": 0,
"snapshots_dropped": 0,
"sparse_dropped": 0,
"rows_deleted": 0,
}
for j in rows:
job_dir = paths.job_root(j.tenant_id, j.project_id, j.job_id)
for sub, key in (
("dense", "dense_dropped"),
("snapshots", "snapshots_dropped"),
("sparse", "sparse_dropped"),
):
d = job_dir / sub
if d.exists():
with contextlib.suppress(OSError):
shutil.rmtree(d)
summary[key] += 1
if drop_db_rows:
await session.delete(j)
summary["rows_deleted"] += 1
await session.flush()
return summary