feat(admin): manual cleanup of stale tus upload sessions + visibility
Adds an admin-driven sweep on top of the existing 24h auto-expire so
operators can see and reap abandoned `.tus/` sessions without waiting.
- storageJanitor: extract `walkTusDir(predicate)` helper, add
`getStaleTusInfo` + `cleanupStaleTusSessions(thresholdMs, dryRun)`;
refactor `cleanupTusStragglers` to delegate while preserving its
janitor-tick `{ removed }` contract.
- StorageStats gains `staleTusSessions` + `staleTusSize` (fixed 1h
display threshold).
- New `POST /api/admin/storage/cleanup-tus` route with
`maxAgeHours` validation (positive finite number, default 1) and
`dryRun` support; admin-gated.
- StoragePanel: 6th overview card "Stale Uploads" + new cleanup
subsection mirroring the media-cleanup pattern (preview-then-clean
with shared result panel styling).
- Tests: 8 new janitor tests covering empty dir, threshold filtering,
dry-run vs live, oldest-mtime tracking, subdir skipping, and the
override path on the existing straggler sweep. New
`routes/admin.test.ts` covers auth/admin gates, validation (zero,
negative, NaN), default `maxAgeHours`, dry-run vs live unlink.
- Docs: `uploads.md` §Janitor expanded to the full lifecycle (cancel
DELETE, discard DELETE, auto-expire, straggler sweep, admin route);
`admin.md` Storage Management updated with the new endpoint and
StorageStats fields.
This commit is contained in:
@@ -174,11 +174,22 @@ function getDanglingAttachments(): { id: string; filename: string; size: number
|
||||
return [...danglingSpace, ...danglingDm];
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold for the `staleTusSessions` count exposed via `getStorageStats()`.
|
||||
* Distinct from `tusStragglerSweepMs` (defensive sweep, default 48h) and from
|
||||
* the configurable `maxAgeHours` of the admin cleanup route — this is purely
|
||||
* the "how many entries look abandoned right now?" display threshold. An
|
||||
* active upload writes chunks often, so a 1h+ gap is a strong signal the user
|
||||
* walked away (paused/crashed/discarded without DELETE).
|
||||
*/
|
||||
const STALE_TUS_DISPLAY_THRESHOLD_MS = 60 * 60 * 1000;
|
||||
|
||||
export function getStorageStats(): StorageStats {
|
||||
const diskFiles = getDiskFiles();
|
||||
const referenced = getReferencedFilenames();
|
||||
const unlinked = getUnlinkedAttachments();
|
||||
const dangling = getDanglingAttachments();
|
||||
const staleTus = getStaleTusInfo(STALE_TUS_DISPLAY_THRESHOLD_MS);
|
||||
|
||||
const danglingFilenames = new Set<string>();
|
||||
let danglingSize = 0;
|
||||
@@ -235,6 +246,8 @@ export function getStorageStats(): StorageStats {
|
||||
unlinkedSize,
|
||||
danglingAttachments: dangling.length,
|
||||
danglingSize,
|
||||
staleTusSessions: staleTus.count,
|
||||
staleTusSize: staleTus.size,
|
||||
breakdown,
|
||||
};
|
||||
}
|
||||
@@ -721,19 +734,33 @@ export async function cleanupTusUploads(): Promise<{ removed: number }> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive sweep of `${config.tusUploadDir}`: any file (payload OR sidecar)
|
||||
* whose mtime is older than `config.tusStragglerSweepMs` is unlinked. Covers
|
||||
* the rare case where a tus crash left an orphan that deleteExpired() doesn't
|
||||
* recognize — e.g. a payload without sidecar (so creation_date is unknown),
|
||||
* or a sidecar without payload (so getUpload() rejects).
|
||||
* Walk `config.tusUploadDir`, yielding `{ name, full, size, mtimeMs }` for each
|
||||
* regular file matching `predicate`. Tolerates missing dir (yields nothing) and
|
||||
* skips entries whose `statSync` throws (e.g. file vanished mid-walk).
|
||||
*
|
||||
* Shared between the unconditional straggler sweep, the admin-driven
|
||||
* stale-session cleanup, and the stats helper. Centralising the iteration
|
||||
* keeps the .tus/ semantics (which entries count as "files we care about") in
|
||||
* exactly one place.
|
||||
*/
|
||||
export function cleanupTusStragglers(): { removed: number } {
|
||||
if (!fs.existsSync(config.tusUploadDir)) return { removed: 0 };
|
||||
const entries = fs.readdirSync(config.tusUploadDir);
|
||||
const cutoff = Date.now() - config.tusStragglerSweepMs;
|
||||
let removed = 0;
|
||||
for (const entry of entries) {
|
||||
const full = path.join(config.tusUploadDir, entry);
|
||||
interface TusEntry {
|
||||
name: string;
|
||||
full: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
function walkTusDir(predicate: (entry: TusEntry) => boolean): TusEntry[] {
|
||||
if (!fs.existsSync(config.tusUploadDir)) return [];
|
||||
let names: string[];
|
||||
try {
|
||||
names = fs.readdirSync(config.tusUploadDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: TusEntry[] = [];
|
||||
for (const name of names) {
|
||||
const full = path.join(config.tusUploadDir, name);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(full);
|
||||
@@ -741,15 +768,90 @@ export function cleanupTusStragglers(): { removed: number } {
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) continue;
|
||||
if (stat.mtimeMs >= cutoff) continue;
|
||||
try {
|
||||
fs.unlinkSync(full);
|
||||
removed += 1;
|
||||
} catch {
|
||||
// Race with tus's own cleanup or permissions error — ignore
|
||||
}
|
||||
const entry: TusEntry = { name, full, size: stat.size, mtimeMs: stat.mtimeMs };
|
||||
if (predicate(entry)) out.push(entry);
|
||||
}
|
||||
return { removed };
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect `.tus/` for entries whose mtime is older than `thresholdMs`. Returns
|
||||
* an aggregate snapshot — count, total bytes, oldest mtime — without touching
|
||||
* any files. Used by `getStorageStats()` to surface a count of "abandoned"
|
||||
* tus sessions in the admin UI, and by the admin route as a dry-run primitive.
|
||||
*
|
||||
* "Stale" here is purely mtime-based and counts both payloads and `.json`
|
||||
* sidecars; the conservative threshold (1h) used by `getStorageStats` matches
|
||||
* the intuition that an active upload writes chunks frequently, so a 1h+ gap
|
||||
* means the user genuinely walked away.
|
||||
*/
|
||||
export function getStaleTusInfo(thresholdMs: number): { count: number; size: number; oldestAt: number | null } {
|
||||
const cutoff = Date.now() - thresholdMs;
|
||||
let count = 0;
|
||||
let size = 0;
|
||||
let oldestAt: number | null = null;
|
||||
walkTusDir((entry) => {
|
||||
if (entry.mtimeMs >= cutoff) return false;
|
||||
count += 1;
|
||||
size += entry.size;
|
||||
if (oldestAt === null || entry.mtimeMs < oldestAt) oldestAt = entry.mtimeMs;
|
||||
return false; // we don't need the returned array, just side-effects
|
||||
});
|
||||
return { count, size, oldestAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-driven sweep of stale tus sessions. Walks `.tus/`, finds entries with
|
||||
* mtime older than `thresholdMs`, optionally unlinks each one, and returns an
|
||||
* aggregate result. In `dryRun=true` mode no files are touched. Per-file
|
||||
* unlink errors are collected (not thrown) so a single bad entry can't abort
|
||||
* the whole sweep.
|
||||
*
|
||||
* Note that `.tus/` holds *pairs* (payload + `.json` sidecar) per session, but
|
||||
* we treat each file independently — the sidecar's mtime is updated by
|
||||
* `@tus/file-store` on every PATCH, so payload + sidecar move together; if the
|
||||
* pair is genuinely abandoned, both are stale and both get reaped. No need for
|
||||
* pair reconciliation.
|
||||
*/
|
||||
export function cleanupStaleTusSessions(
|
||||
thresholdMs: number,
|
||||
dryRun: boolean,
|
||||
): { deletedFiles: number; freedBytes: number; errors: string[] } {
|
||||
const cutoff = Date.now() - thresholdMs;
|
||||
const stale = walkTusDir((entry) => entry.mtimeMs < cutoff);
|
||||
const errors: string[] = [];
|
||||
let deletedFiles = 0;
|
||||
let freedBytes = 0;
|
||||
for (const entry of stale) {
|
||||
if (!dryRun) {
|
||||
try {
|
||||
fs.unlinkSync(entry.full);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`Failed to delete ${entry.name}: ${message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
deletedFiles += 1;
|
||||
freedBytes += entry.size;
|
||||
}
|
||||
return { deletedFiles, freedBytes, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive sweep of `${config.tusUploadDir}`: any file (payload OR sidecar)
|
||||
* whose mtime is older than `thresholdMs` (default `config.tusStragglerSweepMs`,
|
||||
* 48 h) is unlinked. Covers the rare case where a tus crash left an orphan
|
||||
* that deleteExpired() doesn't recognize — e.g. a payload without sidecar (so
|
||||
* creation_date is unknown), or a sidecar without payload (so getUpload()
|
||||
* rejects).
|
||||
*
|
||||
* Janitor-tick contract preserved: returns `{ removed }` with the count of
|
||||
* files actually unlinked. Internally delegates to `cleanupStaleTusSessions`.
|
||||
*/
|
||||
export function cleanupTusStragglers(thresholdMs: number = config.tusStragglerSweepMs): { removed: number } {
|
||||
const result = cleanupStaleTusSessions(thresholdMs, false);
|
||||
return { removed: result.deletedFiles };
|
||||
}
|
||||
|
||||
// cleanupStorage is expensive (full disk scan + DB joins) so we run it at
|
||||
|
||||
@@ -78,4 +78,133 @@ describe('cleanupTusStragglers', () => {
|
||||
|
||||
expect(result.removed).toBe(0);
|
||||
});
|
||||
|
||||
it('honours an explicit threshold override', async () => {
|
||||
const { cleanupTusStragglers } = await import('./storageJanitor.js');
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const file = path.join(tmpDir, 'two-hour-old');
|
||||
fs.writeFileSync(file, 'data');
|
||||
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
||||
const seconds = twoHoursAgo / 1000;
|
||||
fs.utimesSync(file, seconds, seconds);
|
||||
|
||||
// Default 48h threshold would skip this file. Override to 1h to catch it.
|
||||
const result = cleanupTusStragglers(60 * 60 * 1000);
|
||||
|
||||
expect(result.removed).toBe(1);
|
||||
expect(fs.existsSync(file)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStaleTusInfo', () => {
|
||||
it('returns zeros when the directory does not exist', async () => {
|
||||
const { getStaleTusInfo } = await import('./storageJanitor.js');
|
||||
expect(fs.existsSync(tmpDir)).toBe(false);
|
||||
|
||||
const info = getStaleTusInfo(60 * 60 * 1000);
|
||||
|
||||
expect(info).toEqual({ count: 0, size: 0, oldestAt: null });
|
||||
});
|
||||
|
||||
it('excludes entries newer than the threshold', async () => {
|
||||
const { getStaleTusInfo } = await import('./storageJanitor.js');
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, 'fresh'), 'recent');
|
||||
// mtime defaults to "now" — within any reasonable threshold.
|
||||
|
||||
const info = getStaleTusInfo(60 * 60 * 1000);
|
||||
|
||||
expect(info.count).toBe(0);
|
||||
expect(info.size).toBe(0);
|
||||
expect(info.oldestAt).toBeNull();
|
||||
});
|
||||
|
||||
it('includes entries older than the threshold and tracks oldest mtime', async () => {
|
||||
const { getStaleTusInfo } = await import('./storageJanitor.js');
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
const stale1 = path.join(tmpDir, 'stale-1');
|
||||
const stale2 = path.join(tmpDir, 'stale-2');
|
||||
const fresh = path.join(tmpDir, 'fresh');
|
||||
fs.writeFileSync(stale1, 'a'.repeat(100));
|
||||
fs.writeFileSync(stale2, 'b'.repeat(250));
|
||||
fs.writeFileSync(fresh, 'c'.repeat(50));
|
||||
|
||||
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
||||
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000;
|
||||
fs.utimesSync(stale1, twoHoursAgo / 1000, twoHoursAgo / 1000);
|
||||
fs.utimesSync(stale2, fourHoursAgo / 1000, fourHoursAgo / 1000);
|
||||
|
||||
const info = getStaleTusInfo(60 * 60 * 1000); // 1h threshold
|
||||
|
||||
expect(info.count).toBe(2);
|
||||
expect(info.size).toBe(350);
|
||||
expect(info.oldestAt).not.toBeNull();
|
||||
// Oldest mtime should be ~ fourHoursAgo (within fs precision, allow 1.5s slack)
|
||||
expect(Math.abs((info.oldestAt ?? 0) - fourHoursAgo)).toBeLessThan(1500);
|
||||
});
|
||||
|
||||
it('skips subdirectories', async () => {
|
||||
const { getStaleTusInfo } = await import('./storageJanitor.js');
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const sub = path.join(tmpDir, 'subdir');
|
||||
fs.mkdirSync(sub);
|
||||
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
||||
fs.utimesSync(sub, oneDayAgo / 1000, oneDayAgo / 1000);
|
||||
|
||||
const info = getStaleTusInfo(60 * 60 * 1000);
|
||||
|
||||
expect(info.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupStaleTusSessions', () => {
|
||||
it('returns zero counts when the directory does not exist', async () => {
|
||||
const { cleanupStaleTusSessions } = await import('./storageJanitor.js');
|
||||
expect(fs.existsSync(tmpDir)).toBe(false);
|
||||
|
||||
const result = cleanupStaleTusSessions(60 * 60 * 1000, false);
|
||||
|
||||
expect(result.deletedFiles).toBe(0);
|
||||
expect(result.freedBytes).toBe(0);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts but does not delete when dryRun=true', async () => {
|
||||
const { cleanupStaleTusSessions } = await import('./storageJanitor.js');
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const stale = path.join(tmpDir, 'stale');
|
||||
fs.writeFileSync(stale, 'x'.repeat(200));
|
||||
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
||||
fs.utimesSync(stale, twoHoursAgo / 1000, twoHoursAgo / 1000);
|
||||
|
||||
const result = cleanupStaleTusSessions(60 * 60 * 1000, true);
|
||||
|
||||
expect(result.deletedFiles).toBe(1);
|
||||
expect(result.freedBytes).toBe(200);
|
||||
expect(result.errors).toEqual([]);
|
||||
// File must still exist on disk.
|
||||
expect(fs.existsSync(stale)).toBe(true);
|
||||
});
|
||||
|
||||
it('unlinks stale entries when dryRun=false and leaves fresh ones alone', async () => {
|
||||
const { cleanupStaleTusSessions } = await import('./storageJanitor.js');
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
const stale = path.join(tmpDir, 'stale');
|
||||
const fresh = path.join(tmpDir, 'fresh');
|
||||
fs.writeFileSync(stale, 'x'.repeat(123));
|
||||
fs.writeFileSync(fresh, 'y'.repeat(456));
|
||||
|
||||
const threeHoursAgo = Date.now() - 3 * 60 * 60 * 1000;
|
||||
fs.utimesSync(stale, threeHoursAgo / 1000, threeHoursAgo / 1000);
|
||||
|
||||
const result = cleanupStaleTusSessions(60 * 60 * 1000, false);
|
||||
|
||||
expect(result.deletedFiles).toBe(1);
|
||||
expect(result.freedBytes).toBe(123);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(fs.existsSync(stale)).toBe(false);
|
||||
expect(fs.existsSync(fresh)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user