feat: detect and clean up dangling attachment records in storage janitor
Adds getDanglingAttachments() to find attachment records whose messageId/dmMessageId points to a deleted message. Updates stats to show dangling count and cleanup to delete them.
This commit is contained in:
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { eq, isNotNull } from 'drizzle-orm';
|
import { eq, isNotNull } from 'drizzle-orm';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, getRawDb, schema } from '../db/index.js';
|
||||||
import { deleteUploadFile } from './fileCleanup.js';
|
import { deleteUploadFile } from './fileCleanup.js';
|
||||||
import type { StorageStats, StorageBreakdown, OrphanedFile, CleanupResult } from '@backspace/shared';
|
import type { StorageStats, StorageBreakdown, OrphanedFile, CleanupResult } from '@backspace/shared';
|
||||||
|
|
||||||
@@ -149,10 +149,41 @@ function getUnlinkedAttachments(): { id: string; filename: string; thumbnailFile
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attachment records whose message_id or dm_message_id references a deleted message. */
|
||||||
|
function getDanglingAttachments(): { id: string; filename: string; size: number }[] {
|
||||||
|
const rawDb = getRawDb();
|
||||||
|
|
||||||
|
// Space message attachments with dangling references
|
||||||
|
const danglingSpace = rawDb.prepare(`
|
||||||
|
SELECT a.id, a.filename, a.size
|
||||||
|
FROM attachments a
|
||||||
|
LEFT JOIN messages m ON a.message_id = m.id
|
||||||
|
WHERE a.message_id IS NOT NULL AND m.id IS NULL
|
||||||
|
`).all() as { id: string; filename: string; size: number }[];
|
||||||
|
|
||||||
|
// DM message attachments with dangling references
|
||||||
|
const danglingDm = rawDb.prepare(`
|
||||||
|
SELECT a.id, a.filename, a.size
|
||||||
|
FROM attachments a
|
||||||
|
LEFT JOIN dm_messages dm ON a.dm_message_id = dm.id
|
||||||
|
WHERE a.dm_message_id IS NOT NULL AND dm.id IS NULL
|
||||||
|
`).all() as { id: string; filename: string; size: number }[];
|
||||||
|
|
||||||
|
return [...danglingSpace, ...danglingDm];
|
||||||
|
}
|
||||||
|
|
||||||
export function getStorageStats(): StorageStats {
|
export function getStorageStats(): StorageStats {
|
||||||
const diskFiles = getDiskFiles();
|
const diskFiles = getDiskFiles();
|
||||||
const referenced = getReferencedFilenames();
|
const referenced = getReferencedFilenames();
|
||||||
const unlinked = getUnlinkedAttachments();
|
const unlinked = getUnlinkedAttachments();
|
||||||
|
const dangling = getDanglingAttachments();
|
||||||
|
|
||||||
|
const danglingFilenames = new Set<string>();
|
||||||
|
let danglingSize = 0;
|
||||||
|
for (const att of dangling) {
|
||||||
|
danglingFilenames.add(path.basename(att.filename));
|
||||||
|
danglingSize += att.size;
|
||||||
|
}
|
||||||
|
|
||||||
let totalSize = 0;
|
let totalSize = 0;
|
||||||
let referencedSize = 0;
|
let referencedSize = 0;
|
||||||
@@ -168,7 +199,10 @@ export function getStorageStats(): StorageStats {
|
|||||||
entry.size += file.size;
|
entry.size += file.size;
|
||||||
breakdownMap.set(type, entry);
|
breakdownMap.set(type, entry);
|
||||||
|
|
||||||
if (referenced.has(file.filename)) {
|
if (danglingFilenames.has(file.filename)) {
|
||||||
|
// File is on disk but its attachment record points to a deleted message
|
||||||
|
// — counted as dangling, not referenced
|
||||||
|
} else if (referenced.has(file.filename)) {
|
||||||
referencedSize += file.size;
|
referencedSize += file.size;
|
||||||
} else {
|
} else {
|
||||||
orphanedFiles++;
|
orphanedFiles++;
|
||||||
@@ -190,12 +224,14 @@ export function getStorageStats(): StorageStats {
|
|||||||
return {
|
return {
|
||||||
totalFiles: diskFiles.length,
|
totalFiles: diskFiles.length,
|
||||||
totalSize,
|
totalSize,
|
||||||
referencedFiles: diskFiles.length - orphanedFiles,
|
referencedFiles: diskFiles.length - orphanedFiles - danglingFilenames.size,
|
||||||
referencedSize,
|
referencedSize,
|
||||||
orphanedFiles,
|
orphanedFiles,
|
||||||
orphanedSize,
|
orphanedSize,
|
||||||
unlinkedAttachments: unlinked.length,
|
unlinkedAttachments: unlinked.length,
|
||||||
unlinkedSize,
|
unlinkedSize,
|
||||||
|
danglingAttachments: dangling.length,
|
||||||
|
danglingSize,
|
||||||
breakdown,
|
breakdown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -274,6 +310,26 @@ export function cleanupStorage(dryRun: boolean): CleanupResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 3: Delete dangling attachment records (message_id / dm_message_id
|
||||||
|
// points to a message that no longer exists) and their files from disk.
|
||||||
|
const dangling = getDanglingAttachments();
|
||||||
|
for (const att of dangling) {
|
||||||
|
if (!dryRun) {
|
||||||
|
try {
|
||||||
|
deleteUploadFile(att.filename);
|
||||||
|
db.delete(schema.attachments)
|
||||||
|
.where(eq(schema.attachments.id, att.id))
|
||||||
|
.run();
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`Failed to clean up dangling attachment ${att.id}: ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deletedFiles++;
|
||||||
|
freedBytes += att.size;
|
||||||
|
deletedAttachmentRecords++;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dryRun,
|
dryRun,
|
||||||
deletedFiles,
|
deletedFiles,
|
||||||
|
|||||||
Reference in New Issue
Block a user