feat(admin): prune_low_confidence_predictions backfill task + UI (#764)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m13s

The one-time backfill that actually shrinks the DB: drops stored
tagger_predictions entries below ml_settings.tagger_store_floor from every
image_record row, and clamps any allowlist min_confidence below the floor up
to it. Keep predicate (confidence >= floor) mirrors Tagger.infer's store gate
so backfilled rows match new imports. Keyset by id ASC, idempotent,
self-resumes on the soft time limit; runs on the maintenance_long lane.

pg_dump copies live data only, so this alone fixes the #739 backup timeout —
the reclaim (VACUUM FULL / pg_repack on image_record) is a separate, optional
disk-return step, brief because post-prune the live data is tiny.

- admin.prune_low_confidence_predictions_task + POST /api/admin/maintenance/prune-predictions
- PrunePredictionsCard in the Maintenance panel (shows the current floor)
- tests: registration + prune-keeps->=floor/drops-<floor + allowlist clamp

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 13:57:39 -04:00
parent c8b815afe6
commit d55e52ae9b
5 changed files with 230 additions and 0 deletions
+12
View File
@@ -348,3 +348,15 @@ async def trigger_reextract_archives():
async_result = reextract_archive_attachments_task.delay()
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
@admin_bp.route("/maintenance/prune-predictions", methods=["POST"])
async def trigger_prune_predictions():
"""Operator-triggered #764 backfill: drop stored tagger predictions below
the current ml_settings.tagger_store_floor and clamp allowlist thresholds
up to it. Shrinks image_record's TOAST (~100 GB of sub-0.70 scores).
Idempotent + self-resuming; runs on the maintenance_long lane."""
from ..tasks.admin import prune_low_confidence_predictions_task
async_result = prune_low_confidence_predictions_task.delay()
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
+96
View File
@@ -207,3 +207,99 @@ def rescan_series_suggestions_task(self, after_post_id: int = 0) -> dict:
)
rescan_series_suggestions_task.delay(summary["resume_after_id"])
return summary
@celery.task(
name="backend.app.tasks.admin.prune_low_confidence_predictions_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=3600, time_limit=4200, # 60 min / 70 min
)
def prune_low_confidence_predictions_task(self, after_id: int = 0) -> dict:
"""One-time #764 backfill: drop tagger_predictions entries below the DB
store floor (ml_settings.tagger_store_floor) from existing image_record
rows, and clamp any allowlist min_confidence below the floor up to it.
The Camie tagger emits ~10k tags; the old 0.05 floor stored the entire
near-zero tail, bloating image_record's TOAST to ~100 GB. This rewrites
each row to the new floor. Keyset by id ASC (restart-safe via after_id);
idempotent — already-pruned rows rewrite to themselves and are skipped.
Rewriting rows generates bloat, so run VACUUM FULL / pg_repack on
image_record afterward to return the disk to the OS.
The keep predicate (confidence >= floor) mirrors Tagger.infer's store
gate so backfilled rows match what new imports store. Self-resumes on the
soft time limit (re-enqueues from the last committed id)."""
from celery.exceptions import SoftTimeLimitExceeded
from sqlalchemy import select, update
from ..models import ImageRecord, MLSettings, TagAllowlist
SessionLocal = _sync_session_factory()
scanned = 0
pruned = 0
clamped = 0
last_id = after_id
try:
with SessionLocal() as session:
floor = session.execute(
select(MLSettings.tagger_store_floor).where(MLSettings.id == 1)
).scalar_one()
# Clamp allowlist thresholds below the new floor once, on the
# first pass (#764 consumer #4) — a sub-floor min_confidence can't
# apply more permissively now that nothing below it is stored.
if after_id == 0:
clamped = session.execute(
update(TagAllowlist)
.where(TagAllowlist.min_confidence < floor)
.values(min_confidence=floor)
).rowcount or 0
session.commit()
while True:
rows = session.execute(
select(ImageRecord.id, ImageRecord.tagger_predictions)
.where(ImageRecord.id > last_id)
.where(ImageRecord.tagger_predictions.is_not(None))
.order_by(ImageRecord.id.asc())
.limit(500)
).all()
if not rows:
break
for image_id, preds in rows:
scanned += 1
if not preds:
continue
kept = {
name: p for name, p in preds.items()
if float(p.get("confidence", 0.0)) >= floor
}
if len(kept) != len(preds):
session.execute(
update(ImageRecord)
.where(ImageRecord.id == image_id)
.values(tagger_predictions=kept)
)
pruned += 1
session.commit()
last_id = rows[-1].id # advance only after commit, for resume
except SoftTimeLimitExceeded:
log.warning(
"prune_low_confidence_predictions soft-limited at id=%s "
"(scanned=%d pruned=%d) — re-enqueuing", last_id, scanned, pruned,
)
prune_low_confidence_predictions_task.delay(last_id)
return {
"partial": True, "last_id": last_id,
"scanned": scanned, "pruned": pruned,
}
log.info(
"prune_low_confidence_predictions complete: floor=%s scanned=%d "
"pruned=%d allowlist_clamped=%d", floor, scanned, pruned, clamped,
)
return {
"floor": floor, "scanned": scanned, "pruned": pruned,
"allowlist_clamped": clamped, "last_id": last_id,
}