fix: harden retention cleanup safety

This commit is contained in:
2026-09-12 14:12:17 +08:00
parent da5c2b4503
commit b3ab1bfc52
6 changed files with 259 additions and 55 deletions
+12 -6
View File
@@ -30,7 +30,7 @@ from .line_spacing import analyze_svg_line_spacing_file
from .log_config import get_logger from .log_config import get_logger
from .metadata_store import MetadataStore from .metadata_store import MetadataStore
from .product_archive_service import ProductArchiveService, ProductPendingCleanupError from .product_archive_service import ProductArchiveService, ProductPendingCleanupError
from .product_archive_store import ProductArchiveStore from .product_archive_store import ProductArchiveStore, ProductPurgeInProgressError
from .runner import JobRunner from .runner import JobRunner
from .schemas import ( from .schemas import (
Asset, Asset,
@@ -1073,10 +1073,12 @@ def _cleanup_scheduler_loop() -> None:
def _start_cleanup_scheduler() -> None: def _start_cleanup_scheduler() -> None:
global _cleanup_scheduler_thread global _cleanup_scheduler_thread
_run_scheduled_cleanup_once() if _cleanup_scheduler_thread is not None:
if _cleanup_scheduler_thread is not None and _cleanup_scheduler_thread.is_alive(): if _cleanup_scheduler_thread.is_alive():
return return
_cleanup_scheduler_thread = None
_cleanup_scheduler_stop.clear() _cleanup_scheduler_stop.clear()
_run_scheduled_cleanup_once()
_cleanup_scheduler_thread = threading.Thread( _cleanup_scheduler_thread = threading.Thread(
target=_cleanup_scheduler_loop, target=_cleanup_scheduler_loop,
name="retention-cleanup", name="retention-cleanup",
@@ -1088,8 +1090,10 @@ def _start_cleanup_scheduler() -> None:
def _stop_cleanup_scheduler() -> None: def _stop_cleanup_scheduler() -> None:
global _cleanup_scheduler_thread global _cleanup_scheduler_thread
_cleanup_scheduler_stop.set() _cleanup_scheduler_stop.set()
if _cleanup_scheduler_thread is not None: worker = _cleanup_scheduler_thread
_cleanup_scheduler_thread.join(timeout=1) if worker is not None:
worker.join(timeout=5)
if worker is not None and not worker.is_alive() and _cleanup_scheduler_thread is worker:
_cleanup_scheduler_thread = None _cleanup_scheduler_thread = None
@@ -1240,6 +1244,8 @@ def restore_product(request: Request, product_id: str) -> ProductRecord:
_get_product_or_404(product_id) _get_product_or_404(product_id)
try: try:
return product_archive_store.restore_product(product_id, now=datetime.now(timezone.utc)) return product_archive_store.restore_product(product_id, now=datetime.now(timezone.utc))
except ProductPurgeInProgressError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
+13 -13
View File
@@ -117,25 +117,25 @@ class CleanupService:
report.deleted_job_ids.append(item.job_id) report.deleted_job_ids.append(item.job_id)
for candidate in report.pending_products: for candidate in report.pending_products:
# Re-read logical state so a concurrent restore wins over cleanup. product = self.product_store.claim_product_purge(candidate.product_id, now_utc)
try: if product is None:
product = self.product_store.get_product(candidate.product_id)
except ValueError:
continue
if (
product.status != "pending_cleanup"
or product.purge_after is None
or _as_utc(product.purge_after) > now_utc
):
continue continue
product_dir = self.product_store.root.parent / product.product_id product_dir = self.product_store.root.parent / product.product_id
if product_dir.exists(): try:
shutil.rmtree(product_dir) self._before_product_files_delete(product.product_id)
self.product_store.purge_product(product.product_id, now=now_utc) if product_dir.exists():
shutil.rmtree(product_dir)
except Exception:
self.product_store.release_product_purge(product.product_id)
raise
self.product_store.finalize_product_purge(product.product_id, now=now_utc)
report.deleted_product_ids.append(product.product_id) report.deleted_product_ids.append(product.product_id)
return report return report
def _before_product_files_delete(self, product_id: str) -> None:
"""Interleaving seam between the durable claim and physical deletion."""
@staticmethod @staticmethod
def _directory_size(root: Path) -> int: def _directory_size(root: Path) -> int:
if not root.exists(): if not root.exists():
+100 -28
View File
@@ -23,6 +23,10 @@ def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
class ProductPurgeInProgressError(ValueError):
"""Raised when restore loses the atomic race to physical purge."""
class ProductArchiveStore: class ProductArchiveStore:
"""Owns durable product metadata and logical cleanup state.""" """Owns durable product metadata and logical cleanup state."""
@@ -328,43 +332,111 @@ class ProductArchiveStore:
return self.get_product(product_id) return self.get_product(product_id)
def restore_product(self, product_id: str, now: datetime | None = None) -> ProductRecord: def restore_product(self, product_id: str, now: datetime | None = None) -> ProductRecord:
self.get_product(product_id)
restored_at = now or _now() restored_at = now or _now()
self._execute( with self._lock, self._connect() as conn:
"UPDATE products SET status = ?, purge_after = NULL, updated_at = ? WHERE product_id = ?", conn.execute("BEGIN IMMEDIATE")
("active", restored_at.isoformat(), product_id), product = conn.execute(
) "SELECT product_id, status FROM products WHERE product_id = ?", (product_id,)
self._execute( ).fetchone()
"""UPDATE cleanup_records SET status = ?, completed_at = ? if product is None:
WHERE product_id = ? AND status = ?""", raise ValueError(f"unknown product_id: {product_id}")
("restored", restored_at.isoformat(), product_id, "pending_cleanup"), if product["status"] == "purged":
) raise ValueError("purged product cannot be restored")
purging = conn.execute(
"""SELECT 1 FROM cleanup_records
WHERE product_id = ? AND status = 'purging' LIMIT 1""",
(product_id,),
).fetchone()
if purging is not None:
raise ProductPurgeInProgressError("product is purging")
conn.execute(
"UPDATE products SET status = ?, purge_after = NULL, updated_at = ? WHERE product_id = ?",
("active", restored_at.isoformat(), product_id),
)
conn.execute(
"""UPDATE cleanup_records SET status = ?, completed_at = ?
WHERE product_id = ? AND status = ?""",
("restored", restored_at.isoformat(), product_id, "pending_cleanup"),
)
return self.get_product(product_id) return self.get_product(product_id)
def due_product_cleanups(self, now: datetime) -> list[ProductRecord]: def due_product_cleanups(self, now: datetime) -> list[ProductRecord]:
rows = self._fetchall( rows = self._fetchall(
"""SELECT * FROM products WHERE status = ? AND purge_after IS NOT NULL AND purge_after <= ? """SELECT products.* FROM products
ORDER BY purge_after, product_id""", WHERE products.status = ?
AND products.purge_after IS NOT NULL
AND products.purge_after <= ?
AND EXISTS (
SELECT 1 FROM cleanup_records
WHERE cleanup_records.product_id = products.product_id
AND cleanup_records.status = 'pending_cleanup'
)
ORDER BY products.purge_after, products.product_id""",
("pending_cleanup", now.isoformat()), ("pending_cleanup", now.isoformat()),
) )
return [self._product_from_row(row) for row in rows] return [self._product_from_row(row) for row in rows]
def claim_product_purge(self, product_id: str, now: datetime) -> ProductRecord | None:
"""Atomically win the right to delete a due product's files."""
with self._lock, self._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT * FROM products WHERE product_id = ?", (product_id,)
).fetchone()
if row is None:
return None
purge_after = datetime.fromisoformat(row["purge_after"]) if row["purge_after"] else None
if row["status"] != "pending_cleanup" or purge_after is None or purge_after > now:
return None
cleanup = conn.execute(
"""SELECT cleanup_id FROM cleanup_records
WHERE product_id = ? AND status = 'pending_cleanup'
ORDER BY created_at DESC, cleanup_id DESC LIMIT 1""",
(product_id,),
).fetchone()
if cleanup is None:
return None
updated = conn.execute(
"""UPDATE cleanup_records SET status = 'purging'
WHERE cleanup_id = ? AND status = 'pending_cleanup'""",
(cleanup["cleanup_id"],),
)
if updated.rowcount != 1:
return None
return self._product_from_row(row)
def release_product_purge(self, product_id: str) -> None:
"""Return a failed physical deletion claim to its pending state."""
self._execute(
"""UPDATE cleanup_records SET status = 'pending_cleanup'
WHERE product_id = ? AND status = 'purging'""",
(product_id,),
)
def finalize_product_purge(self, product_id: str, now: datetime) -> ProductRecord:
"""Atomically mark a claimed product purged after file removal."""
with self._lock, self._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
claim = conn.execute(
"""SELECT 1 FROM cleanup_records
WHERE product_id = ? AND status = 'purging' LIMIT 1""",
(product_id,),
).fetchone()
if claim is None:
raise ValueError("product purge is not claimed")
conn.execute(
"UPDATE products SET status = ?, updated_at = ? WHERE product_id = ?",
("purged", now.isoformat(), product_id),
)
conn.execute(
"""UPDATE cleanup_records SET status = ?, completed_at = ?
WHERE product_id = ? AND status = 'purging'""",
("purged", now.isoformat(), product_id),
)
return self.get_product(product_id)
def purge_product(self, product_id: str, now: datetime | None = None) -> ProductRecord: def purge_product(self, product_id: str, now: datetime | None = None) -> ProductRecord:
purged_at = now or _now() purged_at = now or _now()
product = self.get_product(product_id) if self.claim_product_purge(product_id, purged_at) is None:
if (
product.status != "pending_cleanup"
or product.purge_after is None
or product.purge_after > purged_at
):
raise ValueError("product is not due for purge") raise ValueError("product is not due for purge")
self._execute( return self.finalize_product_purge(product_id, purged_at)
"UPDATE products SET status = ?, updated_at = ? WHERE product_id = ?",
("purged", purged_at.isoformat(), product_id),
)
self._execute(
"""UPDATE cleanup_records SET status = ?, completed_at = ?
WHERE product_id = ? AND status = ?""",
("purged", purged_at.isoformat(), product_id, "pending_cleanup"),
)
return self.get_product(product_id)
+13 -8
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Inspect and optionally clean stale job directories. """Inspect retention candidates and optionally clean verified managed jobs.
Default mode is a safe dry-run that reports reclaimable bytes. Pass --apply to Default mode is a safe dry-run. Metadata-less workspaces are reported for
actually remove unreferenced job directories. manual investigation but are never removed by ``--apply``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -46,7 +46,11 @@ def referenced_job_ids() -> set[str]:
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--max-age-days", type=float, default=30) 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(
"--apply",
action="store_true",
help="Delete only CleanupService-verified candidates; orphan workspaces remain report-only",
)
parser.add_argument("--json", type=Path, default=None, help="Write JSON report") parser.add_argument("--json", type=Path, default=None, help="Write JSON report")
args = parser.parse_args() args = parser.parse_args()
@@ -89,10 +93,11 @@ def main() -> None:
if os.environ.get("CLEANUP_APPLY_ENABLED", "false").strip().lower() != "true": if os.environ.get("CLEANUP_APPLY_ENABLED", "false").strip().lower() != "true":
parser.error("--apply requires CLEANUP_APPLY_ENABLED=true") parser.error("--apply requires CLEANUP_APPLY_ENABLED=true")
applied = cleanup.apply(datetime.now(timezone.utc)) applied = cleanup.apply(datetime.now(timezone.utc))
freed = 0 freed = sum(
for item in orphaned: item.size_bytes
freed += storage.remove_job_dir(item["job_id"]) for item in applied.temporary_jobs
store.delete_job(item["job_id"]) if item.job_id in applied.deleted_job_ids
)
print( print(
"freed_bytes=" "freed_bytes="
f"{freed} deleted_job_ids={applied.deleted_job_ids} " f"{freed} deleted_job_ids={applied.deleted_job_ids} "
+109
View File
@@ -6,6 +6,7 @@ import sys
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -173,6 +174,48 @@ def test_restored_product_is_not_deleted_when_original_purge_time_arrives(tmp_pa
assert service.product_store.get_product(product.product_id).status == "active" assert service.product_store.get_product(product.product_id).status == "active"
def test_atomic_purge_claim_blocks_restore_before_product_files_are_deleted(
tmp_path, monkeypatch
):
service = make_cleanup_service(tmp_path)
product = create_pending_product(service, purge_after=NOW)
restore_conflicts: list[str] = []
def attempt_restore(product_id: str) -> None:
with pytest.raises(ValueError, match="purging"):
service.product_store.restore_product(product_id, now=NOW)
restore_conflicts.append(product_id)
monkeypatch.setattr(
service, "_before_product_files_delete", attempt_restore, raising=False
)
report = service.apply(now=NOW)
assert restore_conflicts == [product.product_id]
assert report.deleted_product_ids == [product.product_id]
assert service.product_store.get_product(product.product_id).status == "purged"
def test_failed_product_file_deletion_releases_claim_back_to_pending(tmp_path, monkeypatch):
service = make_cleanup_service(tmp_path)
product = create_pending_product(service, purge_after=NOW)
monkeypatch.setattr(
"service.cleanup_service.shutil.rmtree",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk failure")),
)
with pytest.raises(OSError, match="disk failure"):
service.apply(now=NOW)
assert service.product_store.get_product(product.product_id).status == "pending_cleanup"
assert service.product_store._fetchone(
"SELECT status FROM cleanup_records WHERE product_id = ?", (product.product_id,)
)["status"] == "pending_cleanup"
assert service.product_store.due_product_cleanups(NOW)[0].product_id == product.product_id
def test_apply_rechecks_archive_reference_immediately_before_job_deletion(tmp_path): def test_apply_rechecks_archive_reference_immediately_before_job_deletion(tmp_path):
service = make_cleanup_service(tmp_path) service = make_cleanup_service(tmp_path)
due_job = create_success_job(service, age_days=31) due_job = create_success_job(service, age_days=31)
@@ -241,6 +284,31 @@ def test_scheduled_cleanup_is_preview_only_until_apply_flag_is_enabled(tmp_path,
assert not service.storage.job_root(second_job).exists() assert not service.storage.job_root(second_job).exists()
def test_scheduler_stop_retains_live_worker_and_restart_does_not_duplicate_cycle(monkeypatch):
class LiveThread:
def __init__(self):
self.join_calls: list[float | None] = []
def is_alive(self) -> bool:
return True
def join(self, timeout=None) -> None:
self.join_calls.append(timeout)
worker = LiveThread()
cycles: list[str] = []
monkeypatch.setattr(service_app, "_cleanup_scheduler_thread", worker)
monkeypatch.setattr(service_app, "_run_scheduled_cleanup_once", lambda: cycles.append("run"))
service_app._stop_cleanup_scheduler()
service_app._start_cleanup_scheduler()
assert service_app._cleanup_scheduler_stop.is_set()
assert service_app._cleanup_scheduler_thread is worker
assert worker.join_calls
assert cycles == []
def test_storage_summary_separates_archive_protection_from_asset_references(tmp_path, monkeypatch): def test_storage_summary_separates_archive_protection_from_asset_references(tmp_path, monkeypatch):
service = make_cleanup_service(tmp_path) service = make_cleanup_service(tmp_path)
archived_job = create_success_job(service, age_days=3650, archived=True) archived_job = create_success_job(service, age_days=3650, archived=True)
@@ -313,3 +381,44 @@ def test_storage_metrics_protects_archives_but_not_old_asset_references(
assert report["archive_protected_job_ids"] == [archived_job] assert report["archive_protected_job_ids"] == [archived_job]
assert report["asset_referenced_job_ids"] == [asset_job] assert report["asset_referenced_job_ids"] == [asset_job]
assert [item["job_id"] for item in report["stale_jobs"]] == [asset_job] assert [item["job_id"] for item in report["stale_jobs"]] == [asset_job]
def test_storage_metrics_apply_never_deletes_unproven_or_failed_workspaces(
tmp_path, monkeypatch
):
workspace = tmp_path / "workspace"
metadata = tmp_path / "metadata"
storage = Storage(workspace)
metadata_store = MetadataStore(metadata / "app.db")
orphan_job = "c" * 32
failed_job = "d" * 32
for job_id in (orphan_job, failed_job):
root = storage.job_root(job_id)
root.mkdir(parents=True)
(root / "payload.bin").write_bytes(b"payload")
old_timestamp = (datetime.now(timezone.utc) - timedelta(days=31)).timestamp()
os.utime(root, (old_timestamp, old_timestamp))
metadata_store.upsert_job(
JobStatus(
job_id=failed_job,
status="failed",
stage="failed",
progress_percent=100,
message="failed",
artifacts={"db": str(storage.job_root(failed_job) / "output" / "db.sqlite")},
error="generation failed",
created_at=datetime.now(timezone.utc) - timedelta(days=31),
updated_at=datetime.now(timezone.utc) - timedelta(days=31),
)
)
monkeypatch.setattr(storage_metrics, "WORKSPACE_DIR", workspace)
monkeypatch.setattr(storage_metrics, "ASSETS_DIR", tmp_path / "assets")
monkeypatch.setattr(storage_metrics, "METADATA_DIR", metadata)
monkeypatch.setattr(storage_metrics, "PRODUCTS_DIR", tmp_path / "products")
monkeypatch.setattr(sys, "argv", ["storage_metrics", "--apply"])
monkeypatch.setenv("CLEANUP_APPLY_ENABLED", "true")
storage_metrics.main()
assert storage.job_root(orphan_job).exists()
assert storage.job_root(failed_job).exists()
@@ -99,6 +99,18 @@ def test_restore_removes_product_from_due_cleanup_selection(tmp_path):
assert store.due_product_cleanups(now=NOW + timedelta(days=31)) == [] assert store.due_product_cleanups(now=NOW + timedelta(days=31)) == []
def test_restore_rejects_product_after_purge_has_deleted_its_files(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
product = store.upsert_product(ProductInput("manual", None, "已清理笔盒"))
store.mark_pending_cleanup(product.product_id, now=NOW)
store.purge_product(product.product_id, now=NOW + timedelta(days=30))
with pytest.raises(ValueError, match="purged"):
store.restore_product(product.product_id, now=NOW + timedelta(days=31))
assert store.get_product(product.product_id).status == "purged"
def test_version_bound_operations_reject_unknown_or_mismatched_versions(tmp_path): def test_version_bound_operations_reject_unknown_or_mismatched_versions(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products") store = ProductArchiveStore(tmp_path / "service_products")
first = store.upsert_product(ProductInput("manual", None, "第一个笔盒")) first = store.upsert_product(ProductInput("manual", None, "第一个笔盒"))