feat(admin): prune_low_confidence_predictions backfill task + UI (#764)
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<ThumbnailBackfillCard />
|
||||
</div>
|
||||
<MLThresholdSliders class="mt-4" />
|
||||
<PrunePredictionsCard class="mt-4" />
|
||||
<AllowlistTable class="mt-4" />
|
||||
<AliasTable class="mt-4" />
|
||||
<DbMaintenanceCard class="mt-6" />
|
||||
@@ -31,6 +32,7 @@ import MLBackfillCard from './MLBackfillCard.vue'
|
||||
import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
|
||||
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||
import PrunePredictionsCard from './PrunePredictionsCard.vue'
|
||||
import AllowlistTable from './AllowlistTable.vue'
|
||||
import AliasTable from './AliasTable.vue'
|
||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<!-- #764: drop stored tagger predictions below the store floor to shrink
|
||||
image_record's TOAST (the sub-0.70 score tail had grown it to ~100 GB). -->
|
||||
<v-card>
|
||||
<v-card-title>Prune low-confidence predictions</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">
|
||||
Removes stored tagger predictions below the current store floor
|
||||
(<strong>{{ floorPct }}</strong>) from every image, and clamps any
|
||||
allowlist threshold below the floor up to it. This is what shrinks the
|
||||
database — the low-confidence tail was the bulk of its size. Idempotent
|
||||
and resumable; safe to run more than once. Afterward, reclaim the freed
|
||||
space with <code>VACUUM FULL</code> / <code>pg_repack</code> on
|
||||
<code>image_record</code>.
|
||||
</p>
|
||||
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
|
||||
<v-icon start>mdi-database-minus-outline</v-icon> Prune predictions now
|
||||
</v-btn>
|
||||
<span v-if="queued" class="ml-3 text-caption text-success">Queued ✓</span>
|
||||
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance (long)" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
import QueueStatusBar from './QueueStatusBar.vue'
|
||||
|
||||
const api = useApi()
|
||||
const ml = useMLStore()
|
||||
const busy = ref(false)
|
||||
const queued = ref(false)
|
||||
|
||||
const floorPct = computed(() => {
|
||||
const f = ml.settings?.tagger_store_floor
|
||||
return f == null ? '—' : `${Math.round(f * 100)}%`
|
||||
})
|
||||
|
||||
onMounted(() => { if (!ml.settings) ml.loadSettings() })
|
||||
|
||||
async function run () {
|
||||
busy.value = true
|
||||
queued.value = false
|
||||
try {
|
||||
await api.post('/api/admin/maintenance/prune-predictions')
|
||||
queued.value = true
|
||||
toast({ text: 'Prediction prune queued', type: 'success' })
|
||||
} catch (e) {
|
||||
toast({ text: e?.body?.detail || e?.message || 'Failed to queue', type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -42,6 +42,68 @@ def test_bulk_delete_images_task_registered():
|
||||
)
|
||||
|
||||
|
||||
def test_prune_low_confidence_predictions_task_registered():
|
||||
assert (
|
||||
"backend.app.tasks.admin.prune_low_confidence_predictions_task"
|
||||
in celery.tasks
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prune_low_confidence_predictions(db_sync, tmp_path):
|
||||
# #764: drop stored tagger predictions below the store floor (default
|
||||
# 0.70) and clamp allowlist thresholds up to it.
|
||||
from backend.app.models import Tag, TagAllowlist, TagKind
|
||||
from backend.app.tasks.admin import prune_low_confidence_predictions_task
|
||||
|
||||
f0 = tmp_path / "p0.jpg"
|
||||
f0.write_bytes(b"x")
|
||||
img0 = ImageRecord(
|
||||
path=str(f0), sha256=f"{0:064x}", size_bytes=10, mime="image/jpeg",
|
||||
origin="imported_filesystem",
|
||||
tagger_predictions={
|
||||
"keep_high": {"category": "general", "confidence": 0.92},
|
||||
"keep_edge": {"category": "general", "confidence": 0.70},
|
||||
"drop_mid": {"category": "general", "confidence": 0.40},
|
||||
"drop_tiny": {"category": "general", "confidence": 0.06},
|
||||
},
|
||||
)
|
||||
db_sync.add(img0)
|
||||
f1 = tmp_path / "p1.jpg"
|
||||
f1.write_bytes(b"x")
|
||||
img1 = ImageRecord(
|
||||
path=str(f1), sha256=f"{1:064x}", size_bytes=10, mime="image/jpeg",
|
||||
origin="imported_filesystem",
|
||||
tagger_predictions={"only": {"category": "general", "confidence": 0.99}},
|
||||
)
|
||||
db_sync.add(img1)
|
||||
tag = Tag(name="lowthr-tag", kind=TagKind.general)
|
||||
db_sync.add(tag)
|
||||
db_sync.flush()
|
||||
db_sync.add(TagAllowlist(tag_id=tag.id, min_confidence=0.30))
|
||||
db_sync.commit()
|
||||
img0_id, img1_id, tag_id = img0.id, img1.id, tag.id
|
||||
|
||||
result = prune_low_confidence_predictions_task.delay().get()
|
||||
assert result["floor"] == pytest.approx(0.70)
|
||||
assert result["pruned"] == 1 # only img0 had sub-floor entries
|
||||
assert result["allowlist_clamped"] == 1
|
||||
|
||||
db_sync.expire_all()
|
||||
p0 = db_sync.execute(
|
||||
select(ImageRecord.tagger_predictions).where(ImageRecord.id == img0_id)
|
||||
).scalar_one()
|
||||
assert set(p0) == {"keep_high", "keep_edge"} # >=0.70 kept, <0.70 dropped
|
||||
p1 = db_sync.execute(
|
||||
select(ImageRecord.tagger_predictions).where(ImageRecord.id == img1_id)
|
||||
).scalar_one()
|
||||
assert set(p1) == {"only"} # already clean — untouched
|
||||
clamped = db_sync.execute(
|
||||
select(TagAllowlist.min_confidence).where(TagAllowlist.tag_id == tag_id)
|
||||
).scalar_one()
|
||||
assert clamped == pytest.approx(0.70)
|
||||
|
||||
|
||||
# --- delete_artist_cascade_task -------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user