105 lines
3.8 KiB
Python
105 lines
3.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Inspect and optionally clean stale job directories.
|
|
|
|
Default mode is a safe dry-run that reports reclaimable bytes. Pass --apply to
|
|
actually remove unreferenced job directories.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from .cleanup_service import CleanupService
|
|
from .metadata_store import MetadataStore
|
|
from .product_archive_store import ProductArchiveStore
|
|
from .storage import Storage
|
|
|
|
|
|
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
|
PROJECT_ROOT = BACKEND_ROOT
|
|
WORKSPACE_DIR = PROJECT_ROOT / "service_workspace"
|
|
ASSETS_DIR = PROJECT_ROOT / "service_assets"
|
|
METADATA_DIR = PROJECT_ROOT / "service_metadata"
|
|
PRODUCTS_DIR = PROJECT_ROOT / "service_products"
|
|
|
|
|
|
def read_asset_meta(path: Path) -> dict:
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def referenced_job_ids() -> set[str]:
|
|
refs: set[str] = set()
|
|
for meta_path in ASSETS_DIR.glob("*/*/meta.json"):
|
|
job_id = read_asset_meta(meta_path).get("job_id") or ""
|
|
if job_id:
|
|
refs.add(job_id)
|
|
return refs
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--max-age-days", type=float, default=30)
|
|
parser.add_argument("--apply", action="store_true", help="Actually delete stale job directories")
|
|
parser.add_argument("--json", type=Path, default=None, help="Write JSON report")
|
|
args = parser.parse_args()
|
|
|
|
store = MetadataStore(METADATA_DIR / "app.db")
|
|
storage = Storage(WORKSPACE_DIR)
|
|
product_store = ProductArchiveStore(PRODUCTS_DIR / "metadata")
|
|
cleanup = CleanupService(storage, store, product_store)
|
|
known_job_ids = store.job_ids() if store.db_path.exists() else set()
|
|
asset_referenced = referenced_job_ids()
|
|
archive_protected = cleanup.archive_protected_job_ids()
|
|
orphaned = storage.stale_job_dirs(
|
|
referenced_job_ids=archive_protected,
|
|
exclude_job_ids=known_job_ids,
|
|
max_age_days=args.max_age_days,
|
|
)
|
|
cleanup_report = cleanup.preview(datetime.now(timezone.utc))
|
|
reclaimable = cleanup_report.reclaimable_bytes + sum(item["size_bytes"] for item in orphaned)
|
|
report = {
|
|
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
|
"job_dir_count": len(storage.list_job_ids()),
|
|
"referenced_job_ids": len(asset_referenced),
|
|
"asset_referenced_job_ids": sorted(asset_referenced),
|
|
"archive_protected_job_ids": sorted(archive_protected),
|
|
"known_job_ids": len(known_job_ids),
|
|
"temporary_jobs": [item.model_dump(mode="json") for item in cleanup_report.temporary_jobs],
|
|
"pending_product_ids": [item.product_id for item in cleanup_report.pending_products],
|
|
"reminder_job_ids": cleanup_report.reminder_job_ids,
|
|
"stale_job_count": len(orphaned),
|
|
"reclaimable_bytes": reclaimable,
|
|
"max_age_days": args.max_age_days,
|
|
"apply": args.apply,
|
|
"stale_jobs": orphaned[:200],
|
|
}
|
|
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
if args.json:
|
|
args.json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
if args.apply:
|
|
if os.environ.get("CLEANUP_APPLY_ENABLED", "false").strip().lower() != "true":
|
|
parser.error("--apply requires CLEANUP_APPLY_ENABLED=true")
|
|
applied = cleanup.apply(datetime.now(timezone.utc))
|
|
freed = 0
|
|
for item in orphaned:
|
|
freed += storage.remove_job_dir(item["job_id"])
|
|
store.delete_job(item["job_id"])
|
|
print(
|
|
"freed_bytes="
|
|
f"{freed} deleted_job_ids={applied.deleted_job_ids} "
|
|
f"deleted_product_ids={applied.deleted_product_ids}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|