/** * trash-cleanup.ts * * Periodic GC for `data/users/{ownerId}/trash/`. Files older than * `retentionDays` (mtime-based) are unlinked. Empty subdirectories left * behind are removed too. * * Trigger: one sweep at boot + setInterval(24h). The interval is unref()'d * so it does not keep the event loop alive on its own. */ import { promises as fs } from 'node:fs'; import type { Dirent } from 'node:fs'; import { join } from 'node:path'; import { logger } from '../logger.js'; const DAY_MS = 86_400_000; const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000; export interface RunTrashCleanupResult { scannedUsers: number; deletedFiles: number; freedBytes: number; } /** * Walk `{rootDir}/{userId}/trash/` for every user and delete files whose * mtime is older than `retentionDays`. With retentionDays=0 every file is * eligible (useful for tests / aggressive cleanup). */ export async function runTrashCleanup( rootDir: string, retentionDays: number, ): Promise { const result: RunTrashCleanupResult = { scannedUsers: 0, deletedFiles: 0, freedBytes: 0, }; if (!Number.isFinite(retentionDays) || retentionDays < 0) { throw new Error(`runTrashCleanup: retentionDays must be a non-negative finite number, got ${retentionDays}`); } // retentionDays=0 → delete everything regardless of mtime. We can't just compute // `Date.now() - 0` because file mtime can have sub-ms precision slightly newer than // the integer-truncated Date.now(), which would skip a just-written file. const cutoff = retentionDays === 0 ? Number.POSITIVE_INFINITY : Date.now() - retentionDays * DAY_MS; let userEntries: Dirent[]; try { userEntries = await fs.readdir(rootDir, { withFileTypes: true }); } catch (err) { const e = err as NodeJS.ErrnoException; if (e.code === 'ENOENT') return result; // user folder root does not exist yet — nothing to do throw err; } for (const userEntry of userEntries) { if (!userEntry.isDirectory()) continue; const trashDir = join(rootDir, userEntry.name, 'trash'); let stat; try { stat = await fs.stat(trashDir); } catch (err) { const e = err as NodeJS.ErrnoException; if (e.code === 'ENOENT') continue; logger.warn(`[trash-cleanup] stat failed user=${userEntry.name} err=${e.message}`); continue; } if (!stat.isDirectory()) continue; result.scannedUsers++; await sweepDir(trashDir, cutoff, result, userEntry.name); } return result; } async function sweepDir( dir: string, cutoff: number, result: RunTrashCleanupResult, userId: string, ): Promise { let entries: Dirent[]; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch (err) { const e = err as NodeJS.ErrnoException; logger.warn(`[trash-cleanup] readdir failed user=${userId} dir=${dir} err=${e.message}`); return; } for (const entry of entries) { const full = join(dir, entry.name); if (entry.isDirectory()) { await sweepDir(full, cutoff, result, userId); // remove the directory if it became empty after the sweep try { const remaining = await fs.readdir(full); if (remaining.length === 0) await fs.rmdir(full); } catch (err) { const e = err as NodeJS.ErrnoException; if (e.code !== 'ENOENT') { logger.warn(`[trash-cleanup] rmdir failed user=${userId} dir=${full} err=${e.message}`); } } continue; } if (!entry.isFile() && !entry.isSymbolicLink()) continue; try { const fileStat = await fs.lstat(full); if (fileStat.mtimeMs > cutoff) continue; const size = fileStat.size; await fs.unlink(full); result.deletedFiles++; result.freedBytes += size; } catch (err) { const e = err as NodeJS.ErrnoException; if (e.code === 'ENOENT') continue; // already gone logger.warn(`[trash-cleanup] unlink failed user=${userId} path=${full} err=${e.message}`); } } } export interface StartTrashCleanupOptions { userFolderRoot: string; retentionDays: number; intervalMs?: number; // override for tests } /** * Run one sweep at boot, then schedule a daily sweep. Returns a stop() * function and an `initialSweep` promise that tests can await to guarantee * the boot sweep has completed. The interval is unref()'d so it does not * block process exit. */ export function startTrashCleanup(opts: StartTrashCleanupOptions): { stop: () => void; initialSweep: Promise; } { const intervalMs = opts.intervalMs ?? SWEEP_INTERVAL_MS; logger.info(`[trash-cleanup] starting root=${opts.userFolderRoot} retentionDays=${opts.retentionDays} intervalMs=${intervalMs}`); const sweep = (): Promise => runTrashCleanup(opts.userFolderRoot, opts.retentionDays) .then((res) => { if (res.deletedFiles > 0) { logger.info(`[trash-cleanup] swept users=${res.scannedUsers} deleted=${res.deletedFiles} freedBytes=${res.freedBytes}`); } else { logger.info(`[trash-cleanup] swept users=${res.scannedUsers} deleted=0`); } }) .catch((err: Error) => { logger.warn(`[trash-cleanup] sweep failed err=${err.message}`); }); const initialSweep = sweep(); const handle = setInterval(() => { void sweep(); }, intervalMs); handle.unref(); return { stop: () => clearInterval(handle), initialSweep, }; }