feat(cleanup): reclaim orphaned attachments — rows and store blobs (#3068)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m47s

PostAttachment's two FKs are both ON DELETE SET NULL, so a deleted post or
artist left the row behind rather than taking it. Nothing ever pruned those
rows, and nothing in the repo had ever unlinked a file under the attachment
store — so both rows and bytes accumulated permanently, invisible to every
existing diagnostic.

Why a disk->DB reconciliation rather than a row sweep: the store is
sha-addressed and idempotent, so ONE blob backs MANY rows. Deleting a row
does not free its blob, and since the artist cascade (#3066) now deletes
its attachment rows outright, a freed blob has no DB pointer left to find
it by. Walking the store and asking "does any row still reference this
sha?" catches orphans from every cause, including ones no future delete
path will think to report.

Preview and apply share `_orphan_attachment_conditions` (rule 93). The
dry-run derives its surviving-sha set by NEGATING that same predicate, so
it is honest about blobs the delete would free rather than counting them as
still-referenced — the one place this was easy to get backwards, so it has
its own parity test.

Guards, each with a reason:
- A blob is written before its row commits, so a just-stored file legitimately
  has no referencing row. Files under 6h are never judged — same guard and
  reasoning as ORPHAN_TEMP_MIN_AGE_HOURS.
- `.partial` staging files belong to cleanup_orphaned_temp_files; skipped
  rather than raced.
- The sha is parsed as the first 64 chars, not via Path.stem: store() takes
  the extension from the source filename, and a URL-encoded basename yields a
  multi-dot suffix that would make stem eat part of the sha.
- A 900s walk budget reports partial=True instead of running to the task's
  hard limit (rule 89).
- TASK_STUCK_THRESHOLD_MINUTES override at 30 (= time_limit 25 + 5). Without
  it a healthy 20-minute walk is phantom-flagged 'RecoverySweep' at the bare
  5-min default — the #883 failure class; its invariant test is mirrored here.

Defaults to the safe preview at both the task and the route, unlike the other
maintenance triggers: this apply unlinks files. Operator-triggered only,
never on a beat.

Ships with its UI (rule 27): AttachmentReclaimCard in Cleanup → Duplicates &
leftovers, built on the existing useMaintenanceTask/MaintenanceTile shapes, so
a run survives navigating away. Surfaces files_failed and partial explicitly,
since both change what the numbers mean.

Also promotes humanBytes to utils/bytes.js — it was byte-identical in
VideoDedupCard and GatedPurgeCard and this card would have been the third
copy. The three divergent `formatBytes` helpers are deliberately left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:37:24 -04:00
co-authored by Claude Opus 5
parent 2ce467e347
commit 2e0f8f8c61
12 changed files with 572 additions and 18 deletions
@@ -0,0 +1,127 @@
<template>
<!-- #3068: attachment reclamation. PostAttachment's FKs are both SET NULL, so
a deleted post or artist leaves the row behind; and the store is
sha-addressed, so one blob backs many rows and deleting a row never freed
its file. Nothing swept either. Preview first, then apply (destructive:
unlinks files). -->
<MaintenanceTile
icon="mdi-paperclip-off"
title="Reclaim orphaned attachments"
blurb="Remove attachment records belonging to nothing, and the files nothing references."
destructive
:open="applying || previewing"
>
<p class="text-body-2 mb-3">
Attachment records survive the post and artist they belonged to, and the
files behind them are shared between records — so a deleted record never
freed its file on its own. This finds records attributed to
<strong>neither</strong> a post nor an artist, and files in the attachment
store that <strong>no remaining record</strong> points at.
<strong>Preview</strong> first; <strong>Apply</strong> deletes those
records and unlinks those files. Files written in the last few hours are
always left alone, so an in-progress download is never caught mid-write.
</p>
<div class="d-flex align-center flex-wrap" style="gap: 12px;">
<v-btn
color="primary" variant="tonal" rounded="pill"
:loading="previewing" :disabled="applying" @click="preview"
>
<v-icon start>mdi-magnify</v-icon> Preview
</v-btn>
<v-btn
color="error" rounded="pill"
:loading="applying"
:disabled="previewing || !canApply"
@click="confirmOpen = true"
>
<v-icon start>mdi-paperclip-off</v-icon> Apply
</v-btn>
</div>
<v-alert
v-if="summary" :type="summaryType" variant="tonal" class="mt-4"
density="comfortable"
>
<span v-if="applied">
Deleted {{ summary.rows }} orphaned record(s) and unlinked
{{ summary.files }} file(s), reclaiming {{ humanBytes(summary.bytes) }}.
</span>
<span v-else-if="hasWork">
{{ summary.rows }} orphaned record(s) and {{ summary.files }}
unreferenced file(s) — {{ humanBytes(summary.bytes) }} reclaimable.
Click <strong>Apply</strong> to remove them.
</span>
<span v-else>Nothing to reclaim — every attachment is accounted for.</span>
<!-- Both of these change what the numbers MEAN, so they are stated
whenever they are non-zero rather than hidden in a tooltip. -->
<div v-if="summary.files_failed" class="mt-1 text-caption">
{{ summary.files_failed }} file(s) could not be read or removed — see
the worker log.
</div>
<div v-if="summary.partial" class="mt-1 text-caption">
Stopped early at the time limit; some of the store was not examined.
Run it again to continue.
</div>
</v-alert>
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
<v-dialog v-model="confirmOpen" max-width="440">
<v-card>
<v-card-title>Reclaim orphaned attachments?</v-card-title>
<v-card-text class="text-body-2">
This permanently deletes
<strong>{{ summary?.rows ?? 0 }}</strong> attachment record(s) and
unlinks <strong>{{ summary?.files ?? 0 }}</strong> file(s)
({{ humanBytes(summary?.bytes) }}). Only files that no remaining
record points at are removed, so nothing still attached to a post
is affected.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="confirmOpen = false">Cancel</v-btn>
<v-btn color="error" @click="apply">Reclaim</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</MaintenanceTile>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
import { humanBytes } from '../../utils/bytes.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue'
const confirmOpen = ref(false)
// Walks the whole attachment store, so it can run for minutes on a large
// library — the service caps itself at 900s and reports `partial`. 150 polls
// × 2s ≈ 5m of foreground waiting; past that the composable hands off to the
// task dashboard rather than spinning forever.
const { previewing, applying, summary, applied, preview, apply: applyTask } = useMaintenanceTask({
endpoint: '/api/admin/maintenance/reclaim-attachments',
storageKey: 'fc.maint.reclaimAttachments',
appliedToast: 'Orphaned attachments reclaimed',
maxPolls: 150,
})
const hasWork = computed(
() => !!summary.value && (summary.value.rows > 0 || summary.value.files > 0),
)
const canApply = computed(() => hasWork.value && !applied.value)
const summaryType = computed(() => {
if (applied.value) return 'success'
return hasWork.value ? 'info' : 'success'
})
// The confirm dialog gates the destructive apply; close it, then run.
function apply () {
confirmOpen.value = false
applyTask()
}
</script>
@@ -102,6 +102,7 @@
import { computed, ref } from 'vue'
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
import { humanBytes } from '../../utils/bytes.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue'
@@ -122,14 +123,6 @@ const summaryType = computed(() => {
return summary.value && summary.value.matched > 0 ? 'info' : 'success'
})
function humanBytes (n) {
const b = Number(n || 0)
if (b >= 1 << 30) return (b / (1 << 30)).toFixed(1) + ' GB'
if (b >= 1 << 20) return (b / (1 << 20)).toFixed(1) + ' MB'
if (b >= 1 << 10) return (b / (1 << 10)).toFixed(1) + ' KB'
return b + ' B'
}
// The confirm dialog gates the destructive apply; close it, then run.
function apply () {
confirmOpen.value = false
@@ -78,6 +78,7 @@
import { computed, ref } from 'vue'
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
import { humanBytes } from '../../utils/bytes.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue'
@@ -98,14 +99,6 @@ const summaryType = computed(() => {
return summary.value && summary.value.redundant > 0 ? 'info' : 'success'
})
function humanBytes (n) {
const b = Number(n || 0)
if (b >= 1 << 30) return (b / (1 << 30)).toFixed(1) + ' GB'
if (b >= 1 << 20) return (b / (1 << 20)).toFixed(1) + ' MB'
if (b >= 1 << 10) return (b / (1 << 10)).toFixed(1) + ' KB'
return b + ' B'
}
// The confirm dialog gates the destructive apply; close it, then run.
function apply () {
confirmOpen.value = false
+17
View File
@@ -0,0 +1,17 @@
// Human-readable byte sizes for maintenance summaries ("2.4 GB reclaimable").
//
// Promoted out of the cleanup cards, which had grown byte-identical private
// copies (VideoDedupCard, GatedPurgeCard) and were about to grow a third for
// the attachment reclaim. Binary units (1 KB = 1024 B) — these numbers come
// from st_size / SUM(size_bytes), so they describe disk, not marketing.
//
// NOT the same shape as the `formatBytes` helpers in SystemStatsCards,
// BackupRunsTable and PostCard — those differ in units, precision and
// zero-handling. Left alone deliberately rather than force-fitted here.
export function humanBytes (n) {
const b = Number(n || 0)
if (b >= 1 << 30) return (b / (1 << 30)).toFixed(1) + ' GB'
if (b >= 1 << 20) return (b / (1 << 20)).toFixed(1) + ' MB'
if (b >= 1 << 10) return (b / (1 << 10)).toFixed(1) + ' KB'
return b + ' B'
}
+5 -2
View File
@@ -19,14 +19,16 @@
</section>
<section class="fc-section">
<h3 class="fc-section__title">Duplicates &amp; posts</h3>
<h3 class="fc-section__title">Duplicates &amp; leftovers</h3>
<p class="fc-section__hint">
Tidy post records, duplicates and locked-preview leftovers.
Tidy post records, duplicates, locked-preview leftovers and attachments
that outlived what they belonged to.
</p>
<div class="fc-tile-grid">
<PostMaintenanceCard />
<VideoDedupCard />
<GatedPurgeCard />
<AttachmentReclaimCard />
</div>
</section>
@@ -60,6 +62,7 @@ import SingleColorAuditCard from '../components/cleanup/SingleColorAuditCard.vue
import PostMaintenanceCard from '../components/settings/PostMaintenanceCard.vue'
import VideoDedupCard from '../components/settings/VideoDedupCard.vue'
import GatedPurgeCard from '../components/settings/GatedPurgeCard.vue'
import AttachmentReclaimCard from '../components/settings/AttachmentReclaimCard.vue'
import TagMaintenanceCard from '../components/settings/TagMaintenanceCard.vue'
import DangerZoneCard from '../components/settings/DangerZoneCard.vue'
</script>