mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
bef8fc20f1
- A restore (admin panel or RESTORE_FROM_BACKUP) now leaves data/.trek-reclaim-reset before it swaps the database in. Boot reconcile resets the reclaim state when it finds the file, whatever install id the database names, and removes it afterwards; a successful reopen removes it too. A restart after a failed reopen, or a boot restore of this installation's own backup, no longer keeps the old switch, marks and confirmations. The id mismatch reset stays. - The release job skips a tick (install_mismatch) while the database names another installation or a reset is still pending. - An allowed owner verdict is cached for 15 minutes, a refusal for 26 hours. The local driver reads a list prefix as a folder, so the category root is still listed whole, at most once per category per window. - Each tick reads every allowed category's due rows separately, an equal share of the 200 each, so a category whose deletes keep failing cannot starve the others. - Rows dropped after 30 days overdue and releases refused by a full queue are logged at warn with their counts.
550 lines
25 KiB
TypeScript
550 lines
25 KiB
TypeScript
/**
|
|
* StorageReleaseService (#2924): what a user's delete or replace left unnamed
|
|
* is queued by the request, which deletes nothing, and goes once it is due,
|
|
* through the same sources and parser as the nightly pass, over a real
|
|
* StorageModule (a LocalDriver on a temp folder) with fake sources standing in
|
|
* for the owner domains. An object any row still names stays; the pass's
|
|
* guards hold, the owner marker included; a failure never reaches the caller
|
|
* and leaves its rows queued; one failing category never holds up another, and
|
|
* a database that is not this installation's releases nothing. RELS-001 to
|
|
* RELS-026.
|
|
*/
|
|
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
|
|
import { StorageReleaseQueue } from '../../../../src/db/entities/StorageReleaseQueue.entity';
|
|
import { StorageReleaseQueueRepository } from '../../../../src/db/repositories/StorageReleaseQueue.repository';
|
|
import { dbNow } from '../../../../src/db/types';
|
|
import { DataPathsService } from '../../../../src/nest/app-config/data-paths.service';
|
|
import { logWarn } from '../../../../src/nest/audit/audit-log.logger';
|
|
import { StorageInstallService } from '../../../../src/nest/storage/storage-install.service';
|
|
import { StorageJobsService } from '../../../../src/nest/storage/storage-jobs.service';
|
|
import { StorageReclaimGuardService } from '../../../../src/nest/storage/storage-reclaim-guard.service';
|
|
import { StorageReclaimRegistryService } from '../../../../src/nest/storage/storage-reclaim-registry.service';
|
|
import { MAX_LIKE_TERMS } from '../../../../src/nest/storage/storage-reclaim-source.service';
|
|
import {
|
|
STORAGE_RECLAIMABLE_CATEGORIES,
|
|
STORAGE_REFERENCE_SOURCE_IDS,
|
|
storageOwnerMarkerKey,
|
|
type StorageReferenceClaim,
|
|
type StorageReferenceSource,
|
|
type StorageReferenceSourceId,
|
|
} from '../../../../src/nest/storage/storage-reclaim.types';
|
|
import {
|
|
RELEASE_DELAY_MS,
|
|
STORAGE_RELEASE_JOB,
|
|
StorageReleaseService,
|
|
} from '../../../../src/nest/storage/storage-release.service';
|
|
import { StorageModule } from '../../../../src/nest/storage/storage.module';
|
|
import { StorageService } from '../../../../src/nest/storage/storage.service';
|
|
import type { ServedCategory } from '../../../../src/nest/storage/storage.types';
|
|
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
|
import { countRows, deleteRows, findRows } from '../../../helpers/factories/rows';
|
|
import { setAppSetting } from '../../../helpers/factories/settings';
|
|
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
|
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { Readable } from 'node:stream';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../../../src/nest/audit/audit-log.logger')>()),
|
|
logInfo: vi.fn(),
|
|
logDebug: vi.fn(),
|
|
logError: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
}));
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
const db = createSnapshotTestDb();
|
|
let t: TestModule;
|
|
let orm: TestOrm;
|
|
let uploadsRoot: string;
|
|
let dataRoot: string;
|
|
let release: StorageReleaseService;
|
|
let storage: StorageService;
|
|
let references: StorageReclaimRegistryService;
|
|
let jobs: StorageJobsService;
|
|
let installId: string;
|
|
/** What the fake sources still name, per category, lowercased. */
|
|
let named: Map<ServedCategory, Set<string>>;
|
|
let asked: Array<{ id: StorageReferenceSourceId; category: ServedCategory; keys: string[] }>;
|
|
let failWith: Error | null;
|
|
|
|
function fakeSource(id: StorageReferenceSourceId): StorageReferenceSource {
|
|
const categories: readonly ServedCategory[] =
|
|
id === 'auth' ? ['avatars'] : id === 'files' ? ['files'] : STORAGE_RECLAIMABLE_CATEGORIES;
|
|
return {
|
|
id,
|
|
claims: categories.map((category): StorageReferenceClaim => ({ category, prefix: '' })),
|
|
namedKeys: async () => {
|
|
throw new Error('a release never reads a whole category');
|
|
},
|
|
namedKeysByCategory: async () => {
|
|
throw new Error('a release never reads a whole category');
|
|
},
|
|
async referenced(category, keys) {
|
|
asked.push({ id, category, keys: [...keys] });
|
|
if (failWith) throw failWith;
|
|
const set = named.get(category) ?? new Set<string>();
|
|
return new Set(keys.filter((key) => set.has(key.toLowerCase())));
|
|
},
|
|
};
|
|
}
|
|
|
|
async function put(category: ServedCategory, key: string): Promise<void> {
|
|
await storage.put(category, key, Readable.from('bytes'));
|
|
}
|
|
|
|
function exists(category: string, key: string): boolean {
|
|
return fs.existsSync(path.join(uploadsRoot, category, key));
|
|
}
|
|
|
|
async function queued(category: string, key?: string): Promise<number> {
|
|
return await countRows(orm, StorageReleaseQueue, key === undefined ? { category } : { category, key });
|
|
}
|
|
|
|
/** Once everything queued so far is due. */
|
|
function due(): Date {
|
|
return new Date(Date.now() + RELEASE_DELAY_MS + 60_000);
|
|
}
|
|
|
|
async function setBackends(extra: unknown[] = []): Promise<void> {
|
|
await setAppSetting(
|
|
orm,
|
|
'storage.backends',
|
|
JSON.stringify([{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }, ...extra]),
|
|
);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
uploadsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-release-uploads-'));
|
|
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-release-data-'));
|
|
orm = await createTestOrm(db);
|
|
await setBackends();
|
|
const paths = {
|
|
...new DataPathsService(),
|
|
installIdFile: path.join(dataRoot, '.trek-install-id'),
|
|
reclaimResetFile: path.join(dataRoot, '.trek-reclaim-reset'),
|
|
};
|
|
t = await createTestModule({
|
|
db,
|
|
imports: [StorageModule],
|
|
init: true,
|
|
overrides: [{ provide: DataPathsService, useValue: paths }],
|
|
});
|
|
release = t.get(StorageReleaseService);
|
|
storage = t.get(StorageService);
|
|
references = t.get(StorageReclaimRegistryService);
|
|
jobs = t.get(StorageJobsService);
|
|
installId = t.get(StorageInstallService).installId() ?? '';
|
|
await storage.reloadConfig();
|
|
for (const id of STORAGE_REFERENCE_SOURCE_IDS) references.register(fakeSource(id));
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
vi.restoreAllMocks();
|
|
vi.mocked(logWarn).mockClear();
|
|
await deleteRows(orm, StorageReleaseQueue);
|
|
t.get(StorageReclaimGuardService).forget();
|
|
named = new Map();
|
|
asked = [];
|
|
failWith = null;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await t.close();
|
|
await deleteRows(orm, AppSettings, { key: 'storage.backends' });
|
|
await orm.close();
|
|
db.close();
|
|
fs.rmSync(uploadsRoot, { recursive: true, force: true });
|
|
fs.rmSync(dataRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
const UUID = '6f1e2d3c-4b5a-4968-8776-655443322110';
|
|
|
|
describe('StorageReleaseService: the request only queues', () => {
|
|
it('RELS-001: queues what the values name, deletes nothing itself, and the due tick deletes what no row names', async () => {
|
|
expect(installId).toMatch(/^[0-9a-f-]{36}$/);
|
|
await put('covers', `${UUID}-old.jpg`);
|
|
await put('covers', `${UUID}-shared.jpg`);
|
|
named.set('covers', new Set([`${UUID}-shared.jpg`]));
|
|
const before = Date.now();
|
|
expect(await release.releaseValues([`/uploads/covers/${UUID}-old.jpg`, `covers/${UUID}-shared.jpg`])).toBe(2);
|
|
expect(exists('covers', `${UUID}-old.jpg`)).toBe(true);
|
|
expect(asked).toEqual([]);
|
|
const rows = await findRows(orm, StorageReleaseQueue, { category: 'covers' });
|
|
expect(rows.map((row) => row.key).sort()).toEqual([`${UUID}-old.jpg`, `${UUID}-shared.jpg`]);
|
|
for (const row of rows) expect(row.due_at >= dbNow(new Date(before + RELEASE_DELAY_MS - 1000))).toBe(true);
|
|
|
|
expect(await release.processDue(due())).toEqual({ skipped: null, deleted: 1, kept: 1 });
|
|
expect(exists('covers', `${UUID}-old.jpg`)).toBe(false);
|
|
expect(exists('covers', `${UUID}-shared.jpg`)).toBe(true);
|
|
expect(await queued('covers')).toBe(0);
|
|
});
|
|
|
|
it('RELS-002: reads every spelling a column holds, and a bare name with its column options', async () => {
|
|
expect(await release.releaseValues([`https://trip.example.test/uploads/places/${UUID}.jpg?v=2`])).toBeGreaterThan(
|
|
0,
|
|
);
|
|
expect(await release.releaseValues([`${UUID}.png`], { bare: 'avatars' })).toBe(1);
|
|
expect(await release.releaseValues([`legacy/${UUID}. #4711`], { bare: 'files', basename: true })).toBeGreaterThan(
|
|
0,
|
|
);
|
|
expect(await queued('places', `${UUID}.jpg`)).toBe(1);
|
|
expect(await queued('avatars', `${UUID}.png`)).toBe(1);
|
|
expect(await queued('files', `${UUID}. #4711`)).toBe(1);
|
|
});
|
|
|
|
it('RELS-003: never queues a nested key, an owner marker, a category the ledger keeps or a foreign URL', async () => {
|
|
expect(
|
|
await release.releaseValues([
|
|
`/uploads/journey/thumbs/${UUID}.jpg`,
|
|
`/uploads/covers/${storageOwnerMarkerKey(UUID)}`,
|
|
`/uploads/photos/${UUID}.jpg`,
|
|
`/uploads/backups/${UUID}.zip`,
|
|
'https://elsewhere.example.test/x.jpg',
|
|
null,
|
|
undefined,
|
|
]),
|
|
).toBe(0);
|
|
expect(await countRows(orm, StorageReleaseQueue)).toBe(0);
|
|
});
|
|
|
|
it('RELS-004: one value queues a bounded number of keys, and a key queued again waits from the later time', async () => {
|
|
const many = Array.from({ length: 40 }, (_unused, i) => `/uploads/covers/${UUID}-${i}.jpg`).join(' ');
|
|
expect(await release.releaseValues([many])).toBeLessThanOrEqual(16);
|
|
await deleteRows(orm, StorageReleaseQueue);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-again.jpg`]);
|
|
const first = (await findRows(orm, StorageReleaseQueue, { key: `${UUID}-again.jpg` }))[0]?.due_at ?? '';
|
|
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 2 * DAY);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-again.jpg`]);
|
|
vi.mocked(Date.now).mockRestore();
|
|
const second = (await findRows(orm, StorageReleaseQueue, { key: `${UUID}-again.jpg` }))[0]?.due_at ?? '';
|
|
expect(second > first).toBe(true);
|
|
expect(await queued('covers', `${UUID}-again.jpg`)).toBe(1);
|
|
});
|
|
|
|
it('RELS-005: a queue that fails is logged and answered as nothing queued, never thrown', async () => {
|
|
vi.spyOn(StorageReleaseQueueRepository.prototype, 'size').mockRejectedValueOnce(new Error('database is locked'));
|
|
await expect(release.releaseValues([`/uploads/covers/${UUID}-locked.jpg`])).resolves.toBe(0);
|
|
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('database is locked'));
|
|
});
|
|
});
|
|
|
|
describe('StorageReleaseService: a tick deletes only what is due and unnamed', () => {
|
|
it('RELS-006: nothing goes before it is due', async () => {
|
|
await put('places', `${UUID}-young.jpg`);
|
|
await release.releaseValues([`/uploads/places/${UUID}-young.jpg`]);
|
|
expect(await release.processDue(new Date())).toEqual({ skipped: null, deleted: 0, kept: 0 });
|
|
expect(exists('places', `${UUID}-young.jpg`)).toBe(true);
|
|
expect(await queued('places', `${UUID}-young.jpg`)).toBe(1);
|
|
});
|
|
|
|
it('RELS-007: a value an undo wrote back before it was due keeps its object, and its row goes', async () => {
|
|
await put('covers', `${UUID}-undone.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-undone.jpg`]);
|
|
named.set('covers', new Set([`${UUID}-undone.jpg`]));
|
|
expect((await release.processDue(due())).kept).toBe(1);
|
|
expect(exists('covers', `${UUID}-undone.jpg`)).toBe(true);
|
|
expect(await queued('covers')).toBe(0);
|
|
});
|
|
|
|
it('RELS-008: every source that claims the category is asked, at most 50 keys a question, 200 rows a tick', async () => {
|
|
const keys = Array.from({ length: 230 }, (_unused, i) => `${UUID}-n${i}.jpg`);
|
|
expect(await release.releaseValues(keys.map((key) => `/uploads/places/${key}`))).toBe(230);
|
|
const result = await release.processDue(due());
|
|
expect(result.deleted).toBe(200);
|
|
expect(await queued('places')).toBe(30);
|
|
expect(Math.max(...asked.map((question) => question.keys.length))).toBeLessThanOrEqual(50);
|
|
expect([...new Set(asked.map((question) => question.id))].sort()).toEqual(
|
|
['collections', 'journey', 'photos', 'place-contrib', 'places', 'trips'].sort(),
|
|
);
|
|
expect(MAX_LIKE_TERMS).toBeLessThanOrEqual(50);
|
|
});
|
|
|
|
it('RELS-009: a key named in another spelling, case included, keeps its object', async () => {
|
|
await put('covers', `${UUID}-case.jpg`);
|
|
named.set('covers', new Set([`${UUID}-case.jpg`]));
|
|
await release.releaseValues([`/uploads/covers/${UUID.toUpperCase()}-CASE.JPG`]);
|
|
await release.processDue(due());
|
|
expect(exists('covers', `${UUID}-case.jpg`)).toBe(true);
|
|
});
|
|
|
|
it('RELS-010: a source that fails keeps the rows and never throws; the next tick deletes', async () => {
|
|
await put('covers', `${UUID}-f.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-f.jpg`]);
|
|
failWith = new Error('places table locked');
|
|
expect((await release.processDue(due())).deleted).toBe(0);
|
|
expect(exists('covers', `${UUID}-f.jpg`)).toBe(true);
|
|
expect(await queued('covers', `${UUID}-f.jpg`)).toBe(1);
|
|
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('places table locked'));
|
|
failWith = null;
|
|
expect((await release.processDue(due())).deleted).toBe(1);
|
|
expect(exists('covers', `${UUID}-f.jpg`)).toBe(false);
|
|
});
|
|
|
|
it('RELS-011: a delete that fails keeps its row and stops the category for the tick', async () => {
|
|
await put('covers', `${UUID}-d1.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-d1.jpg`]);
|
|
vi.spyOn(storage, 'delete').mockRejectedValue(new Error('permission denied'));
|
|
expect((await release.processDue(due())).deleted).toBe(0);
|
|
expect(exists('covers', `${UUID}-d1.jpg`)).toBe(true);
|
|
expect(await queued('covers', `${UUID}-d1.jpg`)).toBe(1);
|
|
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('permission denied'));
|
|
});
|
|
|
|
it('RELS-012: an object the facade keeps on delete (a demo) stays, and its row goes', async () => {
|
|
await put('covers', `${UUID}-k.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-k.jpg`]);
|
|
vi.spyOn(storage, 'keepsOnDelete').mockReturnValue(true);
|
|
expect((await release.processDue(due())).kept).toBe(1);
|
|
expect(exists('covers', `${UUID}-k.jpg`)).toBe(true);
|
|
expect(await queued('covers')).toBe(0);
|
|
});
|
|
|
|
it('RELS-013: a row that waited thirty days past its time is left to the nightly pass', async () => {
|
|
await orm
|
|
.repo(StorageReleaseQueue)
|
|
.enqueue([{ category: 'covers', key: `${UUID}-ancient.jpg` }], dbNow(new Date(Date.now() - 31 * DAY)));
|
|
await release.processDue(new Date());
|
|
expect(await queued('covers', `${UUID}-ancient.jpg`)).toBe(0);
|
|
expect(asked).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('StorageReleaseService: the guards the nightly pass keeps', () => {
|
|
it('RELS-014: a category missing a required source keeps its rows', async () => {
|
|
await put('avatars', `${UUID}-a.png`);
|
|
await release.releaseValues([`${UUID}-a.png`], { bare: 'avatars' });
|
|
vi.spyOn(references, 'sources').mockReturnValue(references.sources().filter((source) => source.id !== 'auth'));
|
|
await release.processDue(due());
|
|
expect(exists('avatars', `${UUID}-a.png`)).toBe(true);
|
|
expect(await queued('avatars')).toBe(1);
|
|
});
|
|
|
|
it('RELS-015: nothing runs while a restore or a backup runs, or another storage job holds the slot', async () => {
|
|
await put('covers', `${UUID}-r.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-r.jpg`]);
|
|
await jobs.beginRestore();
|
|
try {
|
|
expect((await release.processDue(due())).skipped).toBe('busy');
|
|
} finally {
|
|
jobs.endRestore();
|
|
}
|
|
jobs.beginBackup();
|
|
try {
|
|
expect((await release.processDue(due())).skipped).toBe('busy');
|
|
} finally {
|
|
jobs.endBackup();
|
|
}
|
|
expect(jobs.claimExclusive('a test job')).toBe(true);
|
|
try {
|
|
expect((await release.processDue(due())).skipped).toBe('busy');
|
|
} finally {
|
|
jobs.releaseExclusive('a test job');
|
|
}
|
|
expect(exists('covers', `${UUID}-r.jpg`)).toBe(true);
|
|
expect(await queued('covers')).toBe(1);
|
|
// A tick holds the slot under its own name while it runs, and lets go after.
|
|
const holders: Array<string | null> = [];
|
|
named.set('covers', new Set());
|
|
vi.spyOn(storage, 'delete').mockImplementation(async () => {
|
|
holders.push(jobs.exclusiveHolder);
|
|
});
|
|
await release.processDue(due());
|
|
expect(holders).toEqual([STORAGE_RELEASE_JOB]);
|
|
expect(jobs.exclusiveHolder).toBeNull();
|
|
});
|
|
|
|
it('RELS-016: a restore that begins mid-tick stops it and keeps the rest queued', async () => {
|
|
await put('covers', `${UUID}-m1.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-m1.jpg`]);
|
|
const realReferenced = references.sources().map((source) => source.referenced.bind(source));
|
|
let restoring = false;
|
|
references.sources().forEach((source, i) => {
|
|
vi.spyOn(source, 'referenced').mockImplementation(async (category, keys) => {
|
|
if (!restoring) {
|
|
restoring = true;
|
|
void jobs.beginRestore();
|
|
}
|
|
return await (realReferenced[i] as typeof source.referenced)(category, keys);
|
|
});
|
|
});
|
|
try {
|
|
expect((await release.processDue(due())).deleted).toBe(0);
|
|
} finally {
|
|
jobs.endRestore();
|
|
}
|
|
expect(exists('covers', `${UUID}-m1.jpg`)).toBe(true);
|
|
expect(await queued('covers')).toBe(1);
|
|
});
|
|
|
|
it('RELS-017: a category whose location overlaps another keeps its rows', async () => {
|
|
await put('covers', `${UUID}-s.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-s.jpg`]);
|
|
await setBackends([{ name: 'nested', type: 'local', options: { root: path.join(uploadsRoot, 'covers') } }]);
|
|
await setAppSetting(orm, 'storage.categories', JSON.stringify({ 'photos-trek': 'nested' }));
|
|
await storage.reloadConfig();
|
|
try {
|
|
await release.processDue(due());
|
|
expect(exists('covers', `${UUID}-s.jpg`)).toBe(true);
|
|
expect(await queued('covers')).toBe(1);
|
|
} finally {
|
|
await setBackends();
|
|
await deleteRows(orm, AppSettings, { key: 'storage.categories' });
|
|
await storage.reloadConfig();
|
|
}
|
|
});
|
|
|
|
it('RELS-018: another installation marker keeps the category untouched; this one writes its own', async () => {
|
|
await put('covers', `${UUID}-o.jpg`);
|
|
await put('covers', storageOwnerMarkerKey('another-install-0001'));
|
|
await release.releaseValues([`/uploads/covers/${UUID}-o.jpg`]);
|
|
try {
|
|
await release.processDue(due());
|
|
expect(exists('covers', `${UUID}-o.jpg`)).toBe(true);
|
|
expect(await queued('covers')).toBe(1);
|
|
} finally {
|
|
await storage.delete('covers', storageOwnerMarkerKey('another-install-0001'));
|
|
}
|
|
// The verdict is cached until it is forgotten (a reopen) or runs out.
|
|
await release.processDue(due());
|
|
expect(exists('covers', `${UUID}-o.jpg`)).toBe(true);
|
|
t.get(StorageReclaimGuardService).forget();
|
|
await release.processDue(due());
|
|
expect(exists('covers', `${UUID}-o.jpg`)).toBe(false);
|
|
expect(exists('covers', storageOwnerMarkerKey(installId))).toBe(true);
|
|
});
|
|
|
|
it('RELS-019: an owner marker that cannot be looked for leaves the category as owner_unknown', async () => {
|
|
await put('covers', `${UUID}-u.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-u.jpg`]);
|
|
vi.spyOn(storage, 'list').mockImplementation(() => {
|
|
throw new Error('listing refused');
|
|
});
|
|
await release.processDue(due());
|
|
expect(exists('covers', `${UUID}-u.jpg`)).toBe(true);
|
|
expect(await queued('covers')).toBe(1);
|
|
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('listing refused'));
|
|
});
|
|
|
|
it('RELS-020: without an install id nothing is released', async () => {
|
|
await put('covers', `${UUID}-i.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-i.jpg`]);
|
|
vi.spyOn(t.get(StorageInstallService), 'installId').mockReturnValue(null);
|
|
expect((await release.processDue(due())).skipped).toBe('no_install_id');
|
|
expect(exists('covers', `${UUID}-i.jpg`)).toBe(true);
|
|
});
|
|
|
|
it('RELS-021: a key with no run to narrow a read by is kept without a question, for the nightly pass', async () => {
|
|
await put('covers', 'short.jpg');
|
|
await release.releaseValues(['/uploads/covers/short.jpg']);
|
|
expect((await release.processDue(due())).kept).toBe(1);
|
|
expect(asked).toEqual([]);
|
|
expect(exists('covers', 'short.jpg')).toBe(true);
|
|
expect(await queued('covers')).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('StorageReleaseService: what a round-3 review found', () => {
|
|
it('RELS-022: a database naming another installation, or a restore whose reset is pending, releases nothing', async () => {
|
|
await put('covers', `${UUID}-x.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-x.jpg`]);
|
|
await setAppSetting(orm, 'install_id', '12345678-aaaa-4bbb-8ccc-1234567890ab');
|
|
try {
|
|
expect((await release.processDue(due())).skipped).toBe('install_mismatch');
|
|
} finally {
|
|
await setAppSetting(orm, 'install_id', installId);
|
|
}
|
|
const sentinel = path.join(dataRoot, '.trek-reclaim-reset');
|
|
fs.writeFileSync(sentinel, 'pending');
|
|
try {
|
|
expect((await release.processDue(due())).skipped).toBe('install_mismatch');
|
|
} finally {
|
|
fs.rmSync(sentinel, { force: true });
|
|
}
|
|
expect(exists('covers', `${UUID}-x.jpg`)).toBe(true);
|
|
expect(await queued('covers')).toBe(1);
|
|
expect(await release.processDue(due())).toEqual({ skipped: null, deleted: 1, kept: 0 });
|
|
});
|
|
|
|
it('RELS-023: a category whose deletes keep failing takes only its share of a tick, the others still go', async () => {
|
|
const older = Array.from({ length: 230 }, (_unused, i) => `${UUID}-c${i}.jpg`);
|
|
await orm.repo(StorageReleaseQueue).enqueue(
|
|
older.map((key) => ({ category: 'covers', key })),
|
|
dbNow(new Date(Date.now() - DAY)),
|
|
);
|
|
await put('places', `${UUID}-p.jpg`);
|
|
await release.releaseValues([`/uploads/places/${UUID}-p.jpg`]);
|
|
const realDelete = storage.delete.bind(storage);
|
|
vi.spyOn(storage, 'delete').mockImplementation(async (category, key) => {
|
|
if (category === 'covers') throw new Error('covers bucket refuses deletes');
|
|
await realDelete(category, key);
|
|
});
|
|
// The 230 older covers rows would fill a tick of 200 on their own, every tick.
|
|
expect((await release.processDue(due())).deleted).toBe(1);
|
|
expect(exists('places', `${UUID}-p.jpg`)).toBe(false);
|
|
expect(await queued('places')).toBe(0);
|
|
expect(await queued('covers')).toBe(230);
|
|
});
|
|
|
|
it('RELS-024: rows dropped after thirty days and a full queue are logged at warn with their counts', async () => {
|
|
await orm.repo(StorageReleaseQueue).enqueue(
|
|
[
|
|
{ category: 'covers', key: `${UUID}-old1.jpg` },
|
|
{ category: 'places', key: `${UUID}-old2.jpg` },
|
|
],
|
|
dbNow(new Date(Date.now() - 31 * DAY)),
|
|
);
|
|
await release.processDue(new Date());
|
|
expect(logWarn).toHaveBeenCalledWith(expect.stringMatching(/^\[storage\] 2 released object\(s\) waited 30 days/));
|
|
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('only once deletion is on'));
|
|
vi.mocked(logWarn).mockClear();
|
|
vi.spyOn(StorageReleaseQueueRepository.prototype, 'size').mockResolvedValueOnce(50_000);
|
|
expect(await release.releaseValues([`/uploads/covers/${UUID}-full.jpg`])).toBe(0);
|
|
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('(50000 of 50000 waiting): 1 released object(s)'));
|
|
expect(await countRows(orm, StorageReleaseQueue)).toBe(0);
|
|
});
|
|
|
|
it('RELS-025: an allowed owner verdict holds for one window only, a refusal until the next night', async () => {
|
|
const marker = storageOwnerMarkerKey('another-install-0002');
|
|
const lists = vi.spyOn(storage, 'list');
|
|
const start = due().getTime();
|
|
await put('covers', `${UUID}-w1.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-w1.jpg`]);
|
|
expect((await release.processDue(new Date(start))).deleted).toBe(1);
|
|
expect(lists).toHaveBeenCalledTimes(1);
|
|
// Another installation's marker shows up: within the window the verdict stands, without a second listing.
|
|
await put('covers', marker);
|
|
try {
|
|
await put('covers', `${UUID}-w2.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-w2.jpg`]);
|
|
expect((await release.processDue(new Date(start + 10 * 60_000))).deleted).toBe(1);
|
|
expect(lists).toHaveBeenCalledTimes(1);
|
|
// Past it the root is listed again and the category refused.
|
|
await put('covers', `${UUID}-w3.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-w3.jpg`]);
|
|
expect((await release.processDue(new Date(start + 16 * 60_000))).deleted).toBe(0);
|
|
expect(lists).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
await storage.delete('covers', marker);
|
|
}
|
|
// The refusal holds hours later, though the marker is gone, until the nightly pass judges again.
|
|
expect((await release.processDue(new Date(start + 6 * 60 * 60_000))).deleted).toBe(0);
|
|
expect(lists).toHaveBeenCalledTimes(2);
|
|
expect(exists('covers', `${UUID}-w3.jpg`)).toBe(true);
|
|
expect((await release.processDue(new Date(start + 27 * 60 * 60_000))).deleted).toBe(1);
|
|
expect(exists('covers', `${UUID}-w3.jpg`)).toBe(false);
|
|
});
|
|
|
|
it('RELS-026: the owner marker is looked for in the category root only, one shallow listing', async () => {
|
|
const lists = vi.spyOn(storage, 'list');
|
|
await put('covers', `${UUID}-root.jpg`);
|
|
await release.releaseValues([`/uploads/covers/${UUID}-root.jpg`]);
|
|
await release.processDue(due());
|
|
expect(lists.mock.calls).toEqual([['covers', '', { shallow: true }]]);
|
|
});
|
|
});
|