168 lines
5.3 KiB
TypeScript
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,
|
|
};
|
|
}
|