mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
790fccb92c
The 20 % brake skipped a category and wiped its marks every night once a fifth of it looked unreferenced. Any user could push a small install over that line, a backlog from #2924 started braked, and a second install on the same bucket below the line was not caught at all. It is gone: - Each pass leaves a trek-owner-<install id> marker at the root of every category it judges. A category holding another install's marker is skipped as foreign_install, one where the marker cannot be written as owner_unknown. Backups leave the markers out. - The install id lives in app_settings outside the storage.* prefix. The switch is stored as that id, so a database from another install reads as off, and every reopen (a restore) writes this install's id back, turns deletion off and drops the marks, confirmations and reports. - More than a fifth unreferenced still marks and reports, but deletes only after an admin confirmed the count shown (POST .../reclaim/confirm). A larger count later waits again. A skip no longer touches the marks, so a failed listing or lookup does not restart the week. Check now runs in the background and answers 202; the admin state says running and keeps the last deleting report apart, so a report-only check never resets what was freed. A pass that deletes writes a system audit entry, a pass that stops or fails keeps its partial counts and marks the rest interrupted, a backend error stops deleting in its category, and deleting stops for the rest of the pass once the switch is off or a backup is reading the uploads. The usage scan waits for a pass still running. Sources read each category once per pass and answer the pre-delete re-check with a query narrowed to the keys' plain runs. Bare names keep their raw spelling (a '#', '?' or trailing blank is part of an upload's name), and local roots are resolved through their symlinks before the overlap check. A pass lists only a category's root.
97 lines
4.4 KiB
TypeScript
97 lines
4.4 KiB
TypeScript
/**
|
|
* StorageUsageScanJob — the nightly 04:15 cron that drives
|
|
* StorageStatsService.scan(). Registration gating follows the
|
|
* CronRegistrarService idiom pinned in tests/unit/nest/admin-jobs.test.ts;
|
|
* the tick error-containment mirrors VersionCheckJob/DemoResetJob there.
|
|
*/
|
|
import type { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
|
|
import type { StorageJobsService } from '../../../../src/nest/storage/storage-jobs.service';
|
|
import type { StorageStatsService } from '../../../../src/nest/storage/storage-stats.service';
|
|
import { StorageUsageScanJob } from '../../../../src/nest/storage/storage-usage-scan.job';
|
|
import { Logger } from '@nestjs/common';
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
function registrarStub(enabled = true) {
|
|
return {
|
|
isEnabled: vi.fn(() => enabled),
|
|
register: vi.fn(
|
|
(
|
|
_name: string,
|
|
_expression: string,
|
|
_onTick: () => void | Promise<void>,
|
|
_opts?: { timezone?: 'app' | 'none' },
|
|
) => enabled,
|
|
),
|
|
unregister: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function statsStub(): StorageStatsService {
|
|
return { scan: vi.fn().mockResolvedValue(undefined) } as unknown as StorageStatsService;
|
|
}
|
|
|
|
/** No exclusive pass holds the slot: the scan goes at once. */
|
|
function jobsStub(idle: Promise<void> = Promise.resolve()): StorageJobsService {
|
|
return { exclusiveIdle: vi.fn(() => idle) } as unknown as StorageJobsService;
|
|
}
|
|
|
|
beforeEach(() => vi.restoreAllMocks());
|
|
|
|
describe('StorageUsageScanJob', () => {
|
|
it('SCAN-001 registers the nightly 04:15 cron when the registrar gate is open', async () => {
|
|
const registrar = registrarStub();
|
|
const job = new StorageUsageScanJob(registrar as unknown as CronRegistrarService, statsStub(), jobsStub());
|
|
await job.onApplicationBootstrap();
|
|
expect(registrar.register).toHaveBeenCalledWith('storage-usage-scan', '15 4 * * *', expect.any(Function));
|
|
});
|
|
|
|
it('SCAN-002 does not register under the test gate (registrar.isEnabled() false)', async () => {
|
|
const registrar = registrarStub(false);
|
|
const job = new StorageUsageScanJob(registrar as unknown as CronRegistrarService, statsStub(), jobsStub());
|
|
await job.onApplicationBootstrap();
|
|
expect(registrar.register).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('SCAN-003 the registered callback drives tick(), which calls stats.scan()', async () => {
|
|
const registrar = registrarStub();
|
|
const stats = statsStub();
|
|
const job = new StorageUsageScanJob(registrar as unknown as CronRegistrarService, stats, jobsStub());
|
|
await job.onApplicationBootstrap();
|
|
const onTick = registrar.register.mock.calls[0]![2] as () => void;
|
|
onTick();
|
|
await vi.waitFor(() => expect(stats.scan).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it('SCAN-004 tick() resolves and contains an Error rejection to the usage scan log line', async () => {
|
|
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
const stats = { scan: vi.fn().mockRejectedValue(new Error('disk full')) } as unknown as StorageStatsService;
|
|
const job = new StorageUsageScanJob(registrarStub() as unknown as CronRegistrarService, stats, jobsStub());
|
|
await expect(job.tick()).resolves.toBeUndefined();
|
|
expect(errorSpy).toHaveBeenCalledWith('usage scan failed: disk full');
|
|
});
|
|
|
|
it('SCAN-005 tick() stringifies a non-Error rejection rather than crashing', async () => {
|
|
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
const stats = { scan: vi.fn().mockRejectedValue('backend unreachable') } as unknown as StorageStatsService;
|
|
const job = new StorageUsageScanJob(registrarStub() as unknown as CronRegistrarService, stats, jobsStub());
|
|
await expect(job.tick()).resolves.toBeUndefined();
|
|
expect(errorSpy).toHaveBeenCalledWith('usage scan failed: backend unreachable');
|
|
});
|
|
|
|
it('SCAN-006 tick() waits for a reclaim pass that still holds the slot before it scans', async () => {
|
|
let release: () => void = () => undefined;
|
|
const idle = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const stats = statsStub();
|
|
const job = new StorageUsageScanJob(registrarStub() as unknown as CronRegistrarService, stats, jobsStub(idle));
|
|
const ticked = job.tick();
|
|
await Promise.resolve();
|
|
expect(stats.scan).not.toHaveBeenCalled();
|
|
release();
|
|
await ticked;
|
|
expect(stats.scan).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|