feat: add product archive retention cleanup
This commit is contained in:
+99
-13
@@ -12,6 +12,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -23,6 +24,7 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from core import config as wc_config
|
||||
from core.fonts import get_cached_font
|
||||
from .cleanup_service import CleanupReport, CleanupService
|
||||
from .job_manager import JobManager
|
||||
from .line_spacing import analyze_svg_line_spacing_file
|
||||
from .log_config import get_logger
|
||||
@@ -67,7 +69,7 @@ FONTS_DIR = PROJECT_ROOT / "service_fonts"
|
||||
DESIGN_TEMPLATES_DIR = PROJECT_ROOT / "service_design_templates"
|
||||
METADATA_DIR = PROJECT_ROOT / "service_metadata"
|
||||
ORDERS_DIR = PROJECT_ROOT / "service_orders"
|
||||
PRODUCT_ARCHIVES_DIR = PROJECT_ROOT / "service_product_archives"
|
||||
PRODUCT_ARCHIVES_DIR = PROJECT_ROOT / "service_products"
|
||||
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
FONTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -81,8 +83,19 @@ manager = JobManager(metadata_store)
|
||||
storage = Storage(WORKSPACE_DIR)
|
||||
runner = JobRunner(PROJECT_ROOT, manager)
|
||||
product_archive_store = ProductArchiveStore(PRODUCT_ARCHIVES_DIR / "metadata")
|
||||
cleanup_service = CleanupService(storage, metadata_store, product_archive_store)
|
||||
|
||||
app = FastAPI(title="WordCloud Test Service", version="0.1.0")
|
||||
|
||||
@asynccontextmanager
|
||||
async def _app_lifespan(_app: FastAPI):
|
||||
_start_cleanup_scheduler()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_stop_cleanup_scheduler()
|
||||
|
||||
|
||||
app = FastAPI(title="WordCloud Test Service", version="0.1.0", lifespan=_app_lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -162,26 +175,27 @@ def health() -> dict:
|
||||
|
||||
@app.get("/api/maintenance/storage-summary")
|
||||
def storage_summary() -> dict:
|
||||
referenced_jobs: set[str] = set()
|
||||
asset_referenced_jobs: set[str] = set()
|
||||
for d in _list_dirs(ASSETS_DIR):
|
||||
meta = _read_asset_meta(d)
|
||||
job_id = meta.get("job_id") or ""
|
||||
if job_id:
|
||||
referenced_jobs.add(job_id)
|
||||
stale = storage.stale_job_dirs(
|
||||
referenced_job_ids=referenced_jobs,
|
||||
exclude_job_ids=metadata_store.job_ids(),
|
||||
max_age_days=0,
|
||||
)
|
||||
asset_referenced_jobs.add(job_id)
|
||||
report = cleanup_service.preview(datetime.now(timezone.utc))
|
||||
archive_protected_jobs = cleanup_service.archive_protected_job_ids()
|
||||
return {
|
||||
"job_dir_count": len(storage.list_job_ids()),
|
||||
"referenced_job_ids": len(referenced_jobs),
|
||||
"stale_job_count": len(stale),
|
||||
"reclaimable_bytes": sum(item["size_bytes"] for item in stale),
|
||||
"referenced_job_ids": len(asset_referenced_jobs),
|
||||
"asset_referenced_job_ids": sorted(asset_referenced_jobs),
|
||||
"archive_protected_job_ids": sorted(archive_protected_jobs),
|
||||
"archive_protected_job_count": len(archive_protected_jobs),
|
||||
"temporary_job_ids": [item.job_id for item in report.temporary_jobs],
|
||||
"stale_job_count": len(report.temporary_jobs),
|
||||
"reclaimable_bytes": report.reclaimable_bytes,
|
||||
"metadata_db_bytes": metadata_store.summarize()["db_size_bytes"],
|
||||
"jobs_in_db": metadata_store.summarize()["jobs"],
|
||||
"events_in_db": metadata_store.summarize()["events"],
|
||||
"dry_run_only": True,
|
||||
"dry_run_only": not _cleanup_apply_enabled(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1023,6 +1037,78 @@ def _require_orders_auth(request: Request) -> None:
|
||||
raise HTTPException(status_code=403, detail="需要登录") # noqa: S105
|
||||
|
||||
|
||||
def _cleanup_apply_enabled() -> bool:
|
||||
return os.environ.get("CLEANUP_APPLY_ENABLED", "false").strip().lower() == "true"
|
||||
|
||||
|
||||
def _run_scheduled_cleanup_once(now: datetime | None = None) -> CleanupReport:
|
||||
run_at = now or datetime.now(timezone.utc)
|
||||
apply_enabled = _cleanup_apply_enabled()
|
||||
report = cleanup_service.apply(run_at) if apply_enabled else cleanup_service.preview(run_at)
|
||||
log.info(
|
||||
"cleanup cycle apply=%s temporary_jobs=%d pending_products=%d reclaimable_bytes=%d "
|
||||
"reminder_job_ids=%s deleted_job_ids=%s deleted_product_ids=%s",
|
||||
apply_enabled,
|
||||
len(report.temporary_jobs),
|
||||
len(report.pending_products),
|
||||
report.reclaimable_bytes,
|
||||
report.reminder_job_ids,
|
||||
report.deleted_job_ids,
|
||||
report.deleted_product_ids,
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
_cleanup_scheduler_stop = threading.Event()
|
||||
_cleanup_scheduler_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def _cleanup_scheduler_loop() -> None:
|
||||
while not _cleanup_scheduler_stop.wait(24 * 60 * 60):
|
||||
try:
|
||||
_run_scheduled_cleanup_once()
|
||||
except Exception:
|
||||
log.exception("scheduled cleanup cycle failed")
|
||||
|
||||
|
||||
def _start_cleanup_scheduler() -> None:
|
||||
global _cleanup_scheduler_thread
|
||||
_run_scheduled_cleanup_once()
|
||||
if _cleanup_scheduler_thread is not None and _cleanup_scheduler_thread.is_alive():
|
||||
return
|
||||
_cleanup_scheduler_stop.clear()
|
||||
_cleanup_scheduler_thread = threading.Thread(
|
||||
target=_cleanup_scheduler_loop,
|
||||
name="retention-cleanup",
|
||||
daemon=True,
|
||||
)
|
||||
_cleanup_scheduler_thread.start()
|
||||
|
||||
|
||||
def _stop_cleanup_scheduler() -> None:
|
||||
global _cleanup_scheduler_thread
|
||||
_cleanup_scheduler_stop.set()
|
||||
if _cleanup_scheduler_thread is not None:
|
||||
_cleanup_scheduler_thread.join(timeout=1)
|
||||
_cleanup_scheduler_thread = None
|
||||
|
||||
|
||||
@app.get("/api/maintenance/cleanup-candidates", response_model=CleanupReport)
|
||||
def cleanup_candidates(request: Request) -> CleanupReport:
|
||||
_require_orders_auth(request)
|
||||
return cleanup_service.preview(datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@app.post("/api/maintenance/cleanup-run", response_model=CleanupReport)
|
||||
def cleanup_run(request: Request, payload: dict) -> CleanupReport:
|
||||
_require_orders_auth(request)
|
||||
if payload.get("confirm") is not True:
|
||||
raise HTTPException(status_code=400, detail="confirm must be true")
|
||||
if not _cleanup_apply_enabled():
|
||||
raise HTTPException(status_code=409, detail="cleanup apply is disabled")
|
||||
return cleanup_service.apply(datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@app.post("/api/login")
|
||||
async def login(request: Request) -> dict:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user