maestro/src/user-folder/trash-cleanup.ts
2026-06-03 05:08:00 +00:00

168 lines
5.3 KiB
TypeScript

/**
* 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<RunTrashCleanupResult> {
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<void> {
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<void>;
} {
const intervalMs = opts.intervalMs ?? SWEEP_INTERVAL_MS;
logger.info(`[trash-cleanup] starting root=${opts.userFolderRoot} retentionDays=${opts.retentionDays} intervalMs=${intervalMs}`);
const sweep = (): Promise<void> =>
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,
};
}