fix: reconcile and expose failed product cleanup

This commit is contained in:
2026-09-12 15:24:41 +08:00
parent bd78eef161
commit 21ad6fbf8d
6 changed files with 176 additions and 6 deletions
+57
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import os
import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -254,6 +255,62 @@ def test_failed_cleanup_product_can_be_restored_or_requeued(tmp_path, monkeypatc
]
def test_stale_purging_claim_is_reconciled_to_failed_cleanup(tmp_path, monkeypatch):
service = make_cleanup_service(tmp_path)
product = create_pending_product(service, purge_after=NOW)
store = service.product_store
original_fail = store.fail_product_purge
calls = {"n": 0}
def flaky_fail(product_id, now=None):
calls["n"] += 1
if calls["n"] == 1:
raise sqlite3.OperationalError("simulated busy")
return original_fail(product_id, now=now)
monkeypatch.setattr(store, "fail_product_purge", flaky_fail)
monkeypatch.setattr(
"service.cleanup_service.shutil.rmtree",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk failure")),
)
with pytest.raises(sqlite3.OperationalError, match="simulated busy"):
service.apply(now=NOW)
stranded = store._fetchone(
"SELECT status, claimed_at FROM cleanup_records WHERE product_id = ?",
(product.product_id,),
)
assert stranded["status"] == "purging"
assert datetime.fromisoformat(stranded["claimed_at"]) == NOW
report = service.preview(now=NOW + timedelta(hours=2))
assert [item.product_id for item in report.failed_products] == [product.product_id]
assert store.get_product(product.product_id).status == "failed_cleanup"
assert store.due_product_cleanups(NOW + timedelta(days=1)) == []
def test_cleanup_candidates_reports_failed_products(tmp_path, monkeypatch):
service = make_cleanup_service(tmp_path)
product = create_pending_product(service, purge_after=NOW)
store = service.product_store
store.claim_product_purge(product.product_id, NOW)
store.fail_product_purge(product.product_id, now=NOW)
monkeypatch.setattr(service_app, "cleanup_service", service, raising=False)
client = TestClient(service_app.app)
response = client.get("/api/maintenance/cleanup-candidates", headers=auth_header())
assert response.status_code == 200
data = response.json()
assert [item["product_id"] for item in data["failed_products"]] == [
product.product_id
]
assert data["pending_products"] == []
def test_apply_rechecks_archive_reference_immediately_before_job_deletion(tmp_path):
service = make_cleanup_service(tmp_path)
due_job = create_success_job(service, age_days=31)
+29
View File
@@ -356,6 +356,35 @@ def test_archive_version_rejects_failed_cleanup_product_and_status_is_visible(
assert restored["status"] == "active"
def test_delete_rejects_failed_cleanup_product_until_restore(product_archive_client):
product = create_product(product_archive_client)
deleted = product_archive_client.delete(
f"/api/products/{product['product_id']}", headers=orders_auth_header()
).json()
store = product_archive_client.product_store
purge_after = datetime.fromisoformat(deleted["purge_after"])
store.claim_product_purge(deleted["product_id"], purge_after)
store.fail_product_purge(deleted["product_id"], now=purge_after)
rejected = product_archive_client.delete(
f"/api/products/{deleted['product_id']}", headers=orders_auth_header()
)
assert rejected.status_code == 409
assert store.get_product(deleted["product_id"]).status == "failed_cleanup"
restored = product_archive_client.post(
f"/api/products/{deleted['product_id']}/restore", headers=orders_auth_header()
)
assert restored.status_code == 200
assert restored.json()["status"] == "active"
soft_deleted = product_archive_client.delete(
f"/api/products/{deleted['product_id']}", headers=orders_auth_header()
)
assert soft_deleted.status_code == 200
assert soft_deleted.json()["status"] == "pending_cleanup"
def test_archive_failure_during_final_move_removes_staging_files(product_archive_client, prepared_wordcloud_job, monkeypatch):
product = create_product(product_archive_client)
service = service_app._product_archive_service()
+28 -2
View File
@@ -9,7 +9,10 @@ BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from service.product_archive_store import ProductArchiveStore # noqa: E402
from service.product_archive_store import ( # noqa: E402
ProductArchiveStore,
ProductPurgeInProgressError,
)
from service.schemas import ProductInput # noqa: E402
@@ -121,10 +124,11 @@ def test_fail_product_purge_requires_an_active_claim_and_preserves_purge_after(t
due = NOW + timedelta(days=30)
store.claim_product_purge(product.product_id, due)
failed = store.fail_product_purge(product.product_id)
failed = store.fail_product_purge(product.product_id, now=due + timedelta(hours=1))
assert failed.status == "failed_cleanup"
assert failed.purge_after == due
assert failed.updated_at == due + timedelta(hours=1)
cleanup = store._fetchone(
"SELECT status FROM cleanup_records WHERE product_id = ?", (product.product_id,)
)["status"]
@@ -134,6 +138,28 @@ def test_fail_product_purge_requires_an_active_claim_and_preserves_purge_after(t
store.purge_product(product.product_id, now=due + timedelta(days=1))
def test_reconcile_stalled_purges_quarantines_only_claims_older_than_timeout(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
stale_product = store.upsert_product(ProductInput("manual", None, "卡住清理笔盒"))
fresh_product = store.upsert_product(ProductInput("manual", None, "正常清理笔盒"))
for product in (stale_product, fresh_product):
store.mark_pending_cleanup(product.product_id, now=NOW)
store.claim_product_purge(product.product_id, NOW + timedelta(days=30))
old_claim = (NOW - timedelta(hours=2)).isoformat()
store._execute(
"UPDATE cleanup_records SET claimed_at = ? WHERE product_id = ? AND status = 'purging'",
(old_claim, stale_product.product_id),
)
failed = store.reconcile_stalled_purges(now=NOW, timeout=timedelta(hours=1))
assert [item.product_id for item in failed] == [stale_product.product_id]
assert store.get_product(stale_product.product_id).status == "failed_cleanup"
assert store.get_product(fresh_product.product_id).status == "pending_cleanup"
with pytest.raises(ProductPurgeInProgressError):
store.restore_product(fresh_product.product_id, now=NOW + timedelta(seconds=1))
def test_mark_pending_cleanup_accepts_failed_product_and_rejects_purged(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
product = store.upsert_product(ProductInput("manual", None, "重新排队笔盒"))