feat(studio): run nitro scans in the background with cancel-re-attach and nightly auto-clean
This commit is contained in:
1 parent
9d571e0c29
commit
6c53f4680c
14 files changed
+1547
-303
No files matched your search
@@ -74,6 +74,12 @@ export interface CleanupScanOptions {
|
||||
* leaving the tab looking frozen while a large directory is processed.
|
||||
*/
|
||||
onProgress?: (progress: CleanupProgress) => void;
|
||||
/**
|
||||
* Aborts the scan. Every CPU-bound stage (readdir, stat pool, header
|
||||
* validation) checks the signal and throws an `AbortError`-named error, so a
|
||||
* cancelled scan never writes a partial result to the on-disk cache.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface NitroCleanupDeleteResult {
|
||||
@@ -130,6 +136,16 @@ const KIND_FILE_RE: Record<CleanupAssetKind, RegExp> = {
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function abortSignalReason(): Error {
|
||||
const error = new Error("Scan cancelled");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw abortSignalReason();
|
||||
}
|
||||
|
||||
function uniqueDirs(dirs: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
@@ -249,9 +265,11 @@ const DIR_STAT_CONCURRENCY = 32;
|
||||
async function buildDirEntries(
|
||||
dir: string,
|
||||
names: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<DirectoryRead[]> {
|
||||
const entries: DirectoryRead[] = [];
|
||||
await runPool(names, DIR_STAT_CONCURRENCY, async (name) => {
|
||||
throwIfAborted(signal);
|
||||
let size = 0;
|
||||
let mtimeMs = 0;
|
||||
try {
|
||||
@@ -275,11 +293,13 @@ async function collectAssets(
|
||||
kind: CleanupAssetKind,
|
||||
dirs: string[],
|
||||
namesByKey: Map<string, string[]>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Map<string, AssetCleanupEntry>> {
|
||||
const byName = new Map<string, AssetCleanupEntry>();
|
||||
for (const dir of dirs) {
|
||||
throwIfAborted(signal);
|
||||
const names = namesByKey.get(kindKey(kind, dir)) ?? [];
|
||||
for (const read of await buildDirEntries(dir, names)) {
|
||||
for (const read of await buildDirEntries(dir, names, signal)) {
|
||||
const entry = byName.get(read.name) ?? {
|
||||
fileName: read.name,
|
||||
base: read.base,
|
||||
@@ -355,7 +375,7 @@ function cacheDirsMatch(
|
||||
export async function scanFakeBrokenNitros(
|
||||
options: CleanupScanOptions = {},
|
||||
): Promise<NitroCleanupScan> {
|
||||
const { force = false, onProgress } = options;
|
||||
const { force = false, onProgress, signal } = options;
|
||||
const targets = await getFurniAssetWriteTargets();
|
||||
const nitroDirs = uniqueDirs([
|
||||
targets.nitroDir,
|
||||
@@ -386,6 +406,7 @@ export async function scanFakeBrokenNitros(
|
||||
await Promise.all(
|
||||
directoryPlan.flatMap(({ kind, dirs }) =>
|
||||
dirs.map(async (dir) => {
|
||||
throwIfAborted(signal);
|
||||
namesByKey.set(
|
||||
kindKey(kind, dir),
|
||||
await readDirNames(dir, KIND_FILE_RE[kind]),
|
||||
@@ -398,6 +419,7 @@ export async function scanFakeBrokenNitros(
|
||||
currentDigests[key] = dirNameSignature(namesByKey.get(key) ?? []);
|
||||
}
|
||||
|
||||
throwIfAborted(signal);
|
||||
onProgress?.({ phase: "readdir", scanned: 0 });
|
||||
if (!force) {
|
||||
const cache = await readScanCache();
|
||||
@@ -407,14 +429,15 @@ export async function scanFakeBrokenNitros(
|
||||
}
|
||||
}
|
||||
|
||||
throwIfAborted(signal);
|
||||
// Needed only for classification — skip the DB query on a cache hit.
|
||||
onProgress?.({ phase: "stems", scanned: 0 });
|
||||
const validStems = await getCleanupValidStems();
|
||||
|
||||
const [byNitro, bySwf, byIcon] = await Promise.all([
|
||||
collectAssets("nitro", nitroDirs, namesByKey),
|
||||
collectAssets("swf", swfDirs, namesByKey),
|
||||
collectAssets("icon", iconDirs, namesByKey),
|
||||
collectAssets("nitro", nitroDirs, namesByKey, signal),
|
||||
collectAssets("swf", swfDirs, namesByKey, signal),
|
||||
collectAssets("icon", iconDirs, namesByKey, signal),
|
||||
]);
|
||||
|
||||
const entries: NitroCleanupEntry[] = [...byNitro.values()];
|
||||
@@ -424,6 +447,7 @@ export async function scanFakeBrokenNitros(
|
||||
const totalToValidate = entries.length;
|
||||
|
||||
await runPool(entries, 16, async (entry) => {
|
||||
throwIfAborted(signal);
|
||||
// A .nitro that no DB item maps to is a leftover / fake bundle.
|
||||
if (!validStems.has(entry.base.toLowerCase())) {
|
||||
fake.push(entry);
|
||||
@@ -477,6 +501,7 @@ export async function scanFakeBrokenNitros(
|
||||
orphanedIcon: byName(orphanedIcon),
|
||||
total: byNitro.size + bySwf.size + byIcon.size,
|
||||
};
|
||||
throwIfAborted(signal);
|
||||
await writeScanCache(currentDigests, result);
|
||||
onProgress?.({ phase: "done", scanned: result.total });
|
||||
return result;
|
||||
@@ -567,6 +592,147 @@ export async function autoCleanFakeNitros(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPersistedCleanupResult(): Promise<NitroCleanupScan | null> {
|
||||
const cache = await readScanCache();
|
||||
return cache?.result ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the on-disk scan cache consistent after a mutation without re-scanning:
|
||||
* recompute the directory signatures for the touched asset kind (cheap readdir
|
||||
* pass — no stats, no header validation) and drop the removed file names from
|
||||
* the cached result lists. Without this, the next non-forced scan would see a
|
||||
* digest mismatch (e.g. after a delete) and re-validate the whole directory.
|
||||
*/
|
||||
export async function refreshScanCacheAfterMutation(
|
||||
kind: CleanupAssetKind,
|
||||
fileNames: string[],
|
||||
removedCount = fileNames.length,
|
||||
): Promise<void> {
|
||||
const remove = new Set(fileNames);
|
||||
if (remove.size === 0) return;
|
||||
const cache = await readScanCache();
|
||||
if (!cache) return;
|
||||
|
||||
const targets = await getFurniAssetWriteTargets();
|
||||
for (const dir of dirsForKind(targets, kind)) {
|
||||
cache.dirs[kindKey(kind, dir)] = dirNameSignature(
|
||||
await readDirNames(dir, KIND_FILE_RE[kind]),
|
||||
);
|
||||
}
|
||||
|
||||
const result = cache.result;
|
||||
if (kind === "nitro") {
|
||||
result.fake = result.fake.filter((e) => !remove.has(e.fileName));
|
||||
result.broken = result.broken.filter((e) => !remove.has(e.fileName));
|
||||
} else if (kind === "swf") {
|
||||
result.orphanedSwf = result.orphanedSwf.filter(
|
||||
(e) => !remove.has(e.fileName),
|
||||
);
|
||||
} else {
|
||||
result.orphanedIcon = result.orphanedIcon.filter(
|
||||
(e) => !remove.has(e.fileName),
|
||||
);
|
||||
}
|
||||
result.total = Math.max(0, result.total - removedCount);
|
||||
await writeScanCache(cache.dirs, result);
|
||||
}
|
||||
|
||||
// ── Auto-clean history ──────────────────────────────────────────────────────
|
||||
//
|
||||
// The nightly scheduled auto-clean and the manual button both record a history
|
||||
// entry so staff can see what the jobs worker removed and when.
|
||||
|
||||
export interface NitroAutoCleanHistoryEntry {
|
||||
triggeredAt: string;
|
||||
triggeredBy: "manual" | "scheduled";
|
||||
maxAgeDays: number;
|
||||
deleted: number;
|
||||
copiesRemoved: number;
|
||||
skippedRecent: number;
|
||||
skippedBroken: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const HISTORY_MAX_ENTRIES = 50;
|
||||
const historyPath = () => path.join(SCAN_CACHE_DIR, "history.json");
|
||||
|
||||
export async function readAutoCleanHistory(): Promise<
|
||||
NitroAutoCleanHistoryEntry[]
|
||||
> {
|
||||
try {
|
||||
if (!existsSync(historyPath())) return [];
|
||||
const parsed = JSON.parse(await fs.readFile(historyPath(), "utf8"));
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed as NitroAutoCleanHistoryEntry[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendAutoCleanHistory(
|
||||
entry: NitroAutoCleanHistoryEntry,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const history = (await readAutoCleanHistory()).slice(
|
||||
0,
|
||||
HISTORY_MAX_ENTRIES - 1,
|
||||
);
|
||||
await fs.mkdir(SCAN_CACHE_DIR, { recursive: true });
|
||||
await fs.writeFile(historyPath(), JSON.stringify([entry, ...history]));
|
||||
} catch {
|
||||
/* best effort — history is never fatal */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the scan cache + append a history entry after an auto-clean run.
|
||||
* Shared by the manual route and the nightly scheduled job.
|
||||
*/
|
||||
export async function finishAutoClean(options: {
|
||||
result: NitroAutoCleanResult;
|
||||
maxAgeDays: number;
|
||||
triggeredBy: "manual" | "scheduled";
|
||||
}): Promise<void> {
|
||||
const removed = options.result.files
|
||||
.filter((file) => file.deleted)
|
||||
.map((file) => file.fileName);
|
||||
await refreshScanCacheAfterMutation("nitro", removed, options.result.deleted);
|
||||
await appendAutoCleanHistory({
|
||||
triggeredAt: new Date().toISOString(),
|
||||
triggeredBy: options.triggeredBy,
|
||||
maxAgeDays: options.maxAgeDays,
|
||||
deleted: options.result.deleted,
|
||||
copiesRemoved: options.result.copiesRemoved,
|
||||
skippedRecent: options.result.skippedRecent,
|
||||
skippedBroken: options.result.skippedBroken,
|
||||
errors: options.result.errors,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for the nightly jobs-worker cron. Never runs while an interactive
|
||||
* scan session is in flight, so scheduled deletion can't race the UI.
|
||||
*/
|
||||
export async function scheduledAutoCleanFakeNitros(
|
||||
maxAgeDays = 30,
|
||||
): Promise<{ skipped: boolean; deleted?: number }> {
|
||||
const { isSessionStale, readScanSession } = await import(
|
||||
"./nitro-scan-session"
|
||||
);
|
||||
const session = await readScanSession();
|
||||
if (session?.state === "running" && !isSessionStale(session)) {
|
||||
return { skipped: true };
|
||||
}
|
||||
const result = await autoCleanFakeNitros(maxAgeDays);
|
||||
await finishAutoClean({
|
||||
result,
|
||||
maxAgeDays,
|
||||
triggeredBy: "scheduled",
|
||||
});
|
||||
return { skipped: false, deleted: result.deleted };
|
||||
}
|
||||
|
||||
async function copyFileToDirs(
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
|
||||
Reference in new issue
Block a user