Files
TREK/server/tests/unit/nest/storage/storage-usage-scan.job.test.ts
T
Maurice 790fccb92c fix(storage): judge unreferenced files without a brake anyone can trip
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.
2026-11-01 16:18:08 +01:00

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);
});
});