fix: quarantine failed product cleanups in failed_cleanup state

This commit is contained in:
2026-09-12 15:00:33 +08:00
parent b3ab1bfc52
commit bd78eef161
7 changed files with 149 additions and 19 deletions
+3 -1
View File
@@ -126,7 +126,9 @@ class CleanupService:
if product_dir.exists():
shutil.rmtree(product_dir)
except Exception:
self.product_store.release_product_purge(product.product_id)
# rmtree may have removed only part of the archive. Quarantine
# the record for manual inspection instead of retrying it.
self.product_store.fail_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)
+2 -2
View File
@@ -21,7 +21,7 @@ from .schemas import ProductVersionRecord
class ProductPendingCleanupError(ValueError):
"""Raised when a soft-deleted product is changed before restoration."""
"""Raised when a pending or failed-cleanup product is changed before recovery."""
class ProductArchiveService:
@@ -83,7 +83,7 @@ class ProductArchiveService:
if not isinstance(document, dict):
raise ValueError("document must be a JSON object")
product = self.store.get_product(product_id)
if product.status == "pending_cleanup":
if product.status in ("pending_cleanup", "failed_cleanup"):
raise ProductPendingCleanupError("product is pending cleanup")
if product.status != "active":
raise ValueError("product is not active")
+28 -10
View File
@@ -318,7 +318,9 @@ class ProductArchiveStore:
raise ValueError(f"unknown version_id for product: {version_id}")
def mark_pending_cleanup(self, product_id: str, now: datetime) -> ProductRecord:
self.get_product(product_id)
product = self.get_product(product_id)
if product.status not in ("active", "failed_cleanup"):
raise ValueError("product cannot be marked pending cleanup")
purge_after = now + timedelta(days=30)
self._execute(
"UPDATE products SET status = ?, purge_after = ?, updated_at = ? WHERE product_id = ?",
@@ -355,8 +357,8 @@ class ProductArchiveStore:
)
conn.execute(
"""UPDATE cleanup_records SET status = ?, completed_at = ?
WHERE product_id = ? AND status = ?""",
("restored", restored_at.isoformat(), product_id, "pending_cleanup"),
WHERE product_id = ? AND status IN ('pending_cleanup', 'failed_cleanup')""",
("restored", restored_at.isoformat(), product_id),
)
return self.get_product(product_id)
@@ -405,13 +407,29 @@ class ProductArchiveStore:
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 fail_product_purge(self, product_id: str) -> ProductRecord:
"""Quarantine a purge whose physical deletion failed mid-way."""
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")
updated = conn.execute(
"""UPDATE cleanup_records SET status = 'failed_cleanup'
WHERE product_id = ? AND status = 'purging'""",
(product_id,),
)
if updated.rowcount != 1:
raise ValueError("product purge is not claimed")
conn.execute(
"UPDATE products SET status = 'failed_cleanup', updated_at = ? WHERE product_id = ?",
(_now().isoformat(), product_id),
)
return self.get_product(product_id)
def finalize_product_purge(self, product_id: str, now: datetime) -> ProductRecord:
"""Atomically mark a claimed product purged after file removal."""
+1 -1
View File
@@ -43,7 +43,7 @@ class ProductRecord(BaseModel):
name: str
sku: str = ""
specification: str = ""
status: Literal["active", "pending_cleanup", "purged"] = "active"
status: Literal["active", "pending_cleanup", "failed_cleanup", "purged"] = "active"
created_at: datetime
updated_at: datetime
purge_after: datetime | None = None