Files

375 lines
13 KiB
Python

"""Inspect and optionally clean generated runtime data.
The script is deliberately conservative:
- dry-run is the default;
- protected paths are collected from database product/artifact references;
- project source, local software, external data pools, and license files are never touched.
"""
from __future__ import annotations
import argparse
import asyncio
import os
import shutil
import sys
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Iterable
PROJECT_ROOT = Path(__file__).resolve().parents[1]
BACKEND_DIR = PROJECT_ROOT / "backend"
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from backend.app.config import ensure_project_env_loaded, read_int_env, settings # noqa: E402
ACTIVE_STATUSES = {"PENDING", "READY", "RUNNING", "PROCESSING", "IN_PROGRESS", "QUEUED", "RETRY"}
@dataclass(frozen=True)
class CleanupRoot:
label: str
path: Path
default_retention_days: int
mode: str = "children"
protect_db_references: bool = False
enabled_by_default: bool = True
@dataclass
class Candidate:
label: str
path: Path
reason: str
size_bytes: int
files: int
dirs: int
last_write: datetime | None
protected: bool = False
deleted: bool = False
error: str = ""
def _norm(path: Path | str) -> Path:
return Path(path).expanduser().resolve()
def _is_under(path: Path, parent: Path) -> bool:
try:
path.relative_to(parent)
return True
except ValueError:
return False
def _parse_dt(value: object) -> datetime | None:
if isinstance(value, datetime):
return value
text = str(value or "").strip()
if not text:
return None
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(text[:26], fmt)
except ValueError:
continue
return None
def _measure(path: Path) -> tuple[int, int, int, datetime | None]:
if not path.exists():
return 0, 0, 0, None
if path.is_file():
stat = path.stat()
return stat.st_size, 1, 0, datetime.fromtimestamp(stat.st_mtime)
size = 0
files = 0
dirs = 0
last_write = datetime.fromtimestamp(path.stat().st_mtime)
for entry in path.rglob("*"):
try:
stat = entry.stat()
except OSError:
continue
if entry.is_dir():
dirs += 1
else:
files += 1
size += stat.st_size
mtime = datetime.fromtimestamp(stat.st_mtime)
if mtime > last_write:
last_write = mtime
return size, files, dirs, last_write
def _format_gb(size_bytes: int) -> str:
return f"{size_bytes / (1024 ** 3):.3f} GB"
def _candidate_children(root: CleanupRoot, cutoff: datetime) -> list[Candidate]:
path = _norm(root.path)
if not path.exists() or not path.is_dir():
return []
candidates: list[Candidate] = []
for child in path.iterdir():
size, files, dirs, last_write = _measure(child)
if last_write and last_write > cutoff:
continue
candidates.append(
Candidate(
label=root.label,
path=_norm(child),
reason=f"last_write <= {cutoff.isoformat(timespec='seconds')}",
size_bytes=size,
files=files,
dirs=dirs,
last_write=last_write,
)
)
return candidates
def _candidate_root(root: CleanupRoot, cutoff: datetime) -> list[Candidate]:
path = _norm(root.path)
if not path.exists():
return []
size, files, dirs, last_write = _measure(path)
if last_write and last_write > cutoff:
return []
return [
Candidate(
label=root.label,
path=path,
reason=f"root last_write <= {cutoff.isoformat(timespec='seconds')}",
size_bytes=size,
files=files,
dirs=dirs,
last_write=last_write,
)
]
def _configured_roots() -> list[CleanupRoot]:
runtime_dir = BACKEND_DIR / "runtime"
return [
CleanupRoot(
"pyint_work",
Path(settings.PYINT_WORK_ROOT or runtime_dir / "pyint_work"),
read_int_env("RUNTIME_CLEANUP_PYINT_WORK_RETENTION_DAYS", 14),
),
CleanupRoot(
"pyint_dem",
Path(settings.PYINT_DEM_ROOT or runtime_dir / "pyint_dem"),
read_int_env("RUNTIME_CLEANUP_PYINT_DEM_RETENTION_DAYS", 60),
),
CleanupRoot(
"sbas_insar_production_runs",
Path(settings.GAMMA_SBAS_WORK_ROOT or runtime_dir / "sbas_insar_production") / "runs",
read_int_env("RUNTIME_CLEANUP_SBAS_RUN_RETENTION_DAYS", 30),
protect_db_references=True,
),
CleanupRoot(
"gamma_ipta_trials",
Path(settings.GAMMA_SBAS_TRIAL_ROOT or runtime_dir / "gamma_ipta_trials"),
read_int_env("RUNTIME_CLEANUP_TRIAL_RETENTION_DAYS", 7),
),
CleanupRoot(
"idl_worker_logs",
Path(settings.IDL_WORKER_RUNTIME_DIR or runtime_dir / "idl_worker"),
read_int_env("RUNTIME_CLEANUP_IDL_LOG_RETENTION_DAYS", 30),
),
CleanupRoot(
"wsl_jobs",
Path(settings.WSL_BROKER_JOB_ROOT or runtime_dir / "wsl_jobs"),
read_int_env("RUNTIME_CLEANUP_WSL_JOB_RETENTION_DAYS", 14),
),
CleanupRoot(
"timeseries_work",
Path(settings.TIMESERIES_WORK_ROOT or runtime_dir / "timeseries_work"),
read_int_env("RUNTIME_CLEANUP_TIMESERIES_WORK_RETENTION_DAYS", 30),
),
CleanupRoot(
"image_cache",
Path(settings.CACHE_DIR),
read_int_env("RUNTIME_CLEANUP_IMAGE_CACHE_RETENTION_DAYS", 60),
),
CleanupRoot(
"frontend_dist",
PROJECT_ROOT / "frontend" / "dist",
read_int_env("RUNTIME_CLEANUP_FRONTEND_DIST_RETENTION_DAYS", 0),
mode="root",
enabled_by_default=False,
),
CleanupRoot(
"logs",
PROJECT_ROOT / "logs",
read_int_env("RUNTIME_CLEANUP_LOG_RETENTION_DAYS", 30),
),
CleanupRoot(
"nginx_temp",
PROJECT_ROOT / "nginx" / "temp",
0,
),
]
async def _db_rows(query: str) -> list[tuple]:
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
url = str(settings.DATABASE_URL or "").strip()
if not url:
return []
engine = create_async_engine(url, pool_pre_ping=True)
try:
async with engine.connect() as conn:
result = await conn.execute(text(query))
return list(result.all())
finally:
await engine.dispose()
async def _active_status_summary() -> dict[str, int]:
queries = {
"system_jobs": "select count(*) from system_jobs where upper(status) in ('PENDING','READY','RUNNING','PROCESSING','IN_PROGRESS','QUEUED','RETRY')",
"system_tasks": "select count(*) from system_tasks where upper(status) in ('PENDING','RUNNING','PROCESSING','IN_PROGRESS','QUEUED')",
"workflow_runs": "select count(*) from workflow_runs where upper(status) in ('PENDING','RUNNING','PROCESSING','IN_PROGRESS','QUEUED')",
}
summary: dict[str, int] = {}
for key, query in queries.items():
try:
rows = await _db_rows(query)
summary[key] = int(rows[0][0] or 0) if rows else 0
except Exception:
summary[key] = -1
return summary
async def _protected_paths() -> set[Path]:
protected: set[Path] = set()
queries = [
"select publish_dir, manifest_path, source_primary_path, native_output_dir, preview_path, primary_asset_path from result_products",
"select absolute_path from result_assets",
"select path from workflow_artifacts",
"select storage_root from result_catalog_states",
]
for query in queries:
try:
rows = await _db_rows(query)
except Exception as exc:
print(f"[WARN] Could not read protected paths: {exc}")
continue
for row in rows:
for value in row:
text = str(value or "").strip()
if not text:
continue
try:
protected.add(_norm(text))
except OSError:
continue
return protected
def _apply_protection(candidates: Iterable[Candidate], protected: set[Path]) -> list[Candidate]:
output: list[Candidate] = []
for candidate in candidates:
path = candidate.path
for protected_path in protected:
if path == protected_path or _is_under(protected_path, path) or _is_under(path, protected_path):
candidate.protected = True
candidate.reason = f"database reference protects {protected_path}"
break
output.append(candidate)
return output
def _delete(candidate: Candidate) -> None:
try:
if candidate.path.is_dir():
shutil.rmtree(candidate.path)
else:
candidate.path.unlink()
candidate.deleted = True
except Exception as exc:
candidate.error = f"{type(exc).__name__}: {exc}"
async def collect_candidates(include_optional: bool, include_external_runtime: bool) -> tuple[list[Candidate], dict[str, int]]:
ensure_project_env_loaded()
now = datetime.now()
candidates: list[Candidate] = []
for root in _configured_roots():
if not root.enabled_by_default and not include_optional:
continue
root_path = _norm(root.path)
if not _is_under(root_path, PROJECT_ROOT):
if not include_external_runtime:
print(f"[SKIP] {root.label}: outside project root: {root_path}")
continue
cutoff = now - timedelta(days=max(0, int(root.default_retention_days)))
if root.mode == "root":
items = _candidate_root(root, cutoff)
else:
items = _candidate_children(root, cutoff)
candidates.extend(items)
protected = await _protected_paths()
candidates = _apply_protection(candidates, protected)
active_summary = await _active_status_summary()
if any(value > 0 for value in active_summary.values()):
for candidate in candidates:
if candidate.label in {"pyint_work", "sbas_insar_production_runs", "wsl_jobs", "timeseries_work"}:
candidate.protected = True
candidate.reason = f"active database state present: {active_summary}"
return candidates, active_summary
def print_report(candidates: list[Candidate], active_summary: dict[str, int]) -> None:
total = sum(item.size_bytes for item in candidates)
deletable = sum(item.size_bytes for item in candidates if not item.protected)
print(f"[INFO] Active DB state: {active_summary}")
print(f"[INFO] Candidates: {len(candidates)}, total={_format_gb(total)}, deletable={_format_gb(deletable)}")
for item in sorted(candidates, key=lambda x: x.size_bytes, reverse=True):
status = "PROTECTED" if item.protected else ("DELETED" if item.deleted else "DELETE_CANDIDATE")
last_write = item.last_write.isoformat(timespec="seconds") if item.last_write else "unknown"
print(
f"{status}\t{item.label}\t{_format_gb(item.size_bytes)}\t"
f"files={item.files}\tdirs={item.dirs}\tlast={last_write}\t{item.path}\t{item.reason}"
)
if item.error:
print(f" ERROR: {item.error}")
async def main() -> int:
parser = argparse.ArgumentParser(description="Inspect and clean generated runtime data.")
parser.add_argument("--delete", action="store_true", help="Actually delete unprotected candidates.")
parser.add_argument("--include-optional", action="store_true", help="Include optional targets such as frontend/dist.")
parser.add_argument("--include-external-runtime", action="store_true", help="Also inspect configured runtime roots outside the project.")
parser.add_argument("--yes", action="store_true", help="Required together with --delete.")
args = parser.parse_args()
candidates, active_summary = await collect_candidates(args.include_optional, args.include_external_runtime)
if args.delete and not args.yes:
print("[ERROR] --delete requires --yes")
print_report(candidates, active_summary)
return 2
if args.delete:
for candidate in candidates:
if candidate.protected:
continue
_delete(candidate)
print_report(candidates, active_summary)
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))