prune_runtime_cache.py 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import argparse
  4. import shutil
  5. import sys
  6. import time
  7. from dataclasses import dataclass
  8. from pathlib import Path
  9. DEFAULT_RETENTION_DAYS = 30
  10. @dataclass(frozen=True)
  11. class PruneCandidate:
  12. path: Path
  13. age_days: float
  14. size_bytes: int
  15. def collect_candidates(
  16. runtime_root: Path,
  17. *,
  18. retention_days: int = DEFAULT_RETENTION_DAYS,
  19. now: float | None = None,
  20. ) -> list[PruneCandidate]:
  21. reference_time = time.time() if now is None else now
  22. cutoff_seconds = retention_days * 24 * 60 * 60
  23. if not runtime_root.exists():
  24. return []
  25. candidates: list[PruneCandidate] = []
  26. for path in sorted(runtime_root.iterdir()):
  27. if not path.is_dir() or not path.name.startswith("v1_run_"):
  28. continue
  29. age_seconds = max(0.0, reference_time - path.stat().st_mtime)
  30. if age_seconds <= cutoff_seconds:
  31. continue
  32. candidates.append(
  33. PruneCandidate(
  34. path=path,
  35. age_days=age_seconds / (24 * 60 * 60),
  36. size_bytes=_dir_size(path),
  37. )
  38. )
  39. return candidates
  40. def prune_candidates(candidates: list[PruneCandidate], *, execute: bool) -> int:
  41. for candidate in candidates:
  42. if execute:
  43. shutil.rmtree(candidate.path)
  44. print(
  45. f"{'DELETE' if execute else 'DRY-RUN'}\t"
  46. f"{candidate.age_days:.1f}d\t"
  47. f"{_format_size(candidate.size_bytes)}\t"
  48. f"{candidate.path}"
  49. )
  50. return len(candidates)
  51. def main() -> int:
  52. args = _parse_args()
  53. candidates = collect_candidates(
  54. args.runtime_root,
  55. retention_days=args.retention_days,
  56. )
  57. count = prune_candidates(candidates, execute=args.execute)
  58. action = "deleted" if args.execute else "would_delete"
  59. print(f"{action}={count} retention_days={args.retention_days} runtime_root={args.runtime_root}")
  60. return 0
  61. def _parse_args() -> argparse.Namespace:
  62. parser = argparse.ArgumentParser(
  63. description="Prune local runtime/v1 run directories. DB and OSS records are not touched."
  64. )
  65. parser.add_argument("--runtime-root", default=Path("runtime/v1"), type=Path)
  66. parser.add_argument("--retention-days", default=DEFAULT_RETENTION_DAYS, type=int)
  67. parser.add_argument(
  68. "--execute",
  69. action="store_true",
  70. help="Actually delete matched run directories. Default is dry-run.",
  71. )
  72. return parser.parse_args()
  73. def _dir_size(path: Path) -> int:
  74. return sum(item.stat().st_size for item in path.rglob("*") if item.is_file())
  75. def _format_size(size_bytes: int) -> str:
  76. value = float(size_bytes)
  77. for unit in ["B", "KB", "MB", "GB"]:
  78. if value < 1024 or unit == "GB":
  79. return f"{value:.1f}{unit}"
  80. value /= 1024
  81. return f"{value:.1f}GB"
  82. if __name__ == "__main__":
  83. raise SystemExit(main())