fix(storage): queue what a delete left unnamed past every undo, and tell a copied database apart

A delete or a replace no longer deletes inside the request. The owner's call
queues the keys in storage_release_queue, due after RELEASE_DELAY_MS (26 h:
the assistant's 24 h approval window plus a margin), and StorageReleaseJob
takes the due rows every ten minutes, at most 200 a tick and 50 keys a
question, asks every reference source again and deletes what none names.
It refuses what the nightly pass refuses (StorageReclaimGuardService: the
ledger, the required sources, a shared location, another installation's
owner marker, cached per category), and waits while a restore or a backup
runs or another storage job holds the slot. An assistant undo or a late
write that names the old value again keeps its object.

The install id lives in data/.trek-install-id, outside any backup and never
written by a restore. At boot and after every reopen the database is
reconciled with it: a database naming another installation (a boot restore,
a restart after a failed restore, a copied travel.db) loses its switch,
confirmations, reports, marks and queue before anything reads them.

A confirmation now stores the count the admin was shown and when; it covers
up to one and a half times that count (at least 100 more) for 14 days, is
used up once the share is back under a fifth, and a count the last report
no longer holds is refused as reclaim_confirm_stale. The admin state folds
a live confirmation in, so the prompt goes at once.

A pass reads each source once for every category and drops each category's
keys once judged; right before a batch it reads the category's sources
again, whole and in memory, instead of a LIKE per key. A narrowed read holds
at most 50 LIKE terms. A pass held off by a backup reports backup_running,
and a category the ledger never reclaims reports not_reclaimed even after a
stop.
This commit is contained in:
Maurice
2026-11-01 18:11:43 +01:00
parent 0a25de2c6d
commit 1c6a175ebc
40 changed files with 2225 additions and 581 deletions
+1
View File
@@ -120,6 +120,7 @@
"Settings": "settings",
"ShareTokens": "share",
"StorageReclaimMarks": "storage",
"StorageReleaseQueue": "storage",
"Tags": "tags",
"TodoCategoryAssignees": "todo",
"TodoItems": "todo",
+2 -1
View File
@@ -59,7 +59,8 @@ tokens, `RuntimeEnvService`) and consumes the SAME derive functions.
`resolveDataPaths()` is the only place the server anchors `data/` and
`uploads/` (on `SERVER_ROOT`, the package directory, never on `process.cwd()`)
and names what lives in them: backups, the scratch dir, the logs, the JWT
secret and encryption key files, and the database file (TREK_DB_FILE when set,
secret and encryption key files, the install id file the storage reclaim
tells a copied database apart by, and the database file (TREK_DB_FILE when set,
else `data/travel.db`). Never derive one of these paths from `__dirname` in a
new file: a moved file would quietly move the data.
+7
View File
@@ -29,6 +29,12 @@ export interface DataPaths {
readonly jwtSecretFile: string;
/** `data/.encryption_key`: the at-rest key when ENCRYPTION_KEY does not provide it. */
readonly encryptionKeyFile: string;
/**
* `data/.trek-install-id`: this installation's random id, written on first
* start. It never enters a backup and no restore writes it, so a database
* restored or copied from another install is told apart by it (storage reclaim).
*/
readonly installIdFile: string;
/**
* The SQLite file a non-test process opens: TREK_DB_FILE when set, else
* `data/travel.db`. Under NODE_ENV=test the connection is `:memory:` instead
@@ -58,6 +64,7 @@ export function resolveDataPaths(
logsDir: path.join(dataDir, 'logs'),
jwtSecretFile: path.join(dataDir, '.jwt_secret'),
encryptionKeyFile: path.join(dataDir, '.encryption_key'),
installIdFile: path.join(dataDir, '.trek-install-id'),
dbFile: env.db.trekDbFile || path.join(dataDir, 'travel.db'),
};
}
@@ -0,0 +1,21 @@
import { EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core';
import { StorageReleaseQueueRepository } from '../repositories/StorageReleaseQueue.repository';
import { DbTimestampType } from '../types';
export class StorageReleaseQueue {
[EntityRepositoryType]?: StorageReleaseQueueRepository;
[PrimaryKeyProp]?: ['category', 'key'];
category!: string;
key!: string;
due_at!: string;
}
export const StorageReleaseQueueSchema = defineEntity({
class: StorageReleaseQueue,
repository: () => StorageReleaseQueueRepository,
properties: {
category: p.text().primary(),
key: p.text().primary(),
due_at: p.type(DbTimestampType),
},
});
+2
View File
@@ -119,6 +119,7 @@ import { SchoolHolidayRegionsSchema } from './SchoolHolidayRegions.entity';
import { SettingsSchema } from './Settings.entity';
import { ShareTokensSchema } from './ShareTokens.entity';
import { StorageReclaimMarksSchema } from './StorageReclaimMarks.entity';
import { StorageReleaseQueueSchema } from './StorageReleaseQueue.entity';
import { TagsSchema } from './Tags.entity';
import { TodoCategoryAssigneesSchema } from './TodoCategoryAssignees.entity';
import { TodoItemsSchema } from './TodoItems.entity';
@@ -287,6 +288,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
SettingsSchema,
ShareTokensSchema,
StorageReclaimMarksSchema,
StorageReleaseQueueSchema,
TagsSchema,
TodoCategoryAssigneesSchema,
TodoItemsSchema,
@@ -0,0 +1,8 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
export interface StorageReleaseQueueTable {
category: string;
key: string;
due_at: string;
}
+2
View File
@@ -125,6 +125,7 @@ import type { SchoolHolidayRegionsTable } from './SchoolHolidayRegions.table';
import type { SettingsTable } from './Settings.table';
import type { ShareTokensTable } from './ShareTokens.table';
import type { StorageReclaimMarksTable } from './StorageReclaimMarks.table';
import type { StorageReleaseQueueTable } from './StorageReleaseQueue.table';
import type { TagsTable } from './Tags.table';
import type { TodoCategoryAssigneesTable } from './TodoCategoryAssignees.table';
import type { TodoItemsTable } from './TodoItems.table';
@@ -299,6 +300,7 @@ export interface DB {
settings: SettingsTable;
share_tokens: ShareTokensTable;
storage_reclaim_marks: StorageReclaimMarksTable;
storage_release_queue: StorageReleaseQueueTable;
tags: TagsTable;
todo_category_assignees: TodoCategoryAssigneesTable;
todo_items: TodoItemsTable;
@@ -0,0 +1,24 @@
import { Migration } from '@mikro-orm/migrations';
/**
* What a user's delete or replace left unnamed waits here before it goes: one
* row per stored object, due once every undo that can write the old value back
* has run out. A job then asks every reference source again and deletes only
* what none of them names; the row goes either way. A row is all an owner's
* request writes, so a delete stays as cheap as it was before the storage
* reclaim, whatever the request named.
*/
export class Migration20200101046340_a_released_object_outlasts_every_undo_before_it_goes extends Migration {
override name = 'Migration20200101046340_a_released_object_outlasts_every_undo_before_it_goes';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS storage_release_queue (
category TEXT NOT NULL,
key TEXT NOT NULL,
due_at DATETIME NOT NULL,
PRIMARY KEY (category, key)
)
`);
}
}
@@ -0,0 +1,86 @@
import type { StorageReleaseQueue } from '../entities/StorageReleaseQueue.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
type QueueKyselyDB = Pick<DB, 'storage_release_queue'>;
/** One object a user's change left unnamed, and when it may go. */
export interface ReleaseQueueRow {
category: string;
key: string;
due_at: string;
}
/**
* `storage_release_queue`: the objects a delete or a replace left unnamed,
* keyed by category and category-relative key, each with the time it may go.
* Owners only add rows; the release job reads the due ones of the categories
* it may judge, asks every reference source again and drops each row once it
* has decided, deleted or not. A key queued again waits from the later time.
*/
export class StorageReleaseQueueRepository extends TrekRepository<StorageReleaseQueue> {
private db() {
return this.kysely<QueueKyselyDB>();
}
/** Queues these keys, due at `dueAt`; a key queued already waits until the later time. */
async enqueue(rows: Array<{ category: string; key: string }>, dueAt: string): Promise<void> {
if (rows.length === 0) return;
await this.db()
.insertInto('storage_release_queue')
.values(rows.map((row) => ({ category: row.category, key: row.key, due_at: dueAt })))
.onConflict((oc) => oc.columns(['category', 'key']).doUpdateSet({ due_at: dueAt }))
.execute();
}
/** How many rows wait, due or not. */
async size(): Promise<number> {
return await this.count();
}
/** The categories holding a row due at `now`. */
async dueCategories(now: string): Promise<string[]> {
const rows = await this.db()
.selectFrom('storage_release_queue')
.select('category')
.distinct()
.where('due_at', '<=', now)
.execute();
return rows.map((row) => row.category);
}
/** The rows of these categories due at `now`, the longest waiting first, at most `limit`. */
async dueIn(categories: string[], now: string, limit: number): Promise<ReleaseQueueRow[]> {
if (categories.length === 0 || limit <= 0) return [];
return await this.db()
.selectFrom('storage_release_queue')
.select(['category', 'key', 'due_at'])
.where('category', 'in', categories)
.where('due_at', '<=', now)
.orderBy('due_at')
.limit(limit)
.execute();
}
/** Drops the rows of these keys; answers how many went. */
async deleteKeys(category: string, keys: string[]): Promise<number> {
if (keys.length === 0) return 0;
const result = await this.db()
.deleteFrom('storage_release_queue')
.where('category', '=', category)
.where('key', 'in', keys)
.executeTakeFirst();
return Number(result.numDeletedRows);
}
/** Drops the rows due before `cutoff`: what waited that long is the nightly pass's. */
async deleteDueBefore(cutoff: string): Promise<number> {
const result = await this.db().deleteFrom('storage_release_queue').where('due_at', '<', cutoff).executeTakeFirst();
return Number(result.numDeletedRows);
}
/** Drops every row, for a database another installation's history came with. */
async deleteAll(): Promise<number> {
return await this.nativeDelete({});
}
}
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
/**
* The data layout for Nest classes: the data directory, uploads, backups,
* scratch space, logs, the key files and the database file.
* scratch space, logs, the key files, the install id and the database file.
*
* Providers that read a data path take this one (AdminService,
* StorageRegistryService). Code that runs before the container exists or is
@@ -23,6 +23,7 @@ export class DataPathsService implements DataPaths {
readonly logsDir: string;
readonly jwtSecretFile: string;
readonly encryptionKeyFile: string;
readonly installIdFile: string;
readonly dbFile: string;
constructor() {
@@ -34,6 +35,7 @@ export class DataPathsService implements DataPaths {
this.logsDir = paths.logsDir;
this.jwtSecretFile = paths.jwtSecretFile;
this.encryptionKeyFile = paths.encryptionKeyFile;
this.installIdFile = paths.installIdFile;
this.dbFile = paths.dbFile;
}
}
@@ -54,7 +54,10 @@ export class JourneySettingsService {
return previousSettings(before as unknown as Readonly<Record<string, unknown>> | undefined, patch);
});
if (written) await this.journeys.broadcastJourneyEvent(journeyId, 'journey:updated', {}, sid);
if (typeof previous.cover_image === 'string') await this.release.releaseValues([previous.cover_image]);
// A replaced cover is queued, never deleted here: the assistant's undo can write it back.
if (typeof previous.cover_image === 'string' && previous.cover_image !== patch.cover_image) {
await this.release.releaseValues([previous.cover_image]);
}
const journey = (await this.journeysRepo.findById(journeyId)) as Journey | undefined;
return journey ? { journey, previous } : null;
}
@@ -26,7 +26,6 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
import { StorageService } from '../storage/storage.service';
import { StorageReleaseService } from '../storage/storage-release.service';
import { journeyThumbName } from '../memories/thumbnail.service';
import { JourneyService } from './journey.service';
import { JourneyBookService } from './journey-book.service';
import { JourneyPhotoCaptureService, type ProviderPhotoBatch, type ProviderPhotoTarget } from './journey-photo-capture.service';
@@ -472,16 +471,13 @@ export class JourneyController {
}
/**
* After a photo's row went: its file and its poster go at once unless another
* row (another journey's photo, a journey cover) still names them, and the
* thumbnail derived from a file that went goes with it (#2924).
* After a photo's row went: its file and its poster are queued for release,
* and go once no other row (another journey's photo, a journey cover) names
* them (#2924). The thumbnail derived from the file is the nightly thumbnail
* sweep's, which drops every thumb no photo row accounts for.
*/
private async releasePhoto(photo: { file_path?: string | null; thumbnail_path?: string | null }): Promise<void> {
const released = await this.release.releaseValues([photo.file_path, photo.thumbnail_path], { bare: 'journey' });
const filePath = photo.file_path;
if (!filePath?.startsWith('journey/')) return;
if (!released.some((ref) => ref.category === 'journey' && `journey/${ref.key}` === filePath)) return;
await this.storage.delete('journey', journeyThumbName(filePath)).catch(() => { /* already gone */ });
await this.release.releaseValues([photo.file_path, photo.thumbnail_path], { bare: 'journey' });
}
// ── Gallery (prefix /:id/gallery — before /:id) ─────────────────────────
@@ -0,0 +1,192 @@
import { AppSettings } from '../../db/entities/AppSettings.entity';
import { StorageReclaimMarks } from '../../db/entities/StorageReclaimMarks.entity';
import { StorageReleaseQueue } from '../../db/entities/StorageReleaseQueue.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import type { StorageReclaimMarksRepository } from '../../db/repositories/StorageReclaimMarks.repository';
import type { StorageReleaseQueueRepository } from '../../db/repositories/StorageReleaseQueue.repository';
import { DataPathsService } from '../app-config/data-paths.service';
import { logError, logInfo, logWarn } from '../audit/audit-log.logger';
import { withRequestContext } from '../database/request-context';
import { UnitOfWork } from '../database/unit-of-work';
import { INSTALL_ID_KEY, RECLAIM_DELETE_KEY, RECLAIM_RESET_KEYS } from './storage-reclaim.settings';
import { MikroORM } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const INSTALL_ID = /^[a-z0-9-]{8,64}$/;
/**
* Which installation this is, from a file in the data dir
* (`data/.trek-install-id`, made on the first start with a random id, mode
* 0600 like the at-rest key). No backup archive carries it and no restore
* writes it, so it stays with the data dir while a database can come from
* anywhere: a backup restore (in the app or at boot), a restart after a
* restore that failed half way, a copied `travel.db`.
*
* At boot, and after every reopen of the database, the database is reconciled
* with it: a database that names another id, or none, gets this one, and the
* reclaim state it brought (the deletion switch, the confirmations, both
* reports, the marks and the release queue) is dropped, since it describes
* another installation's storage. After a reopen that happens whatever the id
* says, as before: the rows a restore brings are an older history even when
* they are this installation's own.
*
* What it cannot tell apart is a data dir copied whole, the file with it: the
* wiki asks to give a clone its own data dir or delete the file. When the file
* can be neither read nor written, there is no id: nothing is deleted then.
*/
@Injectable()
export class StorageInstallService implements OnApplicationBootstrap {
/** Undefined until the file was read; null when it cannot be used. */
private fileId: string | null | undefined;
/** Bumped on every reopen: a pass or a release tick that saw another epoch stops. */
private currentEpoch = 0;
constructor(
private readonly dataPaths: DataPathsService,
private readonly uow: UnitOfWork,
private readonly orm: MikroORM,
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
@InjectRepository(StorageReclaimMarks) private readonly marks: StorageReclaimMarksRepository,
@InjectRepository(StorageReleaseQueue) private readonly queue: StorageReleaseQueueRepository,
) {}
async onApplicationBootstrap(): Promise<void> {
try {
await withRequestContext(this.orm, () => this.reconcile());
} catch (err) {
logError(`[storage] could not reconcile the database with this installation's id: ${messageOf(err)}`);
}
}
/** How many reopens this process has seen. */
get epoch(): number {
return this.currentEpoch;
}
/**
* This installation's id, read from its file once and made there on the
* first start; null when the file holds no id or cannot be read or written,
* which keeps every delete off (fail closed).
*/
installId(): string | null {
if (this.fileId === undefined) this.fileId = this.readOrCreate();
return this.fileId;
}
/**
* Makes the database name this installation. One that names another, or
* none, loses its reclaim state first. Answers whether it reset something
* a database had brought along.
*/
async reconcile(): Promise<boolean> {
const id = this.installId();
if (id === null) return false;
const stored = await this.appSettings.getValue(INSTALL_ID_KEY);
if (stored === id) return false;
const wasOn = await this.reset(id);
if (stored !== null) {
logInfo(
`[storage] the database names another installation (restored, cloned or copied): its reclaim state was ` +
`dropped${wasOn ? ', and deletion is off until an admin turns it on' : ''}`,
);
}
return stored !== null;
}
/**
* The database was swapped (a restore, the demo reset): what ran on the old
* one stops, and the new one gets this installation's id and no reclaim
* state. Contained, since a listener that throws fails the reopen.
*/
async afterReopen(): Promise<void> {
this.currentEpoch += 1;
try {
const id = this.installId();
const wasOn = await withRequestContext(this.orm, () => this.reset(id));
if (wasOn)
logInfo('[storage] the restored database had reclaim deletion on: it is off until an admin turns it on');
} catch (err) {
logError(`[storage] could not reset the reclaim state after the database was reopened: ${messageOf(err)}`);
}
}
/** Drops the reclaim state and writes `id` (when there is one); answers whether deletion was on. */
private async reset(id: string | null): Promise<boolean> {
return await this.uow.transactional(async () => {
const wasOn = (await this.appSettings.getValue(RECLAIM_DELETE_KEY)) !== null;
if (id !== null) await this.appSettings.upsertOrReplace(INSTALL_ID_KEY, id);
await RECLAIM_RESET_KEYS.reduce<Promise<void>>(async (previous, key) => {
await previous;
await this.appSettings.deleteValue(key);
}, Promise.resolve());
await this.marks.deleteAll();
await this.queue.deleteAll();
return wasOn;
});
}
private readOrCreate(): string | null {
const file = this.dataPaths.installIdFile;
const read = readId(file);
if (read !== 'missing') return read;
const id = randomUUID();
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
placeOnce(file, `${id}\n`);
return id;
} catch (err) {
if (codeOf(err) === 'EEXIST') {
const again = readId(file);
return again === 'missing' ? null : again;
}
logWarn(`[storage] could not write the install id file, nothing is reclaimed: ${messageOf(err)}`);
return null;
}
}
}
/**
* Puts `text` at `file` unless a file is there already (EEXIST), whole: it is
* written next to it and linked into place, so two processes starting at once
* on one data dir end up with one id and neither reads a half-written file. A
* volume without hard links gets an exclusive create instead.
*/
function placeOnce(file: string, text: string): void {
const spool = `${file}.${process.pid}.${randomUUID()}.tmp`;
fs.writeFileSync(spool, text, { mode: 0o600 });
try {
fs.linkSync(spool, file);
} catch (err) {
if (codeOf(err) === 'EEXIST') throw err;
fs.writeFileSync(file, text, { mode: 0o600, flag: 'wx' });
} finally {
fs.rmSync(spool, { force: true });
}
}
/** The id the file holds, 'missing' when there is no file, null when it is unreadable or holds no id. */
function readId(file: string): string | null | 'missing' {
try {
const text = fs.readFileSync(file, 'utf8').trim();
if (INSTALL_ID.test(text)) return text;
logWarn(`[storage] '${file}' holds no install id, nothing is reclaimed until it is deleted or fixed`);
return null;
} catch (err) {
if (codeOf(err) === 'ENOENT') return 'missing';
logWarn(`[storage] could not read the install id file, nothing is reclaimed: ${messageOf(err)}`);
return null;
}
}
function codeOf(err: unknown): string | undefined {
return typeof err === 'object' && err !== null && 'code' in err ? String((err as { code: unknown }).code) : undefined;
}
function messageOf(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
@@ -0,0 +1,166 @@
import { logWarn } from '../audit/audit-log.logger';
import { StorageReclaimRegistryService } from './storage-reclaim-registry.service';
import { RECLAIM_LEDGER, sharedLocationsOf } from './storage-reclaim.ledger';
import { storageOwnerMarkerInstall, storageOwnerMarkerKey, type StorageReferenceSource } from './storage-reclaim.types';
import { StorageRegistryService } from './storage-registry.service';
import { StorageService } from './storage.service';
import type { ServedCategory } from './storage.types';
import { Injectable } from '@nestjs/common';
import { Readable } from 'node:stream';
/** Why a category may not be judged, before anything of it is listed. */
type CategoryRefusal = 'not_reclaimed' | 'sources_missing' | 'shared_location';
/** Why the place a category lives in may not be judged: another installation's, or one nobody can tell. */
type OwnerRefusal = 'foreign_install' | 'owner_unknown';
/** A verdict the release queue reuses for this long; the nightly pass renews it every night. */
const OWNER_VERDICT_TTL_MS = 26 * 60 * 60 * 1000;
/** A failed look at the owner markers is not retried before this. */
const OWNER_FAILURE_TTL_MS = 60 * 60 * 1000;
interface CachedVerdict {
verdict: OwnerRefusal | null;
/** Where the category lived when it was judged: a changed storage config judges again. */
location: string;
until: number;
}
/**
* The guards the reclaim pass and the release queue share, so a deferred
* release refuses exactly what the nightly pass refuses:
* - `judgeable`: the ledger reclaims the category, every source it requires
* answers for all of it, and its location overlaps no other category's;
* - `ownerVerdict`: no other installation's owner marker lies where the
* category's objects do, and this one's could be written there.
*
* The owner verdict is cached per category with the location it was judged
* at. The pass judges it from the listing it makes anyway; the release queue
* reuses that verdict for a day and lists the category's root itself only
* when there is none, so a release never deletes in a category the pass
* would refuse as another installation's.
*/
@Injectable()
export class StorageReclaimGuardService {
private readonly verdicts = new Map<ServedCategory, CachedVerdict>();
constructor(
private readonly storage: StorageService,
private readonly registry: StorageRegistryService,
private readonly references: StorageReclaimRegistryService,
) {}
/**
* The sources to ask about a category, or why it may not be judged. Pass
* `sources` and `shared` to judge several categories against one snapshot.
*/
judgeable(
category: ServedCategory,
sources: readonly StorageReferenceSource[] = this.references.sources(),
shared: ReadonlySet<ServedCategory> = sharedLocationsOf(this.registry),
): { refused: CategoryRefusal } | { sources: StorageReferenceSource[] } {
const rule = RECLAIM_LEDGER[category];
if (!rule.reclaim) return { refused: 'not_reclaimed' };
const claiming = sources.filter((source) => source.claims.some((claim) => claim.category === category));
const whole = new Set(
claiming
.filter((source) => source.claims.some((claim) => claim.category === category && claim.prefix === ''))
.map((source) => source.id),
);
if (rule.requiredSources.some((id) => !whole.has(id))) return { refused: 'sources_missing' };
if (shared.has(category)) return { refused: 'shared_location' };
return { sources: claiming };
}
/**
* Whether this installation may judge where a category lives, from the
* owner markers a listing of its root found: not where another one's marker
* lies, and not where its own could not be written, since then a second
* installation cannot see it either. Writes this one's marker when it is
* missing, and caches the verdict.
*/
async ownerVerdict(
category: ServedCategory,
owners: ReadonlySet<string>,
installId: string,
now: number = Date.now(),
): Promise<OwnerRefusal | null> {
const location = this.locationOf(category);
const verdict = await this.judgeOwners(category, owners, installId);
this.verdicts.set(category, { verdict, location, until: now + OWNER_VERDICT_TTL_MS });
return verdict;
}
/**
* The owner verdict for a category, as the last judgement left it when that
* holds for where the category lives now; otherwise its root is listed for
* owner markers and judged afresh. A listing that fails reads as
* `owner_unknown` for an hour.
*/
async cachedOwnerVerdict(
category: ServedCategory,
installId: string,
now: number = Date.now(),
): Promise<OwnerRefusal | null> {
const cached = this.verdicts.get(category);
if (cached && cached.until > now && cached.location === this.locationOf(category)) return cached.verdict;
let owners: Set<string>;
try {
owners = await this.ownersAt(category);
} catch (err) {
logWarn(`[storage] could not look for owner markers in '${category}': ${messageOf(err)}`);
this.verdicts.set(category, {
verdict: 'owner_unknown',
location: this.locationOf(category),
until: now + OWNER_FAILURE_TTL_MS,
});
return 'owner_unknown';
}
return await this.ownerVerdict(category, owners, installId, now);
}
/** Forgets every verdict: the database or the installation they were judged for changed. */
forget(): void {
this.verdicts.clear();
}
private async judgeOwners(
category: ServedCategory,
owners: ReadonlySet<string>,
installId: string,
): Promise<OwnerRefusal | null> {
const foreign = [...owners].some((owner) => owner !== installId);
if (!owners.has(installId)) {
try {
await this.storage.put(category, storageOwnerMarkerKey(installId), Readable.from([installId]), {
contentType: 'text/plain',
});
} catch (err) {
logWarn(`[storage] reclaim could not write its owner marker into '${category}': ${messageOf(err)}`);
return foreign ? 'foreign_install' : 'owner_unknown';
}
}
if (foreign) logWarn(`[storage] another TREK installation keeps its uploads where '${category}' does: not judged`);
return foreign ? 'foreign_install' : null;
}
/** The owner markers at a category's root. */
private async ownersAt(category: ServedCategory): Promise<Set<string>> {
const owners = new Set<string>();
for await (const stat of this.storage.list(category, '', { shallow: true })) {
const owner = storageOwnerMarkerInstall(stat.key);
if (owner !== null) owners.add(owner);
}
return owners;
}
/** Where a category lives now: its backend, prefix and the backends' settings. */
private locationOf(category: ServedCategory): string {
const { backendName, keyPrefix } = this.registry.resolve(category);
return JSON.stringify([backendName, keyPrefix, this.registry.snapshot().backends]);
}
}
function messageOf(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
@@ -1,7 +1,9 @@
import { logWarn } from '../audit/audit-log.logger';
import { StorageReclaimRegistryService } from './storage-reclaim-registry.service';
import {
keySearchCore,
keysNamedIn,
storageKeysIn,
type StorageKeyOptions,
type StorageReferenceClaim,
type StorageReferenceSource,
@@ -22,11 +24,18 @@ import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
* learns which tables those are. A column that takes any string (an absolute
* URL of this instance, a path with a query, another category's object)
* cannot be matched with `IN`, so the values are read and parsed here:
* - `namedKeys` reads every value once, for a pass that judges a whole category;
* - `namedKeysByCategory` reads every value once and parses it for every
* category asked, for a pass that judges them all;
* - `namedKeys` reads every value once for one category, fresh, for the
* question a pass asks right before it deletes;
* - `referenced` reads only the rows whose value holds one of the keys' plain
* runs (`keySearchCore`, a `LIKE '%run%'` each), fresh, for the question a
* delete asks last and for a release right after a user's change. A key with
* no such run reads everything, as `namedKeys` does.
* runs (`keySearchCore`, a `LIKE '%run%'` each, at most MAX_LIKE_TERMS per
* query, as many queries as the keys need), for the release queue. A key
* with no such run is answered from one whole read; the release queue never
* asks about such a key, it leaves it to the nightly pass.
*
* The rows are read through the owner's TrekRepository, whose reads skip the
* identity map, so a pass over a large table keeps no entities alive.
*/
@Injectable()
export abstract class StorageReclaimSource implements StorageReferenceSource, OnApplicationBootstrap {
@@ -49,11 +58,32 @@ export abstract class StorageReclaimSource implements StorageReferenceSource, On
return keysNamedIn(category, await this.storedValues(null), this.keyOptions);
}
async namedKeysByCategory(categories: readonly ServedCategory[]): Promise<Map<ServedCategory, Set<string>>> {
const asked = new Set(categories.filter((category) => this.claimsCategory(category)));
const named = new Map<ServedCategory, Set<string>>([...asked].map((category) => [category, new Set<string>()]));
if (asked.size === 0) return named;
for (const value of await this.storedValues(null)) {
for (const ref of storageKeysIn(value, this.keyOptions)) {
named.get(ref.category as ServedCategory)?.add(ref.key.toLowerCase());
}
}
return named;
}
async referenced(category: ServedCategory, keys: readonly string[]): Promise<Set<string>> {
if (keys.length === 0 || !this.claimsCategory(category)) return new Set();
const cores = keys.map(keySearchCore);
const narrow = cores.every((core): core is string => core !== null) ? [...new Set(cores)] : null;
const named = keysNamedIn(category, await this.storedValues(narrow), this.keyOptions);
const named = new Set<string>();
if (cores.some((core) => core === null)) {
for (const key of keysNamedIn(category, await this.storedValues(null), this.keyOptions)) named.add(key);
} else {
const runs = [...new Set(cores.filter((core): core is string => core !== null))];
await chunked(runs, RUNS_PER_QUERY).reduce<Promise<void>>(async (previous, page) => {
await previous;
const values = await this.storedValues(page);
for (const key of keysNamedIn(category, values, this.keyOptions)) named.add(key);
}, Promise.resolve());
}
return new Set(keys.filter((key) => named.has(key.toLowerCase())));
}
@@ -70,11 +100,21 @@ export abstract class StorageReclaimSource implements StorageReferenceSource, On
}
}
/**
* The most LIKE terms one narrowed read may hold. SQLite parses an OR chain
* as a left-deep tree and refuses one deeper than 1000, so a read is kept
* far below that whatever it is asked about.
*/
export const MAX_LIKE_TERMS = 50;
/** Every source reads at most two columns, so this many runs keep one read within MAX_LIKE_TERMS. */
const RUNS_PER_QUERY = 25;
/**
* The filter a source narrows its columns with: rows where one of `fields`
* holds one of the runs, or every row where one of them is set when there are
* none to narrow by. The runs are `[a-z0-9-]` only, so they need no LIKE
* escaping; SQLite's LIKE ignores ASCII case, as the parser does.
* escaping; SQLite's LIKE ignores ASCII case, as the parser does. A filter
* past MAX_LIKE_TERMS is refused rather than handed to SQLite.
*/
export function likeAny<T>(
fields: ReadonlyArray<keyof T & string>,
@@ -84,5 +124,13 @@ export function likeAny<T>(
containing === null
? fields.map((field) => ({ [field]: { $ne: null } }))
: fields.flatMap((field) => containing.map((run) => ({ [field]: { $like: `%${run}%` } })));
if (terms.length > MAX_LIKE_TERMS) {
logWarn(`[storage] a reference lookup asked for ${terms.length} LIKE terms, more than ${MAX_LIKE_TERMS}`);
throw new Error(`a reference lookup may hold at most ${MAX_LIKE_TERMS} LIKE terms`);
}
return { $or: terms } as unknown as FilterQuery<T>;
}
function chunked<T>(items: readonly T[], size: number): T[][] {
return Array.from({ length: Math.ceil(items.length / size) }, (_page, i) => items.slice(i * size, (i + 1) * size));
}
@@ -49,13 +49,14 @@ export class StorageReclaimController {
/**
* Starts a report-only pass in the background and answers 202 at once; the
* admin state says `running` until its report lands. 409 on a demo or while
* another storage job, a reclaim pass included, runs.
* another storage job, a reclaim pass included, runs (a release tick, which
* takes seconds, is waited for).
*/
@Post('run')
@HttpCode(202)
@ResponseContract(storageReclaimRunStartedSchema)
async run(@CurrentUser() user: User, @Req() req: Request): Promise<{ started: true }> {
this.reclaim.start();
await this.reclaim.start();
await this.audit.writeAudit({
userId: user.id,
action: 'admin.storage_reclaim_run',
@@ -67,7 +68,9 @@ export class StorageReclaimController {
/**
* Lets the pass delete in a category whose unreferenced share is over a
* fifth, up to the count the last report showed. 409 when nothing waits.
* fifth, for the count the admin was shown (`unreferenced`). 409
* `reclaim_nothing_to_confirm` when nothing waits, `reclaim_confirm_stale`
* when the last report's count is no longer the one shown.
*/
@Post('confirm')
@HttpCode(200)
@@ -77,15 +80,12 @@ export class StorageReclaimController {
@Body() body: StorageReclaimConfirmDto,
@Req() req: Request,
): Promise<StorageReclaimState> {
const state = await this.reclaim.confirm(body.category);
const state = await this.reclaim.confirm(body.category, body.unreferenced);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.storage_reclaim_confirm',
ip: getClientIp(req),
details: {
category: body.category,
unreferenced: state.lastRun?.categories[body.category].unreferenced.objects ?? 0,
},
details: { category: body.category, unreferenced: body.unreferenced },
});
return state;
}
@@ -1,3 +1,5 @@
import { confirmationCovers, type ReclaimConfirmations } from './storage-reclaim.settings';
import type { ServedCategory } from './storage.types';
import type { StorageReclaimCategoryReport, StorageReclaimReport } from '@trek/shared';
/** One object a pass looks at. */
@@ -38,6 +40,28 @@ function countOf(candidates: readonly ReclaimCandidate[]): { objects: number; by
return { objects: candidates.length, bytes: candidates.reduce((sum, candidate) => sum + candidate.size, 0) };
}
/**
* The report as the admin state shows it: a category whose live confirmation
* covers its unreferenced count no longer reads as waiting for one, so the
* prompt goes as soon as the admin confirmed, not only after the next pass.
*/
export function withConfirmations(
report: StorageReclaimReport,
confirmations: ReclaimConfirmations,
now: number,
): StorageReclaimReport {
const categories = Object.fromEntries(
Object.entries(report.categories).map(([category, row]) => [
category,
row.confirmationRequired &&
confirmationCovers(confirmations[category as ServedCategory], row.unreferenced.objects, now)
? { ...row, confirmationRequired: false }
: row,
]),
) as StorageReclaimReport['categories'];
return { ...report, categories };
}
/** What a report says the pass deleted, over every category. */
export function deletedTotal(report: StorageReclaimReport): { objects: number; bytes: number } {
const rows = Object.values(report.categories);
+223 -193
View File
@@ -7,72 +7,82 @@ import type {
} from '../../db/repositories/StorageReclaimMarks.repository';
import { dbNow, parseDbTimestamp } from '../../db/types';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { logError, logInfo, logWarn } from '../audit/audit-log.logger';
import { logError, logInfo } from '../audit/audit-log.logger';
import { AuditService } from '../audit/audit.service';
import { DomainError } from '../common/domain-error';
import { DatabaseLifecycle } from '../database/database-lifecycle.service';
import { withRequestContext } from '../database/request-context';
import { UnitOfWork } from '../database/unit-of-work';
import { StorageInstallService } from './storage-install.service';
import { StorageJobsService } from './storage-jobs.service';
import { StorageReclaimGuardService } from './storage-reclaim-guard.service';
import { StorageReclaimRegistryService } from './storage-reclaim-registry.service';
import { inReclaimScope, RECLAIM_LEDGER, sharedLocationsOf } from './storage-reclaim.ledger';
import { inReclaimScope, RECLAIM_LEDGER, reclaimableCategories, sharedLocationsOf } from './storage-reclaim.ledger';
import {
deletedTotal,
newProgress,
reportOf,
withConfirmations,
type CategoryProgress,
type ReclaimCandidate,
} from './storage-reclaim.report';
import {
INSTALL_ID_KEY,
confirmationCovers,
confirmationExpired,
confirmationStale,
overConfirmShare,
RECLAIM_DELETE_KEY,
RECLAIM_DELETE_REPORT_KEY,
RECLAIM_REPORT_KEY,
RECLAIM_RESET_KEYS,
readReclaimConfirmations,
readReclaimDeleteEnabled,
readReclaimReport,
writeReclaimConfirmations,
type ReclaimConfirmations,
} from './storage-reclaim.settings';
import { storageOwnerMarkerInstall, storageOwnerMarkerKey, type StorageReferenceSource } from './storage-reclaim.types';
import { storageOwnerMarkerInstall, type StorageReferenceSource } from './storage-reclaim.types';
import { StorageRegistryService } from './storage-registry.service';
import { STORAGE_RELEASE_JOB } from './storage-release.service';
import { StorageService } from './storage.service';
import { SERVED_CATEGORIES, type ServedCategory } from './storage.types';
import { MikroORM } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, type OnApplicationBootstrap, type OnModuleInit } from '@nestjs/common';
import { Injectable, type OnModuleInit } from '@nestjs/common';
import type { StorageReclaimCategoryReport, StorageReclaimReport, StorageReclaimState } from '@trek/shared';
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
const HOUR_MS = 60 * 60 * 1000;
/** An object younger than this is never judged: every writer puts the bytes before it inserts the row. */
const RECLAIM_GRACE_MS = 24 * HOUR_MS;
/** How long an object must stay unreferenced, on every pass, before a pass may delete it. */
const RECLAIM_DELAY_MS = 7 * 24 * HOUR_MS;
/** More unreferenced than this share of a category waits for an admin's confirmation before anything goes. */
const CONFIRM_SHARE = 0.2;
/** Keys per re-check right before a delete: one fresh, narrowed question to every source. */
const RECHECK_BATCH = 100;
/** Deletes in flight at once. */
/** Eligible objects per fresh read of every source right before their deletes. */
const FRESH_BATCH = 1000;
/** Stats and deletes in flight at once. */
const DELETE_CONCURRENCY = 20;
/** Marks per statement: four bound values each, well under SQLite's limit. */
const MARK_BATCH = 100;
/** Listed objects between two looks at whether a restore began, so a restore waits seconds, not a whole listing. */
const LIST_CHECK_EVERY = 500;
const JOB_NAME = 'a storage reclaim';
const INSTALL_ID = /^[a-z0-9-]{8,64}$/;
/** What each source names, per category, from the one read a pass makes of it; an Error when that read failed. */
type NamedCache = Map<StorageReferenceSource, Map<ServedCategory, Set<string>> | Error>;
interface PassContext {
now: number;
installId: string;
/** Null when this installation has no id: nothing is judged then. */
installId: string | null;
/** Deletes may go now; turned off for the rest of the pass once the switch reads off or a backup starts. */
deleting: boolean;
/** A backup turned deleting off: every category with something due says so. */
backupHeld: boolean;
sources: readonly StorageReferenceSource[];
shared: ReadonlySet<ServedCategory>;
confirmed: Partial<Record<ServedCategory, number>>;
generation: number;
confirmations: ReclaimConfirmations;
/** Confirmations this pass found used up or run out, by the time they were given. */
spent: Map<ServedCategory, number>;
/** Read once, at the first category judged; each category's sets are dropped once it is judged. */
named: NamedCache | null;
epoch: number;
}
interface ReclaimRunOptions {
@@ -89,37 +99,37 @@ class ReclaimStopped extends Error {}
* more, found in two phases and, only when the admin switched deletion on,
* deleted. A pass lists each category the ledger allows at its root, skips
* objects younger than a day (the bytes of an upload land before its row),
* reads once per category what every registered reference source names, and
* marks the rest with the time it first saw them. An object goes only once its
* mark is a week old, after one more fresh question to every source about its
* batch, through `StorageService.delete` (so replicas follow and a demo keeps
* its bytes). What a user deletes or replaces is released at once instead
* reads every registered reference source once for the whole pass, and marks
* the rest with the time it first saw them. An object goes only once its mark
* is a week old, after a fresh read of every source right before its batch,
* through `StorageService.delete` (so replicas follow and a demo keeps its
* bytes). What a user deletes or replaces is queued for release instead
* (StorageReleaseService); the pass is the net for whatever that left behind.
*
* Safety, each failing closed:
* - a category whose sources are not all registered, whose location overlaps
* another category's, or where another installation's owner marker lies
* (or this one's cannot be written) is not judged;
* (or this one's cannot be written) is not judged (StorageReclaimGuardService);
* - a failed listing or lookup skips the category for the night; no skip
* touches the marks, so one bad night does not restart the week;
* - more than a fifth unreferenced deletes nothing until an admin confirmed
* that count (`confirm`), which no user action can do;
* the count they were shown (`confirm`), which no user action can do; the
* confirmation covers half as much again for two weeks, and is used up once
* the share is back under a fifth;
* - a delete that fails on the backend stops deleting in that category;
* - nothing is deleted while a backup reads the uploads, once the switch is
* turned off mid-pass, or on a demo instance;
* - a pass never runs beside a backfill, a category migration or a restore
* (the one-job slot of StorageJobsService, which a restore waits on), and
* stops when a restore begins or the database is reopened under it.
* - nothing is deleted while a backup reads the uploads (`backup_running`),
* once the switch is turned off mid-pass, or on a demo instance;
* - a pass never runs beside a backfill, a category migration, a release tick
* or a restore (the one-job slot of StorageJobsService, which a restore
* waits on), and stops when a restore begins or the database is reopened.
*
* Every restore turns deletion off and drops the marks, the confirmations and
* the reports: the restored rows may come from another installation, and this
* one keeps its own install id (written back over the restored one).
* Which installation this is comes from a file in the data dir, not from the
* database (StorageInstallService): a database restored, cloned or copied from
* another one loses its switch, marks, confirmations and reports at boot, and
* every reopen does the same.
*/
@Injectable()
export class StorageReclaimService implements OnModuleInit, OnApplicationBootstrap {
private generation = 0;
private knownInstallId: string | null = null;
export class StorageReclaimService implements OnModuleInit {
constructor(
private readonly storage: StorageService,
private readonly registry: StorageRegistryService,
@@ -128,64 +138,74 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
private readonly lifecycle: DatabaseLifecycle,
private readonly runtimeEnv: RuntimeEnvService,
private readonly audit: AuditService,
private readonly uow: UnitOfWork,
private readonly orm: MikroORM,
private readonly install: StorageInstallService,
private readonly guard: StorageReclaimGuardService,
@InjectRepository(StorageReclaimMarks) private readonly marks: StorageReclaimMarksRepository,
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
) {}
onModuleInit(): void {
this.lifecycle.onReopened(() => this.afterReopen());
this.lifecycle.onReopened(async () => {
this.guard.forget();
await this.install.afterReopen();
});
}
/** Makes the install id on the first boot, so a restore later has one of this install's own to keep. */
async onApplicationBootstrap(): Promise<void> {
try {
await withRequestContext(this.orm, () => this.installId());
} catch (err) {
logWarn(`[storage] could not read or make the install id: ${messageOf(err)}`);
}
}
/** Read only, so the admin state's GET never writes: an install id not made yet reads as deletion off. */
/**
* Read only, so the admin state's GET never writes (the install id file is
* made at boot). A category whose live confirmation covers its count reads
* as no longer waiting for one, before the next pass says so too.
*/
async state(): Promise<StorageReclaimState> {
const installId = this.knownInstallId ?? (await this.appSettings.getValue(INSTALL_ID_KEY));
const installId = this.install.installId();
const lastRun = await readReclaimReport(this.appSettings, RECLAIM_REPORT_KEY);
const confirmations = installId === null ? {} : await readReclaimConfirmations(this.appSettings, installId);
return {
deleteEnabled: installId !== null && (await readReclaimDeleteEnabled(this.appSettings, installId)),
running: this.jobs.exclusiveHolder === JOB_NAME,
lastRun: await readReclaimReport(this.appSettings, RECLAIM_REPORT_KEY),
lastRun: lastRun === null ? null : withConfirmations(lastRun, confirmations, Date.now()),
lastDeleteRun: await readReclaimReport(this.appSettings, RECLAIM_DELETE_REPORT_KEY),
};
}
/** 409 `reclaim_install_unknown` when this installation has no id to bind the switch to. */
async setDeleteEnabled(enabled: boolean): Promise<StorageReclaimState> {
if (enabled) await this.appSettings.upsertOrReplace(RECLAIM_DELETE_KEY, await this.installId());
if (enabled) await this.appSettings.upsertOrReplace(RECLAIM_DELETE_KEY, this.requireInstallId());
else await this.appSettings.deleteValue(RECLAIM_DELETE_KEY);
return await this.state();
}
/**
* The admin lets the pass delete in a category whose unreferenced share is
* over a fifth, up to the count the last report showed. 409 when the last
* report holds nothing that waits for it.
* over a fifth, for the count they were shown (`shown`). 409
* `reclaim_nothing_to_confirm` when the last report holds nothing waiting
* there, `reclaim_confirm_stale` when its count is no longer the one shown.
*/
async confirm(category: ServedCategory): Promise<StorageReclaimState> {
async confirm(category: ServedCategory, shown: number): Promise<StorageReclaimState> {
const installId = this.requireInstallId();
const row = (await readReclaimReport(this.appSettings, RECLAIM_REPORT_KEY))?.categories[category];
if (!row?.confirmationRequired) {
throw refusal('reclaim_nothing_to_confirm', 'nothing in this category waits for a confirmation');
}
const installId = await this.installId();
const counts = await readReclaimConfirmations(this.appSettings, installId);
await writeReclaimConfirmations(this.appSettings, installId, { ...counts, [category]: row.unreferenced.objects });
if (confirmationStale(shown, row.unreferenced.objects)) {
throw refusal('reclaim_confirm_stale', 'the last check found another count since, reload and confirm again');
}
const confirmations = await readReclaimConfirmations(this.appSettings, installId);
await writeReclaimConfirmations(this.appSettings, installId, {
...confirmations,
[category]: { count: shown, confirmedAt: Date.now() },
});
return await this.state();
}
/**
* One pass over every category in the caller, its report persisted and
* returned: the nightly job's. Refuses with a 409 on a demo instance and
* while another storage job holds the slot.
* while another storage job holds the slot; a release tick is waited for.
*/
async run(options: ReclaimRunOptions = {}): Promise<StorageReclaimReport> {
await this.releaseTickDone();
this.claimSlot();
try {
return await this.pass(options.reportOnly === true, options.now ?? new Date());
@@ -199,7 +219,8 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
* longer than a proxy waits for an answer. The same refusals as `run`, at
* once; the report lands on the admin state, which says `running` meanwhile.
*/
start(): void {
async start(): Promise<void> {
await this.releaseTickDone();
this.claimSlot();
void withRequestContext(this.orm, () => this.pass(true, new Date()))
.catch((err: unknown) => {
@@ -210,6 +231,11 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
});
}
/** A release tick holds the slot for seconds: a pass waits for it rather than losing its night. */
private async releaseTickDone(): Promise<void> {
if (this.jobs.exclusiveHolder === STORAGE_RELEASE_JOB) await this.jobs.exclusiveIdle();
}
private claimSlot(): void {
if (this.runtimeEnv.isDemoMode()) throw refusal('reclaim_demo', 'storage reclaim does not run on a demo instance');
if (!this.jobs.claimExclusive(JOB_NAME)) {
@@ -217,47 +243,12 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
}
}
/**
* This install's id, made and stored on first use.
*
* @txStandalone an idempotent lazy provision: the row is made once and only
* read after, whatever the caller writes besides.
*/
private async installId(): Promise<string> {
if (this.knownInstallId) return this.knownInstallId;
let id = await this.appSettings.getValue(INSTALL_ID_KEY);
if (id === null || !INSTALL_ID.test(id)) {
id = randomUUID();
await this.appSettings.upsertOrReplace(INSTALL_ID_KEY, id);
}
this.knownInstallId = id;
return id;
}
/**
* A restore or a reopen swapped the database: a pass in flight stops, and
* the restored rows get this install's id back, deletion off and no marks,
* confirmations or reports. Contained: a listener that throws fails the reopen.
*/
private async afterReopen(): Promise<void> {
this.generation += 1;
try {
await withRequestContext(this.orm, () =>
this.uow.transactional(async () => {
const wasOn = this.knownInstallId !== null && (await this.appSettings.getValue(RECLAIM_DELETE_KEY)) !== null;
if (this.knownInstallId) await this.appSettings.upsertOrReplace(INSTALL_ID_KEY, this.knownInstallId);
await RECLAIM_RESET_KEYS.reduce<Promise<void>>(async (previous, key) => {
await previous;
await this.appSettings.deleteValue(key);
}, Promise.resolve());
await this.marks.deleteAll();
if (wasOn)
logInfo('[storage] the restored database had reclaim deletion on: it is off until an admin turns it on');
}),
);
} catch (err) {
logError(`[storage] could not reset the reclaim state after the database was reopened: ${messageOf(err)}`);
private requireInstallId(): string {
const installId = this.install.installId();
if (installId === null) {
throw refusal('reclaim_install_unknown', 'this installation has no install id file, so nothing is reclaimed');
}
return installId;
}
/**
@@ -266,23 +257,30 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
*/
private async pass(reportOnly: boolean, now: Date): Promise<StorageReclaimReport> {
const wallStart = Date.now();
const installId = await this.installId();
const deleting = !reportOnly && (await readReclaimDeleteEnabled(this.appSettings, installId));
const installId = this.install.installId();
const deleting = !reportOnly && installId !== null && (await readReclaimDeleteEnabled(this.appSettings, installId));
const ctx: PassContext = {
now: now.getTime(),
installId,
deleting,
backupHeld: false,
sources: [...this.references.sources()],
shared: sharedLocationsOf(this.registry),
confirmed: await readReclaimConfirmations(this.appSettings, installId),
generation: this.generation,
confirmations: installId === null ? {} : await readReclaimConfirmations(this.appSettings, installId),
spent: new Map(),
named: null,
epoch: this.install.epoch,
};
const categories = {} as Record<ServedCategory, StorageReclaimCategoryReport>;
let stopped: StorageReclaimReport['stopped'] = null;
await SERVED_CATEGORIES.reduce<Promise<void>>(async (previous, category) => {
await previous;
const progress = newProgress();
if (stopped === null) {
if (!RECLAIM_LEDGER[category].reclaim) {
progress.skipped = 'not_reclaimed';
} else if (stopped !== null) {
progress.skipped = 'interrupted';
} else {
try {
await this.reclaimCategory(category, ctx, progress);
} catch (err) {
@@ -290,11 +288,10 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
if (stopped === 'failed') logError(`[storage] the reclaim pass failed in '${category}': ${messageOf(err)}`);
progress.skipped = 'interrupted';
}
} else {
progress.skipped = 'interrupted';
}
categories[category] = reportOf(progress);
}, Promise.resolve());
ctx.named = null;
const report: StorageReclaimReport = {
startedAt: ctx.now,
finishedAt: ctx.now + (Date.now() - wallStart),
@@ -302,10 +299,29 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
stopped,
categories,
};
if (stopped !== 'database_reopened') await this.pruneConfirmations(ctx);
await this.persist(report);
return report;
}
/**
* Drops the confirmations the pass found used up (the share is back under
* a fifth) or run out, unless an admin gave a new one meanwhile.
*
* @txStandalone one read and one write of the confirmations row, complete on its own.
*/
private async pruneConfirmations(ctx: PassContext): Promise<void> {
if (ctx.spent.size === 0 || ctx.installId === null) return;
const current = await readReclaimConfirmations(this.appSettings, ctx.installId);
const kept: ReclaimConfirmations = {};
for (const [category, confirmation] of Object.entries(current) as Array<
[ServedCategory, ReclaimConfirmations[ServedCategory]]
>) {
if (confirmation && ctx.spent.get(category) !== confirmation.confirmedAt) kept[category] = confirmation;
}
await writeReclaimConfirmations(this.appSettings, ctx.installId, kept);
}
/**
* The report as the last pass's, and as the last deleting pass's when it ran
* with deletion on; an audit entry (the system as actor) when it deleted.
@@ -344,21 +360,12 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
* delete stands alone, so a failure part way keeps what came before.
*/
private async reclaimCategory(category: ServedCategory, ctx: PassContext, progress: CategoryProgress): Promise<void> {
const rule = RECLAIM_LEDGER[category];
if (!rule.reclaim) {
progress.skipped = 'not_reclaimed';
return;
}
const sources = ctx.sources.filter((source) => claimsAny(source, category));
const answering = new Set(sources.filter((source) => answersFor(source, category)).map((source) => source.id));
if (rule.requiredSources.some((id) => !answering.has(id))) {
progress.skipped = 'sources_missing';
return;
}
if (ctx.shared.has(category)) {
progress.skipped = 'shared_location';
const judged = this.guard.judgeable(category, ctx.sources, ctx.shared);
if ('refused' in judged) {
progress.skipped = judged.refused;
return;
}
const { sources } = judged;
let listed: { objects: number; old: ReclaimCandidate[]; owners: Set<string> };
try {
@@ -370,7 +377,10 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
return;
}
progress.objects = listed.objects;
const owner = await this.ownerVerdict(category, listed.owners, ctx.installId);
const owner =
ctx.installId === null
? 'owner_unknown'
: await this.guard.ownerVerdict(category, listed.owners, ctx.installId, ctx.now);
if (owner !== null) {
progress.skipped = owner;
return;
@@ -378,7 +388,7 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
let named: Set<string>;
try {
named = await this.namedIn(category, sources);
named = await this.namedIn(category, sources, ctx);
} catch (err) {
logError(`[storage] reclaim could not check the references of '${category}': ${messageOf(err)}`);
progress.skipped = 'source_failed';
@@ -392,9 +402,20 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
await this.writeMarks(category, unreferenced, marks, ctx.now);
const cutoff = ctx.now - RECLAIM_DELAY_MS;
progress.eligible = unreferenced.filter((candidate) => markedBefore(marks.get(candidate.key), cutoff));
const over = overConfirmShare(unreferenced.length, listed.objects);
this.noteConfirmation(category, over, ctx);
progress.confirmationRequired =
unreferenced.length > listed.objects * CONFIRM_SHARE && unreferenced.length > (ctx.confirmed[category] ?? 0);
if (ctx.deleting && !progress.confirmationRequired) await this.deleteEligible(category, sources, ctx, progress);
over && !confirmationCovers(ctx.confirmations[category], unreferenced.length, ctx.now);
if (progress.confirmationRequired || progress.eligible.length === 0) return;
if (ctx.deleting) await this.deleteEligible(category, sources, ctx, progress);
else if (ctx.backupHeld) progress.skipped = 'backup_running';
}
/** A confirmation is spent once the share is back under a fifth, and void once it ran out. */
private noteConfirmation(category: ServedCategory, over: boolean, ctx: PassContext): void {
const confirmation = ctx.confirmations[category];
if (!confirmation) return;
if (!over || confirmationExpired(confirmation, ctx.now)) ctx.spent.set(category, confirmation.confirmedAt);
}
/** The root-level objects of a category, of them the ones past the grace period, and the owner markers there. */
@@ -424,53 +445,49 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
}
/**
* Whether this installation may judge the place a category lives in: not
* where another one's owner marker lies, and not where its own could not be
* written, since then a second installation cannot see it either.
* Every key of the category some source names, lowercased, from the one read
* of each source the pass makes at its first judged category; the category's
* sets are dropped once they are merged, so memory falls as the pass goes.
*/
private async ownerVerdict(
private async namedIn(
category: ServedCategory,
owners: ReadonlySet<string>,
installId: string,
): Promise<'foreign_install' | 'owner_unknown' | null> {
const foreign = [...owners].some((owner) => owner !== installId);
if (!owners.has(installId)) {
try {
await this.storage.put(category, storageOwnerMarkerKey(installId), Readable.from([installId]), {
contentType: 'text/plain',
});
} catch (err) {
logWarn(`[storage] reclaim could not write its owner marker into '${category}': ${messageOf(err)}`);
return foreign ? 'foreign_install' : 'owner_unknown';
}
}
if (foreign) logWarn(`[storage] another TREK installation keeps its uploads where '${category}' does: not judged`);
return foreign ? 'foreign_install' : null;
}
/** Every key of the category some source names, lowercased: one read per source for the whole category. */
private async namedIn(category: ServedCategory, sources: readonly StorageReferenceSource[]): Promise<Set<string>> {
const answers = await Promise.all(sources.map((source) => source.namedKeys(category)));
sources: readonly StorageReferenceSource[],
ctx: PassContext,
): Promise<Set<string>> {
const cache = (ctx.named ??= await this.readNamed(ctx.sources));
const named = new Set<string>();
for (const answer of answers) for (const key of answer) named.add(key);
for (const source of sources) {
const answer = cache.get(source);
if (answer === undefined) throw new Error(`the source '${source.id}' was not read`);
if (answer instanceof Error) throw answer;
for (const key of answer.get(category) ?? []) named.add(key);
}
for (const answer of cache.values()) if (!(answer instanceof Error)) answer.delete(category);
return named;
}
/** The keys some source names right now, lowercased: the fresh question before a delete. */
private async referencedIn(
category: ServedCategory,
keys: readonly string[],
sources: readonly StorageReferenceSource[],
): Promise<Set<string>> {
const answers = await Promise.all(
sources.map(async (source) => {
const asked = keys.filter((key) => claimsKey(source, category, key));
return asked.length === 0 ? new Set<string>() : await source.referenced(category, asked);
}),
);
const referenced = new Set<string>();
for (const answer of answers) for (const key of answer) referenced.add(key.toLowerCase());
return referenced;
/** One read of each source for every reclaimable category, one source after another to bound memory. */
private async readNamed(sources: readonly StorageReferenceSource[]): Promise<NamedCache> {
const categories = reclaimableCategories();
const cache: NamedCache = new Map();
await sources.reduce<Promise<void>>(async (previous, source) => {
await previous;
try {
cache.set(source, await source.namedKeysByCategory(categories));
} catch (err) {
logError(`[storage] reclaim could not read the reference source '${source.id}': ${messageOf(err)}`);
cache.set(source, err instanceof Error ? err : new Error(String(err)));
}
}, Promise.resolve());
return cache;
}
/** Every key of the category some source names right now, lowercased: the fresh read before deletes. */
private async namedNow(category: ServedCategory, sources: readonly StorageReferenceSource[]): Promise<Set<string>> {
const answers = await Promise.all(sources.map((source) => source.namedKeys(category)));
const named = new Set<string>();
for (const answer of answers) for (const key of answer) named.add(key.toLowerCase());
return named;
}
/**
@@ -499,7 +516,8 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
/**
* The eligible objects a batch at a time, while deleting may still go; a
* batch whose backend failed stops the category with `delete_failed`.
* batch whose backend failed stops the category with `delete_failed`, a
* backup that started stops it with `backup_running`.
*
* @txIndependent a batch at a time, each delete standing alone, so a failure
* leaves the others' work standing.
@@ -510,9 +528,13 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
ctx: PassContext,
progress: CategoryProgress,
): Promise<void> {
await chunk(progress.eligible, RECHECK_BATCH).reduce<Promise<void>>(async (previous, batch) => {
await chunk(progress.eligible, FRESH_BATCH).reduce<Promise<void>>(async (previous, batch) => {
await previous;
if (progress.skipped !== null || !(await this.mayDelete(ctx))) return;
if (progress.skipped !== null) return;
if (!(await this.mayDelete(ctx))) {
if (ctx.backupHeld) progress.skipped = 'backup_running';
return;
}
this.assertSameDatabase(ctx);
const failed = await this.deleteBatch(category, batch, sources, ctx, progress);
if (failed) progress.skipped = 'delete_failed';
@@ -521,18 +543,22 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
/** Deleting goes on while the switch stays on and no backup reads the uploads; once off, off for the pass. */
private async mayDelete(ctx: PassContext): Promise<boolean> {
if (!ctx.deleting) return false;
if (this.jobs.backupRunning || !(await readReclaimDeleteEnabled(this.appSettings, ctx.installId))) {
if (!ctx.deleting || ctx.installId === null) return false;
if (this.jobs.backupRunning) {
ctx.deleting = false;
ctx.backupHeld = true;
logInfo('[storage] a backup is reading the uploads: this reclaim pass deletes nothing more, the next one will');
} else if (!(await readReclaimDeleteEnabled(this.appSettings, ctx.installId))) {
ctx.deleting = false;
}
return ctx.deleting;
}
/**
* Checks each object is still the old one the pass judged, asks every source
* about the batch again, fresh, and deletes what none of them names. The
* question goes last so nothing slower than the deletes sits between the
* answer and them. True when the backend failed, so the category stops.
* Checks each object is still the old one the pass judged, reads every
* source again, fresh and whole, and deletes what none of them names. The
* read goes last so nothing slower than the deletes sits between the answer
* and them. True when the backend failed, so the category stops.
*
* @txIndependent every object goes on its own; its mark goes after it.
*/
@@ -545,7 +571,7 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
): Promise<boolean> {
let stats: Awaited<ReturnType<StorageService['stat']>>[];
try {
stats = await Promise.all(batch.map((candidate) => this.storage.stat(category, candidate.key)));
stats = await inGroups(batch, (candidate) => this.storage.stat(category, candidate.key));
} catch (err) {
logError(`[storage] reclaim stopped deleting in '${category}', the backend failed: ${messageOf(err)}`);
return true;
@@ -562,7 +588,7 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
if (due.length === 0) return false;
let referenced: Set<string>;
try {
referenced = await this.referencedIn(category, keysOf(due), sources);
referenced = await this.namedNow(category, sources);
} catch (err) {
logError(
`[storage] reclaim kept ${due.length} '${category}' object(s), their re-check failed: ${messageOf(err)}`,
@@ -594,12 +620,20 @@ export class StorageReclaimService implements OnModuleInit, OnApplicationBootstr
/** A restore that began, or a reopen that happened, since the pass started ends it. */
private assertSameDatabase(ctx: PassContext): void {
if (this.jobs.restoreRunning) throw new ReclaimStopped('a restore began during the pass');
if (this.generation !== ctx.generation) throw new ReclaimStopped('the database was reopened during the pass');
if (this.install.epoch !== ctx.epoch) throw new ReclaimStopped('the database was reopened during the pass');
}
}
/** A 409 whose body names the refusal in `code` next to the text, so the client can say it in the user's language. */
function refusal(code: 'reclaim_busy' | 'reclaim_demo' | 'reclaim_nothing_to_confirm', message: string): DomainError {
function refusal(
code:
| 'reclaim_busy'
| 'reclaim_demo'
| 'reclaim_nothing_to_confirm'
| 'reclaim_confirm_stale'
| 'reclaim_install_unknown',
message: string,
): DomainError {
return new DomainError(409, message, { code, details: { code } });
}
@@ -608,20 +642,6 @@ function markedBefore(mark: ReclaimMarkRow | undefined, cutoff: number): boolean
return firstSeen !== null && firstSeen.getTime() <= cutoff;
}
/** The source answers for some part of the category. */
function claimsAny(source: StorageReferenceSource, category: ServedCategory): boolean {
return source.claims.some((claim) => claim.category === category);
}
/** The source answers for the whole category: what the ledger's required sources must do. */
function answersFor(source: StorageReferenceSource, category: ServedCategory): boolean {
return source.claims.some((claim) => claim.category === category && claim.prefix === '');
}
function claimsKey(source: StorageReferenceSource, category: ServedCategory, key: string): boolean {
return source.claims.some((claim) => claim.category === category && key.startsWith(claim.prefix));
}
function keysOf(candidates: readonly ReclaimCandidate[]): string[] {
return candidates.map((candidate) => candidate.key);
}
@@ -630,6 +650,16 @@ function chunk<T>(items: readonly T[], size: number): T[][] {
return Array.from({ length: Math.ceil(items.length / size) }, (_page, i) => items.slice(i * size, (i + 1) * size));
}
/** `work` over every item, DELETE_CONCURRENCY at a time, the answers in order; the first failure rejects. */
async function inGroups<T, R>(items: readonly T[], work: (item: T) => Promise<R>): Promise<R[]> {
const answers: R[] = [];
await chunk(items, DELETE_CONCURRENCY).reduce<Promise<void>>(async (previous, group) => {
await previous;
answers.push(...(await Promise.all(group.map(work))));
}, Promise.resolve());
return answers;
}
function messageOf(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
@@ -6,13 +6,13 @@ import { storageReclaimReportSchema, type StorageReclaimReport } from '@trek/sha
import { z } from 'zod';
/**
* This installation's random id, made on its first boot. A backup restore
* brings back the app_settings of whatever install made the backup, so the
* reclaim pass keeps the id it booted with and writes it back over the
* restored one (StorageReclaimService, on every database reopen). Outside the
* `storage.` prefix on purpose: the documented reset of the storage config
* (deleting every `storage.%` row) must not make this install a stranger to
* the owner markers it wrote.
* The install id the database was last reconciled with: a copy of the one in
* `data/.trek-install-id` (StorageInstallService), which no backup carries and
* no restore writes. A database whose row names another id came from another
* installation (a restore, a clone, a copied file), and its reclaim state is
* reset before anything reads it. Outside the `storage.` prefix on purpose:
* the documented reset of the storage config (deleting every `storage.%` row)
* must not read as a database from elsewhere.
*/
export const INSTALL_ID_KEY = 'install_id';
/**
@@ -26,10 +26,15 @@ export const RECLAIM_REPORT_KEY = 'storage.reclaim_report';
export const RECLAIM_DELETE_REPORT_KEY = 'storage.reclaim_delete_report';
/**
* Per category, the unreferenced count an admin confirmed when that was more
* than a fifth of the category, for the install it was confirmed on.
* than a fifth of the category, with when, for the install it was confirmed on.
*/
const RECLAIM_CONFIRMED_KEY = 'storage.reclaim_confirmed';
/** More unreferenced than this share of a category waits for an admin's confirmation before anything goes. */
const CONFIRM_SHARE = 0.2;
/** How long a confirmation lets a pass delete, unless the share fell under the line before. */
const CONFIRM_TTL_MS = 14 * 24 * 60 * 60 * 1000;
/** Every row a restore or a foreign database must not carry over into this install's reclaim. */
export const RECLAIM_RESET_KEYS = [
RECLAIM_DELETE_KEY,
@@ -40,9 +45,55 @@ export const RECLAIM_RESET_KEYS = [
const confirmationsSchema = z.object({
installId: z.string(),
counts: z.record(z.string(), z.number().int().nonnegative()),
categories: z.record(
z.string(),
z.object({ count: z.number().int().nonnegative(), confirmedAt: z.number().int().nonnegative() }),
),
});
/** One admin confirmation: the unreferenced count the admin was shown, and when (epoch ms). */
interface ReclaimConfirmation {
count: number;
confirmedAt: number;
}
export type ReclaimConfirmations = Partial<Record<ServedCategory, ReclaimConfirmation>>;
/** More of the category is unreferenced than a fifth of it. */
export function overConfirmShare(unreferenced: number, objects: number): boolean {
return unreferenced > objects * CONFIRM_SHARE;
}
/** The confirmation ran out: older than CONFIRM_TTL_MS. */
export function confirmationExpired(confirmation: ReclaimConfirmation, now: number): boolean {
return now - confirmation.confirmedAt >= CONFIRM_TTL_MS;
}
/**
* A live confirmation covers this many unreferenced objects: up to one and a
* half times the count the admin confirmed, and at least 100 more, so the
* orphans a busy instance adds before the backlog has drained do not undo it,
* while a surge well past it waits for a new one.
*/
export function confirmationCovers(
confirmation: ReclaimConfirmation | undefined,
unreferenced: number,
now: number,
): boolean {
if (!confirmation || confirmationExpired(confirmation, now)) return false;
return unreferenced <= Math.max(confirmation.count * 1.5, confirmation.count + 100);
}
/**
* The count the admin was shown is not the one the last report holds: more
* than a tenth of it, or more than 50 objects, apart. Then a pass ran since
* the page loaded, and the admin would confirm a number they never saw.
*/
export function confirmationStale(shown: number, current: number): boolean {
const apart = Math.abs(current - shown);
return apart > shown * 0.1 || apart > 50;
}
/** Fail closed: only this install's own id switches deletion on. */
export async function readReclaimDeleteEnabled(
appSettings: AppSettingsRepository,
@@ -66,26 +117,31 @@ export async function readReclaimReport(
}
}
/** The confirmed counts of this install; none for a row another install wrote or that no longer parses. */
/** The confirmations of this install; none for a row another install wrote or that no longer parses. */
export async function readReclaimConfirmations(
appSettings: AppSettingsRepository,
installId: string,
): Promise<Partial<Record<ServedCategory, number>>> {
): Promise<ReclaimConfirmations> {
const value = await appSettings.getValue(RECLAIM_CONFIRMED_KEY);
if (value == null) return {};
try {
const parsed = confirmationsSchema.parse(JSON.parse(value));
return parsed.installId === installId ? (parsed.counts as Partial<Record<ServedCategory, number>>) : {};
return parsed.installId === installId ? (parsed.categories as ReclaimConfirmations) : {};
} catch {
logWarn('[storage] the stored reclaim confirmations are unparseable, treating them as none');
return {};
}
}
/** Stores this install's confirmations; none left drops the row. */
export async function writeReclaimConfirmations(
appSettings: AppSettingsRepository,
installId: string,
counts: Partial<Record<ServedCategory, number>>,
categories: ReclaimConfirmations,
): Promise<void> {
await appSettings.upsertOrReplace(RECLAIM_CONFIRMED_KEY, JSON.stringify({ installId, counts }));
if (Object.keys(categories).length === 0) {
await appSettings.deleteValue(RECLAIM_CONFIRMED_KEY);
return;
}
await appSettings.upsertOrReplace(RECLAIM_CONFIRMED_KEY, JSON.stringify({ installId, categories }));
}
@@ -52,16 +52,21 @@ export interface StorageReferenceSource {
*/
readonly claims: ReadonlyArray<StorageReferenceClaim>;
/**
* Every key of `category` the rows name, lowercased: one read of the whole
* column set, which a pass makes once per category. A row that is trashed,
* pending or otherwise not gone counts. Throwing stops the pass for the
* category; it never deletes on a failed answer.
* Every key of `category` the rows name, lowercased: one fresh read of the
* whole column set, the question a pass asks right before it deletes. A row
* that is trashed, pending or otherwise not gone counts. Throwing stops the
* pass for the category; it never deletes on a failed answer.
*/
namedKeys(category: ServedCategory): Promise<Set<string>>;
/**
* Of these category-relative keys (as `StorageService.list` yields them, a
* batch at a time), the ones a row names right now: the question a delete
* asks last, read fresh and narrowed to the rows that can name them.
* The same for several categories from one read, each the source claims
* keyed in the answer: what a pass reads once and judges every category by.
*/
namedKeysByCategory(categories: readonly ServedCategory[]): Promise<Map<ServedCategory, Set<string>>>;
/**
* Of these category-relative keys (as `StorageService.list` yields them), the
* ones a row names right now, read narrowed to the rows that can name them:
* the question the release queue asks before it deletes.
*/
referenced(category: ServedCategory, keys: readonly string[]): Promise<Set<string>>;
}
@@ -0,0 +1,39 @@
import { logDebug, logError } from '../audit/audit-log.logger';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { StorageReleaseService } from './storage-release.service';
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
/**
* Every ten minutes, what users' deletes and replaces queued and what is due
* now (StorageReleaseService): each object asked about once more and deleted
* when no row names it. The registrar is handed the tick's own promise, so
* its lease covers the whole tick.
*/
@Injectable()
export class StorageReleaseJob implements OnApplicationBootstrap {
constructor(
private readonly registrar: CronRegistrarService,
private readonly release: StorageReleaseService,
) {}
onApplicationBootstrap(): void {
if (!this.registrar.isEnabled()) return;
this.registrar.register('storage-release', '*/10 * * * *', () => this.tick());
}
/**
* @txIndependent every delete and every dropped row stands on its own
* (StorageReleaseService.processDue).
*/
async tick(now: Date = new Date()): Promise<void> {
try {
const result = await this.release.processDue(now);
if (result.skipped !== null) logDebug(`Storage release skipped: ${result.skipped}`);
else if (result.deleted + result.kept > 0) {
logDebug(`Storage release: deleted ${result.deleted}, kept ${result.kept} still named`);
}
} catch (err: unknown) {
logError(`Storage release: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
@@ -1,38 +1,105 @@
import { StorageReleaseQueue } from '../../db/entities/StorageReleaseQueue.entity';
import type {
ReleaseQueueRow,
StorageReleaseQueueRepository,
} from '../../db/repositories/StorageReleaseQueue.repository';
import { dbNow } from '../../db/types';
import { logDebug, logWarn } from '../audit/audit-log.logger';
import { UnitOfWork } from '../database/unit-of-work';
import { StorageInstallService } from './storage-install.service';
import { StorageJobsService } from './storage-jobs.service';
import { isValidKey } from './storage-keys';
import { StorageReclaimGuardService } from './storage-reclaim-guard.service';
import { StorageReclaimRegistryService } from './storage-reclaim-registry.service';
import { inReclaimScope, RECLAIM_LEDGER, sharedLocationsOf } from './storage-reclaim.ledger';
import { inReclaimScope, RECLAIM_LEDGER, reclaimableCategories, sharedLocationsOf } from './storage-reclaim.ledger';
import {
keySearchCore,
storageKeysIn,
storageOwnerMarkerInstall,
type StorageKeyOptions,
type StorageKeyRef,
type StorageReferenceSource,
} from './storage-reclaim.types';
import { StorageRegistryService } from './storage-registry.service';
import { StorageService } from './storage.service';
import { SERVED_CATEGORIES, type ServedCategory } from './storage.types';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
const HOUR_MS = 60 * 60 * 1000;
/**
* How long a released object waits before the queue may delete it: longer
* than every window in TREK that can write a value back that names it.
* - the assistant: a change card's undo lasts 15 minutes (UNDO_WINDOW_MS), and
* a proposed change may be approved for 24 hours (APPROVAL_TTL_MS), so a
* change proposed with an old cover path can still be applied a day later;
* - the offline mutation queue retries a write the server failed with a 5xx
* for about two hours (MAX_SERVER_ERROR_ATTEMPTS, doubling from 30 s);
* - the planner's own undo (usePlannerHistory) and a write queued while
* offline are bounded by nothing but the tab and the outage: those are
* unbounded, and the place undo does not offer to undo a delete that took an
* uploaded image (placeUndo.ts) for that reason.
* So the longest bounded window is a day; two hours on top cover the clock
* and the job's interval. What comes back later than that finds the object
* gone, as it did before the reclaim when every delete was immediate.
*/
export const RELEASE_DELAY_MS = 26 * HOUR_MS;
/** A queued row older than this past its time belongs to the nightly pass: its category stayed refused. */
const QUEUE_MAX_AGE_MS = 30 * 24 * HOUR_MS;
/** Keys one stored value may queue: a value naming more than that is no upload path of ours. */
const MAX_KEYS_PER_VALUE = 16;
/** Keys one call may queue; the rest is the nightly pass's. */
const MAX_KEYS_PER_CALL = 1000;
/** No more rows than this wait at once; a release past it is the nightly pass's. */
const QUEUE_LIMIT = 50_000;
/** Rows per insert: three bound values each. */
const ENQUEUE_BATCH = 100;
/** Rows one tick looks at. */
const TICK_LIMIT = 200;
/** Keys per question to the sources. */
const ASK_BATCH = 50;
/** Deletes in flight at once. */
const DELETE_CONCURRENCY = 10;
const SERVED = new Set<string>(SERVED_CATEGORIES);
/** The name a release tick holds the one-job slot under. */
export const STORAGE_RELEASE_JOB = 'a storage release';
export interface ReleaseTickResult {
/** Why the tick did nothing, or null when it ran. */
skipped: 'busy' | 'no_install_id' | null;
deleted: number;
/** Rows dropped because a row still names the object, or the instance keeps it. */
kept: number;
}
/** A restore began, a backup started or the database was swapped mid-tick: the rest waits for the next one. */
class ReleaseStopped extends Error {}
/**
* Deletes what a user's change left unnamed, right after it: a replaced or
* removed cover, place image or avatar, a deleted journey photo or trip file.
* The owner calls it once its transaction committed, with the values the rows
* held, and every registered reference source (the nightly pass's sources and
* parser) is asked whether anything still names each object. Only what no row
* names goes, through `StorageService.delete` (replicas follow, a demo keeps
* its bytes), so an object a trip copy, a journey or a saved place still shows
* stays, unlike before (#2924), while a deleted photo is gone at once as it
* always was.
* What a user's change left unnamed: a replaced or removed cover, place image
* or avatar, a deleted journey photo or trip file. The owner hands the values
* its rows held to `releaseValues` once its transaction committed, and every
* object they name is queued, which is all the request pays. Nothing goes
* before RELEASE_DELAY_MS, so an undo or a late write that brings the value
* back keeps its object.
*
* The pass's own guards hold: only the root of a reclaimable category, never
* an owner marker, never while a restore runs, never where a required source
* is missing or the category's location overlaps another's. It never fails
* the caller's request: anything it leaves (an error, a guard) is the nightly
* pass's to find. Releases are logged at debug, not audited one by one.
* A job (StorageReleaseJob) then takes the due rows, asks every registered
* reference source again about them (the nightly pass's sources and parser)
* and deletes only what none of them names, through `StorageService.delete`
* (replicas follow, a demo keeps its bytes); the row goes either way. So an
* object a trip copy, a journey or a saved place still shows stays (#2924),
* while a deleted photo still goes as it did before, a day later.
*
* The queue refuses what the pass refuses (StorageReclaimGuardService): only
* the root of a reclaimable category, never an owner marker, never where a
* required source is missing, where the category's location overlaps another
* one's, or where another installation's owner marker lies (or this one's
* cannot be written). It does nothing while a restore or a backup runs or
* another storage job holds the slot, and without an install id. A refused
* category's rows wait, up to QUEUE_MAX_AGE_MS. Releases run whatever the
* nightly deletion switch says: they are what a delete always did. Queueing
* never fails the caller's request.
*/
@Injectable()
export class StorageReleaseService {
@@ -41,75 +108,186 @@ export class StorageReleaseService {
private readonly registry: StorageRegistryService,
private readonly references: StorageReclaimRegistryService,
private readonly jobs: StorageJobsService,
private readonly install: StorageInstallService,
private readonly guard: StorageReclaimGuardService,
private readonly uow: UnitOfWork,
@InjectRepository(StorageReleaseQueue) private readonly queue: StorageReleaseQueueRepository,
) {}
/**
* The objects these stored values name (any spelling a column holds), each
* deleted when no row names it any more. Answers the ones that went.
* Queues the objects these stored values name (any spelling a column holds)
* for a release once they are due; answers how many were queued. Never
* throws: what it cannot queue is the nightly pass's.
*
* @txStandalone a queue row stands on its own whatever the caller wrote:
* the job asks every source again before anything goes.
*/
async releaseValues(
values: ReadonlyArray<string | null | undefined>,
options: StorageKeyOptions = {},
): Promise<StorageKeyRef[]> {
const byCategory = new Map<ServedCategory, Set<string>>();
for (const value of values) {
for (const ref of storageKeysIn(value, options)) {
if (!SERVED.has(ref.category)) continue;
const category = ref.category as ServedCategory;
const keys = byCategory.get(category) ?? new Set<string>();
keys.add(ref.key);
byCategory.set(category, keys);
}
}
const released = await Promise.all(
[...byCategory].map(async ([category, keys]) =>
(await this.releaseIfUnreferenced(category, [...keys])).map((key) => ({ category, key })),
),
);
return released.flat();
}
/** These keys of one category, each deleted when it exists and no row names it. Answers the ones that went. */
async releaseIfUnreferenced(category: ServedCategory, keys: readonly string[]): Promise<string[]> {
): Promise<number> {
try {
const candidates = [...new Set(keys)].filter(
(key) => inReclaimScope(key) && isValidKey(key) && storageOwnerMarkerInstall(key) === null,
);
if (candidates.length === 0) return [];
const sources = this.sourcesFor(category);
if (sources === null) return [];
const referenced = await this.referencedIn(category, candidates, sources);
const unnamed = candidates.filter(
(key) => !referenced.has(key.toLowerCase()) && !this.storage.keepsOnDelete(category, key),
);
const released = await Promise.all(unnamed.map((key) => this.deleteIfPresent(category, key)));
return unnamed.filter((_key, i) => released[i]);
const rows = releasable(values, options);
if (rows.length === 0) return 0;
if ((await this.queue.size()) + rows.length > QUEUE_LIMIT) {
logWarn(`[storage] the release queue is full, ${rows.length} object(s) are left to the nightly pass`);
return 0;
}
const dueAt = dbNow(new Date(Date.now() + RELEASE_DELAY_MS));
await this.uow.transactional(async () => {
await chunk(rows, ENQUEUE_BATCH).reduce<Promise<void>>(async (previous, page) => {
await previous;
await this.queue.enqueue(page, dueAt);
}, Promise.resolve());
});
return rows.length;
} catch (err) {
logWarn(`[storage] could not release '${category}' objects, the nightly pass will: ${messageOf(err)}`);
return [];
logWarn(`[storage] could not queue released objects, the nightly pass will find them: ${messageOf(err)}`);
return 0;
}
}
/** The sources to ask, or null when the category may not be released from now. */
private sourcesFor(category: ServedCategory): readonly StorageReferenceSource[] | null {
const rule = RECLAIM_LEDGER[category];
if (!rule.reclaim || this.jobs.restoreRunning) return null;
const sources = this.references.sources().filter((source) => source.claims.some((c) => c.category === category));
const whole = new Set(
sources
.filter((source) => source.claims.some((c) => c.category === category && c.prefix === ''))
.map((source) => source.id),
);
if (rule.requiredSources.some((id) => !whole.has(id))) return null;
if (sharedLocationsOf(this.registry).has(category)) return null;
return sources;
/**
* One tick of the release job: the due rows of every category the guards
* let through, at most TICK_LIMIT, each object deleted when no source names
* it. Holds the one-job slot while it runs, so a restore waits for it and a
* backfill or a migration never runs beside it.
*
* @txIndependent every delete and every dropped row stands on its own: a
* tick cut short leaves the rest queued.
*/
async processDue(now: Date = new Date()): Promise<ReleaseTickResult> {
const result: ReleaseTickResult = { skipped: null, deleted: 0, kept: 0 };
const installId = this.install.installId();
if (installId === null) return { ...result, skipped: 'no_install_id' };
if (this.jobs.backupRunning || !this.jobs.claimExclusive(STORAGE_RELEASE_JOB))
return { ...result, skipped: 'busy' };
try {
await this.drain(installId, now.getTime(), this.install.epoch, result);
} finally {
this.jobs.releaseExclusive(STORAGE_RELEASE_JOB);
}
return result;
}
private async referencedIn(
/** @txIndependent see processDue. */
private async drain(installId: string, now: number, epoch: number, result: ReleaseTickResult): Promise<void> {
await this.queue.deleteDueBefore(dbNow(new Date(now - QUEUE_MAX_AGE_MS)));
const due = new Set(await this.queue.dueCategories(dbNow(new Date(now))));
if (due.size === 0) return;
const allowed = await this.allowedCategories(due, installId, now);
const rows = await this.queue.dueIn([...allowed.keys()], dbNow(new Date(now)), TICK_LIMIT);
const byCategory = new Map<ServedCategory, string[]>();
for (const row of rows) {
const category = row.category as ServedCategory;
byCategory.set(category, [...(byCategory.get(category) ?? []), row.key]);
}
try {
await [...byCategory].reduce<Promise<void>>(async (previous, [category, keys]) => {
await previous;
await this.releaseCategory(category, keys, allowed.get(category) ?? [], epoch, result);
}, Promise.resolve());
} catch (err) {
if (!(err instanceof ReleaseStopped)) throw err;
logDebug(`[storage] a release tick stopped: ${err.message}`);
}
}
/** Of the categories with rows due, the ones the guards let a release judge now, each with the sources to ask. */
private async allowedCategories(
due: ReadonlySet<string>,
installId: string,
now: number,
): Promise<Map<ServedCategory, StorageReferenceSource[]>> {
const sources = this.references.sources();
const shared = sharedLocationsOf(this.registry);
const allowed = new Map<ServedCategory, StorageReferenceSource[]>();
const asked = reclaimableCategories().filter((category) => due.has(category));
await asked.reduce<Promise<void>>(async (previous, category) => {
await previous;
const judged = this.guard.judgeable(category, sources, shared);
if ('refused' in judged) return;
if ((await this.guard.cachedOwnerVerdict(category, installId, now)) !== null) return;
allowed.set(category, judged.sources);
}, Promise.resolve());
return allowed;
}
/**
* The due keys of one category, ASK_BATCH at a time: asked about, then
* deleted when unnamed. A failed question or delete stops the category for
* this tick and leaves its rows queued.
*
* @txIndependent every delete stands alone, its row goes after it.
*/
private async releaseCategory(
category: ServedCategory,
keys: readonly string[],
sources: readonly StorageReferenceSource[],
epoch: number,
result: ReleaseTickResult,
): Promise<void> {
let failed = false;
await chunk(keys, ASK_BATCH).reduce<Promise<void>>(async (previous, batch) => {
await previous;
if (failed) return;
this.assertUndisturbed(epoch);
failed = await this.releaseBatch(category, batch, sources, epoch, result);
}, Promise.resolve());
}
/** True when the batch failed part way, so the category stops for this tick. */
private async releaseBatch(
category: ServedCategory,
batch: readonly string[],
sources: readonly StorageReferenceSource[],
epoch: number,
result: ReleaseTickResult,
): Promise<boolean> {
let referenced: Set<string>;
try {
referenced = await this.referencedIn(category, batch, sources);
} catch (err) {
logWarn(`[storage] could not ask about released '${category}' objects, they wait: ${messageOf(err)}`);
return true;
}
const kept = batch.filter((key) => referenced.has(key.toLowerCase()) || this.storage.keepsOnDelete(category, key));
const unnamed = batch.filter((key) => !kept.includes(key));
this.assertUndisturbed(epoch);
const deleted: string[] = [];
let failure: unknown = null;
await chunk(unnamed, DELETE_CONCURRENCY).reduce<Promise<void>>(async (previous, group) => {
await previous;
if (failure !== null) return;
const results = await Promise.allSettled(group.map((key) => this.storage.delete(category, key)));
results.forEach((settled, i) => {
if (settled.status === 'fulfilled') deleted.push(group[i] as string);
else failure ??= settled.reason;
});
}, Promise.resolve());
await this.queue.deleteKeys(category, [...kept, ...deleted]);
result.kept += kept.length;
result.deleted += deleted.length;
if (deleted.length > 0) logDebug(`[storage] released ${deleted.length} unreferenced '${category}' object(s)`);
if (failure === null) return false;
logWarn(`[storage] could not release a '${category}' object, it waits: ${messageOf(failure)}`);
return true;
}
/**
* The keys some source names right now, lowercased. A key no source can
* narrow its read to (no plain run of 8 characters, which every name TREK
* writes has) counts as named without a question: asking about it would read
* whole tables, so it is the nightly pass's.
*/
private async referencedIn(
category: ServedCategory,
batch: readonly string[],
sources: readonly StorageReferenceSource[],
): Promise<Set<string>> {
const referenced = new Set(batch.filter((key) => keySearchCore(key) === null).map((key) => key.toLowerCase()));
const keys = batch.filter((key) => keySearchCore(key) !== null);
if (keys.length === 0) return referenced;
const answers = await Promise.all(
sources.map(async (source) => {
const asked = keys.filter((key) =>
@@ -118,22 +296,50 @@ export class StorageReleaseService {
return asked.length === 0 ? new Set<string>() : await source.referenced(category, asked);
}),
);
const referenced = new Set<string>();
for (const answer of answers) for (const key of answer) referenced.add(key.toLowerCase());
return referenced;
}
private async deleteIfPresent(category: ServedCategory, key: string): Promise<boolean> {
try {
if ((await this.storage.stat(category, key)) === null) return false;
await this.storage.delete(category, key);
logDebug(`[storage] released an unreferenced '${category}' object`);
return true;
} catch (err) {
logWarn(`[storage] could not release a '${category}' object, the nightly pass will: ${messageOf(err)}`);
return false;
/** A restore or a backup that began, or a reopen that happened, since the tick started ends it. */
private assertUndisturbed(epoch: number): void {
if (this.jobs.restoreRunning) throw new ReleaseStopped('a restore began');
if (this.jobs.backupRunning) throw new ReleaseStopped('a backup began');
if (this.install.epoch !== epoch) throw new ReleaseStopped('the database was reopened');
}
}
/**
* The rows these values queue: root-level keys of reclaimable categories,
* never an owner marker, at most MAX_KEYS_PER_VALUE per value and
* MAX_KEYS_PER_CALL in all.
*/
function releasable(
values: ReadonlyArray<string | null | undefined>,
options: StorageKeyOptions,
): Array<Pick<ReleaseQueueRow, 'category' | 'key'>> {
const seen = new Set<string>();
const rows: Array<Pick<ReleaseQueueRow, 'category' | 'key'>> = [];
for (const value of values) {
const refs = storageKeysIn(value, options).filter(
(ref) =>
SERVED.has(ref.category) &&
RECLAIM_LEDGER[ref.category as ServedCategory].reclaim &&
inReclaimScope(ref.key) &&
isValidKey(ref.key) &&
storageOwnerMarkerInstall(ref.key) === null,
);
for (const ref of refs.slice(0, MAX_KEYS_PER_VALUE)) {
const id = `${ref.category}\n${ref.key}`;
if (seen.has(id) || rows.length >= MAX_KEYS_PER_CALL) continue;
seen.add(id);
rows.push({ category: ref.category, key: ref.key });
}
}
return rows;
}
function chunk<T>(items: readonly T[], size: number): T[][] {
return Array.from({ length: Math.ceil(items.length / size) }, (_page, i) => items.slice(i * size, (i + 1) * size));
}
function messageOf(err: unknown): string {
+12 -2
View File
@@ -1,5 +1,6 @@
import { AppSettings } from '../../db/entities/AppSettings.entity';
import { StorageReclaimMarks } from '../../db/entities/StorageReclaimMarks.entity';
import { StorageReleaseQueue } from '../../db/entities/StorageReleaseQueue.entity';
import { AppConfigModule } from '../app-config/app-config.module';
import { AuditModule } from '../audit/audit.module';
import { DatabaseLifecycleModule } from '../database/database-lifecycle.module';
@@ -7,12 +8,15 @@ import { SchedulingModule } from '../scheduling/scheduling.module';
import { StorageAdminController } from './storage-admin.controller';
import { StorageAdminService } from './storage-admin.service';
import { StorageEventsService } from './storage-events.service';
import { StorageInstallService } from './storage-install.service';
import { StorageJobsService } from './storage-jobs.service';
import { StorageReclaimGuardService } from './storage-reclaim-guard.service';
import { StorageReclaimRegistryService } from './storage-reclaim-registry.service';
import { StorageReclaimController } from './storage-reclaim.controller';
import { StorageReclaimJob } from './storage-reclaim.job';
import { StorageReclaimService } from './storage-reclaim.service';
import { StorageRegistryService } from './storage-registry.service';
import { StorageReleaseJob } from './storage-release.job';
import { StorageReleaseService } from './storage-release.service';
import { StorageStatsService } from './storage-stats.service';
import { StorageUsageScanJob } from './storage-usage-scan.job';
@@ -42,7 +46,10 @@ import { Module } from '@nestjs/common';
* running there and waits for a reclaim pass in flight to let go, a backup
* marks itself so the pass deletes nothing it still reads.
* StorageReleaseService is exported for the owners, which hand it the values a
* delete or a replace left unnamed right after their write.
* delete or a replace left unnamed right after their write; it queues them,
* and StorageReleaseJob deletes what is still unnamed once every undo window
* has passed. StorageInstallService keeps this installation's id in the data
* dir and resets the reclaim state of a database that came from elsewhere.
*/
@Module({
imports: [
@@ -50,7 +57,7 @@ import { Module } from '@nestjs/common';
AuditModule,
DatabaseLifecycleModule,
SchedulingModule,
MikroOrmModule.forFeature([AppSettings, StorageReclaimMarks]),
MikroOrmModule.forFeature([AppSettings, StorageReclaimMarks, StorageReleaseQueue]),
],
controllers: [StorageAdminController, StorageReclaimController],
providers: [
@@ -62,9 +69,12 @@ import { Module } from '@nestjs/common';
StorageStatsService,
StorageUsageScanJob,
StorageReclaimRegistryService,
StorageInstallService,
StorageReclaimGuardService,
StorageReclaimService,
StorageReclaimJob,
StorageReleaseService,
StorageReleaseJob,
],
exports: [
StorageService,
+1 -1
View File
@@ -537,7 +537,7 @@ export class TripsService {
}
});
if (vacayId !== undefined) await this.vacay.notifyPlanUsers(vacayId, undefined);
if (cover_image !== undefined) await this.release.releaseValues([trip.cover_image]); // kept while any row names it
if (cover_image !== undefined && cover_image !== trip.cover_image) await this.release.releaseValues([trip.cover_image]);
const changes: Record<string, unknown> = {};
if (title && title !== trip.title) changes.title = title;
+55 -8
View File
@@ -5,19 +5,22 @@
* contracts the handlers declare all stand in front of it. Every owner
* domain registers its reference source here, so a pass looks at every
* reclaimable category; the checks below run in the background and only
* report. RCLE-001 to RCLE-007.
* report. RCLE-001 to RCLE-008.
*/
import { buildApp } from '../../src/bootstrap';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import { StorageInstallService } from '../../src/nest/storage/storage-install.service';
import { StorageJobsService } from '../../src/nest/storage/storage-jobs.service';
import { STORAGE_RECLAIMABLE_CATEGORIES, storageOwnerMarkerKey } from '../../src/nest/storage/storage-reclaim.types';
import { StorageService } from '../../src/nest/storage/storage.service';
import { countRows, deleteRows, findRow } from '../helpers/factories/rows';
import { setAppSetting } from '../helpers/factories/settings';
import { makeAdmin, makeUser } from '../helpers/factories/users';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import { STORAGE_RECLAIM_CATEGORIES, type StorageReclaimReport } from '@trek/shared';
import type { Server } from 'http';
import request from 'supertest';
@@ -53,7 +56,7 @@ describe('Storage reclaim e2e (whole app: real auth + admin guard + managed guar
afterAll(async () => {
// The uploads folder is the test run's shared one: take back the owner markers this app's checks wrote.
const installId = (await findRow(orm, AppSettings, { key: 'install_id' }))?.value;
const installId = app.get(StorageInstallService).installId();
if (installId) {
const storage = app.get(StorageService);
await Promise.all(
@@ -99,9 +102,11 @@ describe('Storage reclaim e2e (whole app: real auth + admin guard + managed guar
expect(res.status).toBe(200);
expect(res.body).toEqual({ deleteEnabled: true, running: false, lastRun: null, lastDeleteRun: null });
expect(await countRows(orm, AuditLog, { action: 'admin.storage_reclaim_update' })).toBe(1);
// Stored as the install id it was turned on for, so a database restored elsewhere reads as off.
// Stored as the install id it was turned on for, so a database restored elsewhere reads as off. The id
// comes from the data dir's file, and the database carries a copy it is reconciled with at boot.
const installId = (await findRow(orm, AppSettings, { key: 'install_id' }))?.value;
expect(installId).toMatch(/^[0-9a-f-]{36}$/);
expect(installId).toBe(app.get(StorageInstallService).installId());
expect((await findRow(orm, AppSettings, { key: 'storage.reclaim_delete' }))?.value).toBe(installId);
expect((await settledState()).deleteEnabled).toBe(true);
const off = await request(server)
@@ -119,11 +124,15 @@ describe('Storage reclaim e2e (whole app: real auth + admin guard + managed guar
);
expect(refused.map((res) => res.status)).toEqual([400, 400, 400]);
const confirm = await Promise.all(
[{}, { category: 'nowhere' }, { category: 'covers', count: 3 }].map((body) =>
request(server).post('/api/admin/storage/reclaim/confirm').set('Cookie', adminCookie).send(body),
),
[
{},
{ category: 'nowhere', unreferenced: 3 },
{ category: 'covers', count: 3 },
{ category: 'covers' },
{ category: 'covers', unreferenced: -1 },
].map((body) => request(server).post('/api/admin/storage/reclaim/confirm').set('Cookie', adminCookie).send(body)),
);
expect(confirm.map((res) => res.status)).toEqual([400, 400, 400]);
expect(confirm.map((res) => res.status)).toEqual([400, 400, 400, 400, 400]);
expect(await countRows(orm, AuditLog)).toBe(0);
});
@@ -161,11 +170,49 @@ describe('Storage reclaim e2e (whole app: real auth + admin guard + managed guar
const confirm = await request(server)
.post('/api/admin/storage/reclaim/confirm')
.set('Cookie', adminCookie)
.send({ category: 'covers' });
.send({ category: 'covers', unreferenced: 3 });
expect(confirm.status).toBe(409);
expect(confirm.body.code).toBe('reclaim_nothing_to_confirm');
});
it('RCLE-008: confirm takes the count shown, refuses a stale one with a code, and the prompt goes at once', async () => {
const none = { objects: 0, bytes: 0 };
const row = {
objects: 0,
unreferenced: none,
eligible: none,
deleted: none,
confirmationRequired: false,
skipped: null,
};
const report: StorageReclaimReport = {
startedAt: Date.now(),
finishedAt: Date.now(),
mode: 'report',
stopped: null,
categories: {
...(Object.fromEntries(STORAGE_RECLAIM_CATEGORIES.map((c) => [c, row])) as StorageReclaimReport['categories']),
covers: { ...row, objects: 1000, unreferenced: { objects: 300, bytes: 3000 }, confirmationRequired: true },
},
};
await setAppSetting(orm, 'storage.reclaim_report', JSON.stringify(report));
const confirm = (unreferenced: number) =>
request(server)
.post('/api/admin/storage/reclaim/confirm')
.set('Cookie', adminCookie)
.send({ category: 'covers', unreferenced });
const stale = await confirm(5000);
expect(stale.status).toBe(409);
expect(stale.body.code).toBe('reclaim_confirm_stale');
expect(await countRows(orm, AuditLog)).toBe(0);
const ok = await confirm(300);
expect(ok.status).toBe(200);
expect(ok.body.lastRun.categories.covers.confirmationRequired).toBe(false);
const audit = await findRow(orm, AuditLog, { action: 'admin.storage_reclaim_confirm' });
expect(JSON.parse(String(audit?.details))).toEqual({ category: 'covers', unreferenced: 300 });
expect((await settledState()).lastRun.categories.covers.confirmationRequired).toBe(false);
});
it('RCLE-007: managed mode refuses the surface with the standard body', async () => {
process.env.TREK_MANAGED = 'true';
try {
+20 -37
View File
@@ -1,19 +1,16 @@
/**
* Stand-ins for StorageReleaseService in suites that hand-build an owner
* service. The release itself (asking every reference source, the ledger's
* guards) has its own suites: storage-release.service.test.ts and the
* integration suite over the full app.
* service. The release itself (the queue, the job asking every reference
* source, the guards) has its own suites: storage-release.service.test.ts,
* storage-release.job.test.ts and the integration suite over the full app.
*
* - `releaseNothing()` releases nothing and records the calls;
* - `releaseDirect(storage)` deletes every root-level object the values name,
* as a suite with no second row naming them would see the real one do.
* - `releaseNothing()` queues nothing and records the calls;
* - `releaseDirect(storage)` deletes every root-level object the values name
* at once, as the queue would once the objects are due in a suite with no
* second row naming them.
*/
import { inReclaimScope } from '../../src/nest/storage/storage-reclaim.ledger';
import {
storageKeysIn,
type StorageKeyOptions,
type StorageKeyRef,
} from '../../src/nest/storage/storage-reclaim.types';
import { storageKeysIn, type StorageKeyOptions } from '../../src/nest/storage/storage-reclaim.types';
import type { StorageReleaseService } from '../../src/nest/storage/storage-release.service';
import type { StorageService } from '../../src/nest/storage/storage.service';
import type { ServedCategory } from '../../src/nest/storage/storage.types';
@@ -21,39 +18,25 @@ import type { ServedCategory } from '../../src/nest/storage/storage.types';
import { vi } from 'vitest';
export function releaseNothing() {
const releaseValues = vi.fn(async (): Promise<StorageKeyRef[]> => []);
const releaseIfUnreferenced = vi.fn(async (): Promise<string[]> => []);
return { releaseValues, releaseIfUnreferenced } as unknown as StorageReleaseService & {
releaseValues: typeof releaseValues;
releaseIfUnreferenced: typeof releaseIfUnreferenced;
};
const releaseValues = vi.fn(async (): Promise<number> => 0);
return { releaseValues } as unknown as StorageReleaseService & { releaseValues: typeof releaseValues };
}
export function releaseDirect(storage: StorageService): StorageReleaseService {
const releaseIfUnreferenced = async (category: ServedCategory, keys: readonly string[]): Promise<string[]> => {
const present = await Promise.all(
[...new Set(keys)].map(async (key) =>
inReclaimScope(key) && (await storage.stat(category, key).catch(() => null)) !== null ? key : null,
),
);
const gone = present.filter((key): key is string => key !== null);
await Promise.all(gone.map((key) => storage.delete(category, key)));
return gone;
};
const releaseValues = async (
values: ReadonlyArray<string | null | undefined>,
options: StorageKeyOptions = {},
): Promise<StorageKeyRef[]> => {
const byCategory = new Map<string, Set<string>>();
for (const ref of values.flatMap((value) => storageKeysIn(value, options))) {
byCategory.set(ref.category, (byCategory.get(ref.category) ?? new Set<string>()).add(ref.key));
}
const released = await Promise.all(
[...byCategory].map(async ([category, keys]) =>
(await releaseIfUnreferenced(category as ServedCategory, [...keys])).map((key) => ({ category, key })),
): Promise<number> => {
const refs = values.flatMap((value) => storageKeysIn(value, options)).filter((ref) => inReclaimScope(ref.key));
const unique = [...new Map(refs.map((ref) => [`${ref.category}\n${ref.key}`, ref])).values()];
const present = await Promise.all(
unique.map(async (ref) =>
(await storage.stat(ref.category as ServedCategory, ref.key).catch(() => null)) !== null ? ref : null,
),
);
return released.flat();
const gone = present.filter((ref): ref is NonNullable<typeof ref> => ref !== null);
await Promise.all(gone.map((ref) => storage.delete(ref.category as ServedCategory, ref.key)));
return gone.length;
};
return { releaseValues, releaseIfUnreferenced } as unknown as StorageReleaseService;
return { releaseValues } as unknown as StorageReleaseService;
}
+115 -13
View File
@@ -1,19 +1,28 @@
/**
* The immediate release over the full app (#2924): what a user's replace or
* delete leaves unnamed is gone right after the request, through every owner's
* real reference source, while an object another row still names (a trip
* copy, a journey, a saved place) stays. The objects are written straight into
* the test run's uploads folder under random names and taken back afterwards.
* RELI-001 to RELI-006.
* The deferred release over the full app (#2924): what a user's replace or
* delete leaves unnamed is queued by the request and left in place, then
* deleted by the release job once it is due, through every owner's real
* reference source, while an object another row still names (a trip copy, a
* journey, a saved place, a value an undo wrote back) stays. The objects are
* written straight into the test run's uploads folder under random names and
* taken back afterwards, the owner markers the job wrote with them.
* RELI-001 to RELI-009.
*/
import { buildApp } from '../../src/bootstrap';
import { Addons } from '../../src/db/entities/Addons.entity';
import { StorageReleaseQueue } from '../../src/db/entities/StorageReleaseQueue.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { withRequestContext } from '../../src/nest/database/request-context';
import { StorageInstallService } from '../../src/nest/storage/storage-install.service';
import { STORAGE_RECLAIMABLE_CATEGORIES, storageOwnerMarkerKey } from '../../src/nest/storage/storage-reclaim.types';
import { RELEASE_DELAY_MS, StorageReleaseService } from '../../src/nest/storage/storage-release.service';
import { StorageService } from '../../src/nest/storage/storage.service';
import { authCookie } from '../helpers/auth';
import { makeCollection, makeCollectionPlace } from '../helpers/factories/collections';
import type { FactoryOrm } from '../helpers/factories/context';
import { makeJourney } from '../helpers/factories/journeys';
import { makePlace } from '../helpers/factories/places';
import { updateRows } from '../helpers/factories/rows';
import { countRows, updateRows, upsertRow } from '../helpers/factories/rows';
import { makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { MikroORM } from '@mikro-orm/core';
@@ -47,6 +56,15 @@ let nestApp: INestApplication;
let app: Application;
let orm: FactoryOrm;
/** One tick of the release job at `at` (by default once everything queued so far is due). */
async function tick(at: Date = new Date(Date.now() + RELEASE_DELAY_MS + 60_000)) {
return await withRequestContext(nestApp.get(MikroORM), () => nestApp.get(StorageReleaseService).processDue(at));
}
async function queued(category: string, key: string): Promise<number> {
return await countRows(orm, StorageReleaseQueue, { category, key });
}
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
@@ -54,11 +72,21 @@ beforeAll(async () => {
});
afterAll(async () => {
for (const file of written) fs.rmSync(file, { force: true });
// The uploads folder is the test run's shared one: take back the owner markers the job wrote.
const installId = nestApp.get(StorageInstallService).installId();
if (installId) {
const storage = nestApp.get(StorageService);
await Promise.all(
STORAGE_RECLAIMABLE_CATEGORIES.map((category) =>
storage.delete(category, storageOwnerMarkerKey(installId)).catch(() => undefined),
),
);
}
await nestApp.close();
});
describe('the immediate release of what a change left unnamed', () => {
it('RELI-001: a replaced trip cover goes at once, but not while a trip copy still shows it', async () => {
describe('the deferred release of what a change left unnamed', () => {
it('RELI-001: a replaced trip cover waits in the queue, then goes, but not while a trip copy still shows it', async () => {
const { user } = await makeUser(orm);
const old = stored('covers');
const trip = await makeTrip(orm, user.id, { cover_image: `/uploads/covers/${old}` });
@@ -70,18 +98,24 @@ describe('the immediate release of what a change left unnamed', () => {
.set('Cookie', authCookie(user.id))
.send({ cover_image: `/uploads/covers/${next}` });
expect(first.status).toBe(200);
expect(await queued('covers', old)).toBe(1);
await tick();
expect(exists('covers', old)).toBe(true);
expect(await queued('covers', old)).toBe(0);
const second = await request(app)
.put(`/api/trips/${copy.id}`)
.set('Cookie', authCookie(user.id))
.send({ cover_image: `/uploads/covers/${next}` });
expect(second.status).toBe(200);
// The request itself deletes nothing.
expect(exists('covers', old)).toBe(true);
await tick();
expect(exists('covers', old)).toBe(false);
expect(exists('covers', next)).toBe(true);
});
it('RELI-002: a deleted trip takes its cover along unless a journey inherited it', async () => {
it('RELI-002: a deleted trip takes its cover along a day later, unless a journey inherited it', async () => {
const { user } = await makeUser(orm);
const alone = stored('covers');
const inherited = stored('covers');
@@ -92,11 +126,13 @@ describe('the immediate release of what a change left unnamed', () => {
const deleteTrip = (id: number) => request(app).delete(`/api/trips/${id}`).set('Cookie', authCookie(user.id));
expect((await deleteTrip(tripA.id)).status).toBe(200);
expect((await deleteTrip(tripB.id)).status).toBe(200);
expect(exists('covers', alone)).toBe(true);
await tick();
expect(exists('covers', alone)).toBe(false);
expect(exists('covers', inherited)).toBe(true);
});
it('RELI-003: a replaced place image goes, one a saved place still names stays', async () => {
it('RELI-003: a replaced place image goes once due, one a saved place still names stays', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id);
const own = stored('places');
@@ -115,11 +151,12 @@ describe('the immediate release of what a change left unnamed', () => {
.send({ image_url: null });
expect((await clearImage(placeA.id)).status).toBe(200);
expect((await clearImage(placeB.id)).status).toBe(200);
await tick();
expect(exists('places', own)).toBe(false);
expect(exists('places', saved)).toBe(true);
});
it('RELI-004: a deleted place takes its image along', async () => {
it('RELI-004: a deleted place takes its image along once due, not before', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id);
const image = stored('places');
@@ -128,10 +165,15 @@ describe('the immediate release of what a change left unnamed', () => {
.delete(`/api/trips/${trip.id}/places/${place.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
await tick(new Date());
expect(exists('places', image)).toBe(true);
expect(await queued('places', image)).toBe(1);
await tick();
expect(exists('places', image)).toBe(false);
expect(await queued('places', image)).toBe(0);
});
it('RELI-005: a deleted avatar goes; an avatar another account still names stays', async () => {
it('RELI-005: a deleted avatar goes once due; an avatar another account still names stays', async () => {
const { user } = await makeUser(orm);
const { user: other } = await makeUser(orm);
const own = stored('avatars', '.png');
@@ -140,10 +182,12 @@ describe('the immediate release of what a change left unnamed', () => {
await updateRows(orm, Users, { id: other.id }, { avatar: shared });
const res = await request(app).delete('/api/auth/avatar').set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
await tick();
expect(exists('avatars', own)).toBe(false);
await updateRows(orm, Users, { id: user.id }, { avatar: shared });
await request(app).delete('/api/auth/avatar').set('Cookie', authCookie(user.id));
await tick();
expect(exists('avatars', shared)).toBe(true);
});
@@ -155,5 +199,63 @@ describe('the immediate release of what a change left unnamed', () => {
.set('Cookie', authCookie(user.id))
.send({ cover_image: 'https://images.example.test/cover.jpg' });
expect(res.status).toBe(200);
const tooLong = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ cover_image: `/uploads/covers/${'a'.repeat(2048)}` });
expect(tooLong.status).toBe(400);
});
it('RELI-007: a cover an undo writes back before it is due stays, and its row goes', async () => {
const { user } = await makeUser(orm);
const old = stored('covers');
const trip = await makeTrip(orm, user.id, { cover_image: `/uploads/covers/${old}` });
const put = (cover: string | null) =>
request(app).put(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id)).send({ cover_image: cover });
expect((await put(null)).status).toBe(200);
expect(await queued('covers', old)).toBe(1);
expect((await put(`/uploads/covers/${old}`)).status).toBe(200);
await tick();
expect(exists('covers', old)).toBe(true);
expect(await queued('covers', old)).toBe(0);
});
it('RELI-008: a PUT that sends the cover a trip already has queues nothing', async () => {
const { user } = await makeUser(orm);
const same = stored('covers');
const trip = await makeTrip(orm, user.id, { cover_image: `/uploads/covers/${same}` });
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ cover_image: `/uploads/covers/${same}` });
expect(res.status).toBe(200);
expect(await queued('covers', same)).toBe(0);
});
it('RELI-009: a replaced journey cover is queued, never deleted by the request; an unchanged one queues nothing', async () => {
await upsertRow(orm, Addons, {
id: 'journey',
name: 'Journey',
description: 'Travel journal',
type: 'global',
icon: 'Compass',
enabled: true,
sort_order: 35,
});
const { user } = await makeUser(orm);
const old = stored('covers');
const journey = await makeJourney(orm, user.id, { cover_image: `covers/${old}` });
const patch = (cover: string) =>
request(app).patch(`/api/journeys/${journey.id}`).set('Cookie', authCookie(user.id)).send({ cover_image: cover });
expect((await patch(`covers/${old}`)).status).toBe(200);
expect(await queued('covers', old)).toBe(0);
const next = stored('covers');
expect((await patch(`covers/${next}`)).status).toBe(200);
expect(await queued('covers', old)).toBe(1);
// What the assistant's undo writes back within the window keeps its object.
expect((await patch(`covers/${old}`)).status).toBe(200);
await tick();
expect(exists('covers', old)).toBe(true);
expect(exists('covers', next)).toBe(false);
});
});
@@ -41,6 +41,7 @@ describe('resolveDataPaths', () => {
logsDir: path.join('/srv/trek', 'data', 'logs'),
jwtSecretFile: path.join('/srv/trek', 'data', '.jwt_secret'),
encryptionKeyFile: path.join('/srv/trek', 'data', '.encryption_key'),
installIdFile: path.join('/srv/trek', 'data', '.trek-install-id'),
dbFile: path.join('/srv/trek', 'data', 'travel.db'),
});
});
+2 -2
View File
@@ -63,8 +63,8 @@ describe('ALL_ENTITIES', () => {
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
* which de-duplicates by class name).
*/
it('ENT-003: exactly 166 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(166);
it('ENT-003: exactly 167 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(167);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -60,7 +60,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// + assistant_attachments: id, thread_id, user_id, trip_id, name, mime, size, sha256, storage_key, text_status
// (an attached file is only ever written whole, by the turn it was attached in).
// + storage_reclaim_marks: category, key, first_seen_at, size (a mark is only ever written whole, by the reclaim pass).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 608;
// + storage_release_queue: category, key, due_at (a queued release is only ever written whole, by the release queue).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 611;
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
@@ -101,6 +101,7 @@ const UNNUMBERED = [
'Migration20200101046310_an_admin_can_run_a_plugin_outside_its_trek_range',
'Migration20200101046320_a_place_knows_where_its_details_came_from',
'Migration20200101046330_an_object_no_row_names_is_marked_before_it_goes',
'Migration20200101046340_a_released_object_outlasts_every_undo_before_it_goes',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -0,0 +1,91 @@
/**
* StorageReleaseQueueRepository: the objects a delete or a replace left
* unnamed, each with the time it may go. RELQ-001 to RELQ-005.
*/
import { StorageReleaseQueue } from '../../../../src/db/entities/StorageReleaseQueue.entity';
import type { StorageReleaseQueueRepository } from '../../../../src/db/repositories/StorageReleaseQueue.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { deleteRows } from '../../../helpers/factories/rows';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let queue: StorageReleaseQueueRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
queue = t.repo(StorageReleaseQueue);
});
beforeEach(async () => {
await deleteRows(t, StorageReleaseQueue);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
describe('StorageReleaseQueueRepository', () => {
it('RELQ-001: queues keys per category and answers the due ones, the longest waiting first, up to a limit', async () => {
await queue.enqueue([{ category: 'covers', key: 'b.jpg' }], '2026-01-02 00:00:00');
await queue.enqueue([{ category: 'covers', key: 'a.jpg' }], '2026-01-01 00:00:00');
await queue.enqueue([{ category: 'places', key: 'a.jpg' }], '2026-01-01 00:00:00');
await queue.enqueue([{ category: 'covers', key: 'later.jpg' }], '2026-02-01 00:00:00');
expect(await queue.size()).toBe(4);
expect(await queue.dueIn(['covers'], '2026-01-15 00:00:00', 10)).toEqual([
{ category: 'covers', key: 'a.jpg', due_at: '2026-01-01 00:00:00' },
{ category: 'covers', key: 'b.jpg', due_at: '2026-01-02 00:00:00' },
]);
expect(await queue.dueIn(['covers', 'places'], '2026-01-15 00:00:00', 1)).toHaveLength(1);
expect(await queue.dueIn([], '2026-01-15 00:00:00', 10)).toEqual([]);
expect(await queue.dueIn(['covers'], '2026-01-15 00:00:00', 0)).toEqual([]);
});
it('RELQ-002: a key queued again waits from the later time, and an empty batch writes nothing', async () => {
await queue.enqueue([{ category: 'covers', key: 'a.jpg' }], '2026-01-01 00:00:00');
await queue.enqueue([{ category: 'covers', key: 'a.jpg' }], '2026-03-01 00:00:00');
await queue.enqueue([], '2026-04-01 00:00:00');
expect(await queue.size()).toBe(1);
expect(await queue.dueIn(['covers'], '2026-02-01 00:00:00', 10)).toEqual([]);
expect(await queue.dueIn(['covers'], '2026-03-01 00:00:00', 10)).toHaveLength(1);
});
it('RELQ-003: answers the categories holding a due row, each once', async () => {
await queue.enqueue(
[
{ category: 'covers', key: 'a.jpg' },
{ category: 'covers', key: 'b.jpg' },
],
'2026-01-01 00:00:00',
);
await queue.enqueue([{ category: 'places', key: 'a.jpg' }], '2026-05-01 00:00:00');
expect(await queue.dueCategories('2026-02-01 00:00:00')).toEqual(['covers']);
expect((await queue.dueCategories('2026-06-01 00:00:00')).sort()).toEqual(['covers', 'places']);
});
it('RELQ-004: drops the rows of the keys named, in their category only, and the rows due before a time', async () => {
await queue.enqueue(
[
{ category: 'covers', key: 'a.jpg' },
{ category: 'covers', key: 'b.jpg' },
],
'2026-01-01 00:00:00',
);
await queue.enqueue([{ category: 'places', key: 'a.jpg' }], '2026-03-01 00:00:00');
expect(await queue.deleteKeys('covers', ['a.jpg', 'nope.jpg'])).toBe(1);
expect(await queue.deleteKeys('covers', [])).toBe(0);
expect(await queue.deleteDueBefore('2026-02-01 00:00:00')).toBe(1);
expect(await queue.dueIn(['places'], '2026-03-01 00:00:00', 10)).toEqual([
{ category: 'places', key: 'a.jpg', due_at: '2026-03-01 00:00:00' },
]);
});
it('RELQ-005: drops every row, for a database another installation brought', async () => {
await queue.enqueue([{ category: 'covers', key: 'a.jpg' }], '2026-01-01 00:00:00');
await queue.enqueue([{ category: 'places', key: 'a.jpg' }], '2026-01-01 00:00:00');
expect(await queue.deleteAll()).toBe(2);
expect(await queue.size()).toBe(0);
});
});
@@ -731,6 +731,18 @@ describe('BACKUP-036 createBackup', () => {
expect(names.some((n) => n?.includes('trek-owner-'))).toBe(false);
});
it('BACKUP-036k — never bundles the install id file, so a restore cannot make another install look like this one', async () => {
// The key files both exist here: the archive still takes only the ones it names.
fsMock.existsSync.mockImplementation((p: string) => /\.(encryption_key|trek-install-id)$/.test(String(p)));
setupArchiveSuccess();
await createBackup(deps(stubStorage({ stat: statOf(1024) })));
const files = archiverInstanceMock.file.mock.calls.map((c) => [String(c[0]), c[1]?.name as string]);
expect(files.some(([from, name]) => from.includes('.trek-install-id') || name?.includes('.trek-install-id'))).toBe(
false,
);
expect(archiverInstanceMock.glob).not.toHaveBeenCalled();
});
it('BACKUP-036h — only category prefixes are ever archived; backups/ and restore-* are structurally out (issue #1358)', async () => {
// The uploads/** glob is gone: even when data and uploads map to the same
// directory, enumeration only ever walks the six archived categories, so
@@ -8,8 +8,6 @@ import type { JourneyPhotoLinksService } from '../../../src/nest/journey/journey
import { JourneyPublicController } from '../../../src/nest/journey/journey-public.controller';
import { JourneyController } from '../../../src/nest/journey/journey.controller';
import type { JourneyService } from '../../../src/nest/journey/journey.service';
import { journeyThumbName } from '../../../src/nest/memories/thumbnail.service';
import type { StorageKeyRef } from '../../../src/nest/storage/storage-reclaim.types';
import type { StorageReleaseService } from '../../../src/nest/storage/storage-release.service';
import type { StorageService } from '../../../src/nest/storage/storage.service';
import type { User } from '../../../src/types';
@@ -47,14 +45,12 @@ const captureBackfillStub = {
} as unknown as JourneyPhotoCaptureService;
/**
* What a deleted photo hands on. By default every `journey/<name>` it names
* goes, as the real release answers when no other row names them; a case
* about a shared object answers what it keeps.
* What a deleted photo hands on: the release queues every `journey/<name>` it
* names, and answers how many.
*/
const releaseValues = vi.fn(async (values: ReadonlyArray<string | null | undefined>): Promise<StorageKeyRef[]> =>
values
.filter((value): value is string => typeof value === 'string' && value.startsWith('journey/'))
.map((value) => ({ category: 'journey', key: value.slice('journey/'.length) })),
const releaseValues = vi.fn(
async (values: ReadonlyArray<string | null | undefined>): Promise<number> =>
values.filter((value) => typeof value === 'string' && value.startsWith('journey/')).length,
);
const releaseStub = { releaseValues } as unknown as StorageReleaseService;
@@ -721,7 +717,7 @@ describe('JourneyController', () => {
expect(updatePhoto).toHaveBeenCalledWith(7, 1, { caption: 'x' }, 'sock-1');
});
it('DELETE photo releases its file and poster once the row is gone, and the thumb of a file that went', async () => {
it('DELETE photo queues its file and poster for release once the row is gone, and deletes nothing itself', async () => {
(storageStub.delete as ReturnType<typeof vi.fn>).mockClear();
releaseValues.mockClear();
const deletePhoto = vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg', thumbnail_path: 'journey/p.jpg' });
@@ -730,16 +726,17 @@ describe('JourneyController', () => {
});
expect(deletePhoto).toHaveBeenCalledWith(7, 1);
expect(releaseValues).toHaveBeenCalledWith(['journey/a.jpg', 'journey/p.jpg'], { bare: 'journey' });
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/a.jpg'));
// Neither the file nor its thumb goes inside the request: the queue waits out every undo, and the
// nightly thumbnail sweep drops a thumb no photo row accounts for.
expect(storageStub.delete).not.toHaveBeenCalled();
});
it('DELETE photo keeps the thumb of a file another row still names, and of a path outside journey/', async () => {
it('DELETE photo outside journey/ hands the path on as it is and deletes nothing', async () => {
(storageStub.delete as ReturnType<typeof vi.fn>).mockClear();
releaseValues.mockResolvedValueOnce([]);
const shared = vi.fn().mockReturnValue({ id: 8, file_path: 'journey/shared.jpg', thumbnail_path: null });
await ctl(svc({ deletePhoto: shared } as Partial<JourneyService>)).deletePhoto(user, '8');
releaseValues.mockClear();
const outside = vi.fn().mockReturnValue({ id: 9, file_path: 'elsewhere/x.jpg' });
await ctl(svc({ deletePhoto: outside } as Partial<JourneyService>)).deletePhoto(user, '9');
expect(releaseValues).toHaveBeenCalledWith(['elsewhere/x.jpg', undefined], { bare: 'journey' });
expect(storageStub.delete).not.toHaveBeenCalled();
});
@@ -770,7 +767,7 @@ describe('JourneyController', () => {
expect(await ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
});
it('DELETE gallery photo: 404, then releases the video, its poster and its thumb', async () => {
it('DELETE gallery photo: 404, then queues the video and its poster for release', async () => {
expect(
await thrownAsync(() =>
ctl(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).deleteGalleryPhoto(
@@ -789,12 +786,7 @@ describe('JourneyController', () => {
).toBeUndefined();
expect(deleteGalleryPhoto).toHaveBeenCalledWith(10, 1);
expect(releaseValues).toHaveBeenCalledWith(['journey/clip.mp4', 'journey/poster.jpg'], { bare: 'journey' });
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/clip.mp4'));
// A thumb delete that finds nothing is swallowed.
(storageStub.delete as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('ENOENT'));
await expect(
ctl(svc({ deleteGalleryPhoto } as Partial<JourneyService>)).deleteGalleryPhoto(user, '10'),
).resolves.toBeUndefined();
expect(storageStub.delete).not.toHaveBeenCalled();
});
it('PATCH /:id returns the updated journey on success', async () => {
@@ -7,7 +7,7 @@
* `namedKeys`, every key of a category its rows name (one read, for a pass),
* and `referenced`, a few keys asked fresh and narrowed to the rows holding
* their plain runs (the re-check before a delete, and a release).
* RSRC-001 to RSRC-014.
* RSRC-001 to RSRC-017.
*/
import { PlaceContribOutbox } from '../../../src/db/entities/PlaceContribOutbox.entity';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
@@ -20,6 +20,7 @@ import { PhotosStorageRefsService } from '../../../src/nest/photos/photos-storag
import { PlaceContribStorageRefsService } from '../../../src/nest/place-contrib/place-contrib-storage-refs.service';
import { PlacesStorageRefsService } from '../../../src/nest/places/places-storage-refs.service';
import { StorageReclaimRegistryService } from '../../../src/nest/storage/storage-reclaim-registry.service';
import { likeAny, MAX_LIKE_TERMS } from '../../../src/nest/storage/storage-reclaim-source.service';
import {
STORAGE_RECLAIMABLE_CATEGORIES,
STORAGE_REFERENCE_SOURCE_IDS,
@@ -233,4 +234,31 @@ describe('reference sources', () => {
const named = await files.namedKeys('files');
for (const name of names) expect(named.has(name.toLowerCase())).toBe(true);
});
it('RSRC-015: a question about many keys is split into reads of at most MAX_LIKE_TERMS terms each', async () => {
const ids = Array.from({ length: 60 }, (_unused, i) => `9d8c7b6a-5f4e-4d3c-8b2a-${String(i).padStart(12, '0')}`);
await Promise.all(ids.slice(0, 30).map((id) => makePlace(orm, tripId, { image_url: `/uploads/places/${id}.jpg` })));
const keys = ids.map((id) => `${id}.jpg`);
expect(await asked(t.get(PlacesStorageRefsService), 'places', keys)).toEqual(keys.slice(0, 30).sort());
// The photos source reads two columns, still within the bound.
expect(await asked(t.get(PhotosStorageRefsService), 'journey', keys)).toEqual([]);
});
it('RSRC-016: a filter past MAX_LIKE_TERMS terms is refused before it reaches SQLite', () => {
const runs = Array.from({ length: MAX_LIKE_TERMS + 1 }, (_unused, i) => `run-${String(i).padStart(8, '0')}`);
expect(() => likeAny<{ a: string }>(['a'], runs)).toThrow(/at most/);
expect(() => likeAny<{ a: string; b: string }>(['a', 'b'], runs.slice(0, 25))).not.toThrow();
expect(() => likeAny<{ a: string; b: string }>(['a', 'b'], runs.slice(0, 26))).toThrow(/at most/);
});
it('RSRC-017: namedKeysByCategory reads once and answers every category the source claims, none it does not', async () => {
await makeTrip(orm, userId, { cover_image: '/uploads/covers/rsrc-once-a.jpg' });
await makeTrip(orm, userId, { cover_image: '/uploads/places/rsrc-once-b.jpg' });
const answer = await t.get(TripsStorageRefsService).namedKeysByCategory(['covers', 'places', 'photos']);
expect(answer.get('covers')?.has('rsrc-once-a.jpg')).toBe(true);
expect(answer.get('covers')?.has('rsrc-once-b.jpg')).toBe(false);
expect(answer.get('places')?.has('rsrc-once-b.jpg')).toBe(true);
expect(answer.has('photos')).toBe(false);
expect(await t.get(FilesStorageRefsService).namedKeysByCategory(['covers'])).toEqual(new Map());
});
});
@@ -1,7 +1,7 @@
/**
* StorageReclaimController over a stub service: what it forwards, what it
* audits, and that a refusal of the service reaches the client unchanged.
* RCLC-001 to RCLC-006.
* RCLC-001 to RCLC-007.
*/
import type { AuditService } from '../../../../src/nest/audit/audit.service';
import { DomainError } from '../../../../src/nest/common/domain-error';
@@ -52,7 +52,7 @@ function makeController(over: Partial<Record<keyof StorageReclaimService, unknow
const writeAudit = vi.fn();
const service = {
setDeleteEnabled: vi.fn(async (enabled: boolean) => state({ deleteEnabled: enabled })),
start: vi.fn(),
start: vi.fn(async () => undefined),
confirm: vi.fn(async () => state({ lastRun: report() })),
...over,
} as unknown as StorageReclaimService;
@@ -89,19 +89,18 @@ describe('StorageReclaimController', () => {
code: 'reclaim_busy',
});
const { controller, writeAudit } = makeController({
start: vi.fn(() => {
throw refusal;
}),
start: vi.fn().mockRejectedValue(refusal),
});
await expect(controller.run(user, req)).rejects.toBe(refusal);
expect(writeAudit).not.toHaveBeenCalled();
});
it('RCLC-005: POST confirm forwards the category, answers the state and audits the confirmed count', async () => {
it('RCLC-005: POST confirm forwards the category and the count shown, answers the state and audits that count', async () => {
const { controller, service, writeAudit } = makeController();
const answer = await controller.confirm(user, { category: 'covers' } as StorageReclaimConfirmDto, req);
const body = { category: 'covers', unreferenced: 4 } as StorageReclaimConfirmDto;
const answer = await controller.confirm(user, body, req);
expect(answer.lastRun?.categories.covers.confirmationRequired).toBe(true);
expect(service.confirm).toHaveBeenCalledWith('covers');
expect(service.confirm).toHaveBeenCalledWith('covers', 4);
expect(writeAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: 'admin.storage_reclaim_confirm',
@@ -115,9 +114,18 @@ describe('StorageReclaimController', () => {
code: 'reclaim_nothing_to_confirm',
});
const { controller, writeAudit } = makeController({ confirm: vi.fn().mockRejectedValue(refusal) });
await expect(controller.confirm(user, { category: 'files' } as StorageReclaimConfirmDto, req)).rejects.toBe(
refusal,
);
const body = { category: 'files', unreferenced: 3 } as StorageReclaimConfirmDto;
await expect(controller.confirm(user, body, req)).rejects.toBe(refusal);
expect(writeAudit).not.toHaveBeenCalled();
});
it('RCLC-007: a count the last report no longer holds propagates its stale 409 and writes no audit row', async () => {
const refusal = new DomainError(409, 'the last check found another count since, reload and confirm again', {
code: 'reclaim_confirm_stale',
});
const { controller, writeAudit } = makeController({ confirm: vi.fn().mockRejectedValue(refusal) });
const body = { category: 'covers', unreferenced: 400 } as StorageReclaimConfirmDto;
await expect(controller.confirm(user, body, req)).rejects.toBe(refusal);
expect(writeAudit).not.toHaveBeenCalled();
});
});
@@ -217,6 +217,7 @@ describe('StorageReclaimRegistryService', () => {
id,
claims: [{ category: 'covers', prefix: '' }],
namedKeys: async () => new Set<string>(),
namedKeysByCategory: async () => new Map(),
referenced: async () => new Set<string>(),
});
@@ -1,24 +1,31 @@
/**
* The storage reclaim pass (#2924) over a real StorageModule: a real registry
* and LocalDriver on temp folders, the marks table in the snapshot db, and
* fake reference sources standing in for the owner domains. The pass marks
* first, deletes a week later only with the switch on and only after asking
* every source about its batch again. A skip keeps the marks; a share over a
* fifth waits for an admin's confirmation; an owner marker of another install
* keeps its hands off; a restore resets the switch, the marks and the reports
* but keeps this install's id. RCLS-001 to RCLS-036.
* and LocalDriver on temp folders, the marks table in the snapshot db, the
* install id file in a temp data dir, and fake reference sources standing in
* for the owner domains. The pass reads every source once, marks first,
* deletes a week later only with the switch on and only after reading every
* source again, fresh, right before the batch. A skip keeps the marks; a share
* over a fifth waits for an admin's confirmation of the count they saw, which
* covers a margin for two weeks and is used up once the share is back under;
* an owner marker of another install keeps its hands off; a restore, or a
* database naming another install, resets the switch, the marks, the queue
* and the reports but keeps this install's id. RCLS-001 to RCLS-043.
*/
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
import { AuditLog } from '../../../../src/db/entities/AuditLog.entity';
import { StorageReclaimMarks } from '../../../../src/db/entities/StorageReclaimMarks.entity';
import { StorageReleaseQueue } from '../../../../src/db/entities/StorageReleaseQueue.entity';
import { StorageReclaimMarksRepository } from '../../../../src/db/repositories/StorageReclaimMarks.repository';
import { dbNow } from '../../../../src/db/types';
import { DataPathsService } from '../../../../src/nest/app-config/data-paths.service';
import { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
import { logError } from '../../../../src/nest/audit/audit-log.logger';
import { DomainError } from '../../../../src/nest/common/domain-error';
import { DatabaseLifecycle } from '../../../../src/nest/database/database-lifecycle.service';
import { StorageAdminService } from '../../../../src/nest/storage/storage-admin.service';
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 { StorageReclaimService } from '../../../../src/nest/storage/storage-reclaim.service';
import {
@@ -29,6 +36,7 @@ import {
type StorageReferenceSource,
type StorageReferenceSourceId,
} from '../../../../src/nest/storage/storage-reclaim.types';
import { STORAGE_RELEASE_JOB } 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';
@@ -58,6 +66,7 @@ let t: TestModule;
let orm: TestOrm;
let uploadsRoot: string;
let backupsRoot: string;
let dataRoot: string;
let reclaim: StorageReclaimService;
let storage: StorageService;
let jobs: StorageJobsService;
@@ -68,6 +77,8 @@ let installId: string;
let named: Map<ServedCategory, Set<string>>;
/** Called on every question a source is asked, before it answers: `keys` is null for a whole-category read. */
let onAsk: ((category: ServedCategory, keys: readonly string[] | null) => void | Promise<void>) | null;
/** Called when a source is read once for the whole pass, before it answers. */
let onRead: ((id: StorageReferenceSourceId) => void | Promise<void>) | null;
/** The keys the fake sources name in `category`, created empty on first use. */
function namedIn(category: ServedCategory): Set<string> {
@@ -82,12 +93,22 @@ function namedIn(category: ServedCategory): Set<string> {
function fakeSource(id: StorageReferenceSourceId): StorageReferenceSource {
const categories: readonly ServedCategory[] =
id === 'auth' ? ['avatars'] : id === 'files' ? ['files'] : STORAGE_RECLAIMABLE_CATEGORIES;
const lowered = (category: ServedCategory) =>
new Set([...(named.get(category) ?? [])].map((key) => key.toLowerCase()));
return {
id,
claims: categories.map((category): StorageReferenceClaim => ({ category, prefix: '' })),
async namedKeys(category) {
await onAsk?.(category, null);
return new Set([...(named.get(category) ?? [])].map((key) => key.toLowerCase()));
return lowered(category);
},
async namedKeysByCategory(asked) {
await onRead?.(id);
return new Map(
asked
.filter((category) => categories.includes(category))
.map((category): [ServedCategory, Set<string>] => [category, lowered(category)]),
);
},
async referenced(category, keys) {
await onAsk?.(category, keys);
@@ -127,16 +148,6 @@ async function marksOf(category: string): Promise<Array<{ key: string; first_see
.sort((a, b) => a.key.localeCompare(b.key));
}
/** What a synchronous call threw, or null. */
function thrownBy(call: () => unknown): unknown {
try {
call();
} catch (err) {
return err;
}
return null;
}
async function markWeekOld(category: ServedCategory, keys: string[]): Promise<void> {
await orm.repo(StorageReclaimMarks).insertMissing(
category,
@@ -148,6 +159,7 @@ async function markWeekOld(category: ServedCategory, keys: string[]): Promise<vo
beforeAll(async () => {
uploadsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-reclaim-uploads-'));
backupsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-reclaim-backups-'));
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-reclaim-data-'));
orm = await createTestOrm(db);
await setAppSetting(orm, 'storage.backends', backends());
t = await createTestModule({
@@ -155,6 +167,7 @@ beforeAll(async () => {
imports: [StorageModule],
init: true,
overrides: [
{ provide: DataPathsService, useValue: { ...new DataPathsService(), installIdFile: path.join(dataRoot, 'id') } },
{
provide: DatabaseLifecycle,
useValue: {
@@ -170,7 +183,7 @@ beforeAll(async () => {
jobs = t.get(StorageJobsService);
references = t.get(StorageReclaimRegistryService);
for (const id of STORAGE_REFERENCE_SOURCE_IDS) references.register(fakeSource(id));
installId = (await readAppSetting(orm, 'install_id')) ?? '';
installId = t.get(StorageInstallService).installId() ?? '';
});
beforeEach(async () => {
@@ -180,10 +193,13 @@ beforeEach(async () => {
if (entry !== '.tmp') fs.rmSync(path.join(uploadsRoot, entry), { recursive: true, force: true });
}
await deleteRows(orm, StorageReclaimMarks);
await deleteRows(orm, StorageReleaseQueue);
await deleteRows(orm, AppSettings, { key: { $like: 'storage.reclaim%' } });
t.get(StorageReclaimGuardService).forget();
await deleteRows(orm, AuditLog);
named = new Map();
onAsk = null;
onRead = null;
});
afterAll(async () => {
@@ -192,6 +208,7 @@ afterAll(async () => {
db.close();
fs.rmSync(uploadsRoot, { recursive: true, force: true });
fs.rmSync(backupsRoot, { recursive: true, force: true });
fs.rmSync(dataRoot, { recursive: true, force: true });
});
const T0 = new Date(Date.now() + 2 * DAY);
@@ -206,6 +223,8 @@ async function markedWeekOld(orphans: string[], kept = 4): Promise<void> {
describe('StorageReclaimService: mark, then sweep', () => {
it('RCLS-001: a first pass reports and marks; the report is persisted and both states carry it', async () => {
expect(installId).toMatch(/^[0-9a-f-]{36}$/);
expect(fs.readFileSync(path.join(dataRoot, 'id'), 'utf8').trim()).toBe(installId);
expect(await readAppSetting(orm, 'install_id')).toBe(installId);
await seedCovers(['orphan.jpg']);
const report = await reclaim.run({ now: T0 });
expect(report.mode).toBe('report');
@@ -273,10 +292,10 @@ describe('StorageReclaimService: mark, then sweep', () => {
expect(await marksOf('covers')).toEqual([{ key: 'orphan.jpg', first_seen_at: dbNow(WEEK_LATER) }]);
});
it('RCLS-005: the fresh re-check right before the delete keeps an object named in between', async () => {
it('RCLS-005: the fresh read right before the delete keeps an object named in between', async () => {
await markedWeekOld(['orphan.jpg']);
onAsk = (category, keys) => {
if (category === 'covers' && keys !== null) namedIn('covers').add('orphan.jpg');
if (category === 'covers' && keys === null) namedIn('covers').add('orphan.jpg');
};
const report = await reclaim.run({ now: WEEK_LATER });
expect(report.categories.covers.eligible.objects).toBe(1);
@@ -337,37 +356,64 @@ describe('StorageReclaimService: a large unreferenced share waits for the admin'
]);
});
it('RCLS-009: an admin confirmation of the shown count lets the next pass delete; a larger share waits again', async () => {
it('RCLS-009: confirming the count shown clears the prompt at once and lets the next pass delete', async () => {
await seedCovers(['o1.jpg', 'o2.jpg'], 1);
await markWeekOld('covers', ['o1.jpg', 'o2.jpg']);
await reclaim.setDeleteEnabled(true);
await reclaim.run({ now: WEEK_LATER });
const confirmed = await reclaim.confirm('covers');
expect(confirmed.lastRun?.categories.covers.confirmationRequired).toBe(true);
expect(JSON.parse(String(await readAppSetting(orm, 'storage.reclaim_confirmed')))).toEqual({
installId,
counts: { covers: 2 },
});
const before = Date.now();
const confirmed = await reclaim.confirm('covers', 2);
// The state folds the confirmation in: the prompt goes before the next pass.
expect(confirmed.lastRun?.categories.covers.confirmationRequired).toBe(false);
const stored = JSON.parse(String(await readAppSetting(orm, 'storage.reclaim_confirmed')));
expect(stored).toEqual({ installId, categories: { covers: { count: 2, confirmedAt: expect.any(Number) } } });
expect(stored.categories.covers.confirmedAt).toBeGreaterThanOrEqual(before);
await put('covers', 'o3.jpg');
await put('covers', 'o4.jpg');
await put('covers', 'o5.jpg');
await markWeekOld('covers', ['o3.jpg', 'o4.jpg', 'o5.jpg']);
const bigger = await reclaim.run({ now: WEEK_LATER });
expect(bigger.categories.covers.confirmationRequired).toBe(true);
expect(bigger.categories.covers.deleted.objects).toBe(0);
await reclaim.confirm('covers');
const after = await reclaim.run({ now: WEEK_LATER });
expect(after.categories.covers).toMatchObject({ confirmationRequired: false, deleted: { objects: 5, bytes: 25 } });
expect(after.categories.covers).toMatchObject({ confirmationRequired: false, deleted: { objects: 2, bytes: 10 } });
expect(fs.existsSync(objectPath('covers', 'kept-0.jpg'))).toBe(true);
});
it('RCLS-037: a confirmation covers half as much again, at least a hundred more; a surge past that waits again', async () => {
await seedCovers(['o1.jpg', 'o2.jpg'], 1);
await markWeekOld('covers', ['o1.jpg', 'o2.jpg']);
await reclaim.run({ now: WEEK_LATER });
await reclaim.confirm('covers', 2);
// A few more orphans than were shown: still covered (2 + 100).
const few = ['o3.jpg', 'o4.jpg', 'o5.jpg'];
await Promise.all(few.map((key) => put('covers', key)));
const covered = await reclaim.run({ now: WEEK_LATER });
expect(covered.categories.covers).toMatchObject({ unreferenced: { objects: 5 }, confirmationRequired: false });
// A surge well past the margin waits for a new confirmation.
const surge = Array.from({ length: 105 }, (_unused, i) => `s${i}.jpg`);
await Promise.all(surge.map((key) => put('covers', key)));
const waiting = await reclaim.run({ now: WEEK_LATER });
expect(waiting.categories.covers).toMatchObject({ unreferenced: { objects: 110 }, confirmationRequired: true });
});
it('RCLS-038: a confirmation runs out after two weeks, and is used up once the share is back under a fifth', async () => {
await seedCovers(['o1.jpg', 'o2.jpg'], 1);
await reclaim.run({ now: WEEK_LATER });
await reclaim.confirm('covers', 2);
const late = await reclaim.run({ now: new Date(Date.now() + 15 * DAY) });
expect(late.categories.covers.confirmationRequired).toBe(true);
// The pass that found it run out dropped it.
expect(await readAppSetting(orm, 'storage.reclaim_confirmed')).toBeNull();
await reclaim.run({ now: WEEK_LATER });
await reclaim.confirm('covers', 2);
await Promise.all(Array.from({ length: 20 }, (_unused, i) => put('covers', `named-${i}.jpg`)));
for (let i = 0; i < 20; i += 1) namedIn('covers').add(`named-${i}.jpg`);
const under = await reclaim.run({ now: WEEK_LATER });
expect(under.categories.covers.confirmationRequired).toBe(false);
expect(await readAppSetting(orm, 'storage.reclaim_confirmed')).toBeNull();
});
it('RCLS-010: a confirmation is refused with a code when the last report holds nothing waiting', async () => {
await expect(reclaim.confirm('covers')).rejects.toMatchObject({ code: 'reclaim_nothing_to_confirm' });
await expect(reclaim.confirm('covers', 1)).rejects.toMatchObject({ code: 'reclaim_nothing_to_confirm' });
await seedCovers(['orphan.jpg']);
await reclaim.run({ now: T0 });
const refusal = await reclaim.confirm('covers').catch((err: unknown) => err);
const refusal = await reclaim.confirm('covers', 1).catch((err: unknown) => err);
expect(refusal).toBeInstanceOf(DomainError);
expect((refusal as DomainError).getStatus()).toBe(409);
expect((refusal as DomainError).getResponse()).toEqual({
@@ -376,18 +422,38 @@ describe('StorageReclaimService: a large unreferenced share waits for the admin'
});
});
it('RCLS-011: a confirmation another install wrote counts for nothing here', async () => {
it('RCLS-039: a count the admin was shown that the last report no longer holds is refused as stale', async () => {
const orphans = Array.from({ length: 300 }, (_unused, i) => `o${i}.jpg`);
await seedCovers(orphans, 1);
await reclaim.run({ now: T0 });
const stale = await reclaim.confirm('covers', 5).catch((err: unknown) => err);
expect(stale).toBeInstanceOf(DomainError);
expect((stale as DomainError).getStatus()).toBe(409);
expect((stale as DomainError).getResponse()).toMatchObject({ code: 'reclaim_confirm_stale' });
// Within a tenth and fifty objects of it is the count the admin saw.
await expect(reclaim.confirm('covers', 280)).resolves.toMatchObject({ deleteEnabled: false });
await expect(reclaim.confirm('covers', 240)).rejects.toMatchObject({ code: 'reclaim_confirm_stale' });
expect(await readAppSetting(orm, 'storage.reclaim_confirmed')).toContain('"count":280');
});
it('RCLS-011: a confirmation another install wrote counts for nothing here, and an old-format row neither', async () => {
await seedCovers(['o1.jpg', 'o2.jpg'], 1);
await markWeekOld('covers', ['o1.jpg', 'o2.jpg']);
await setAppSetting(
orm,
'storage.reclaim_confirmed',
JSON.stringify({ installId: '11111111-2222-4333-8444-555555555555', counts: { covers: 99 } }),
JSON.stringify({
installId: '11111111-2222-4333-8444-555555555555',
categories: { covers: { count: 99, confirmedAt: Date.now() } },
}),
);
await reclaim.setDeleteEnabled(true);
const report = await reclaim.run({ now: WEEK_LATER });
expect(report.categories.covers.confirmationRequired).toBe(true);
expect(report.categories.covers.deleted.objects).toBe(0);
await setAppSetting(orm, 'storage.reclaim_confirmed', JSON.stringify({ installId, counts: { covers: 99 } }));
const old = await reclaim.run({ now: WEEK_LATER });
expect(old.categories.covers.confirmationRequired).toBe(true);
});
});
@@ -405,6 +471,7 @@ describe('StorageReclaimService: categories it may not judge', () => {
id: 'trips',
claims: [{ category: 'covers', prefix: 'sub/' }],
namedKeys: async () => new Set<string>(),
namedKeysByCategory: async () => new Map(),
referenced: async () => new Set<string>(),
};
vi.spyOn(references, 'sources').mockReturnValue([...all.filter((source) => source.id !== 'trips'), partialTrips]);
@@ -413,21 +480,24 @@ describe('StorageReclaimService: categories it may not judge', () => {
expect((await marksOf('covers')).map((mark) => mark.key)).toEqual(['orphan.jpg']);
});
it('RCLS-013: a source answering for a subtree only is not asked about root keys before a delete', async () => {
it('RCLS-013: a source answering for a subtree is read with the rest, and never asked key by key', async () => {
await markedWeekOld(['orphan.jpg']);
const subtree = vi.fn(async () => new Set<string>());
const subtreeRead = vi.fn(async () => new Set<string>());
const subtreeAsked = vi.fn(async () => new Set<string>());
vi.spyOn(references, 'sources').mockReturnValue([
...references.sources(),
{
id: 'trips',
claims: [{ category: 'covers', prefix: 'sub/' }],
namedKeys: async () => new Set(),
referenced: subtree,
namedKeys: subtreeRead,
namedKeysByCategory: async () => new Map<ServedCategory, Set<string>>([['covers', new Set<string>()]]),
referenced: subtreeAsked,
},
]);
const report = await reclaim.run({ now: WEEK_LATER });
expect(report.categories.covers.deleted.objects).toBe(1);
expect(subtree).not.toHaveBeenCalled();
expect(subtreeRead).toHaveBeenCalledWith('covers');
expect(subtreeAsked).not.toHaveBeenCalled();
});
it('RCLS-014: a category whose location holds another category is skipped with its marks', async () => {
@@ -488,17 +558,18 @@ describe('StorageReclaimService: categories it may not judge', () => {
expect(await marksOf('covers')).toEqual([{ key: 'x.jpg', first_seen_at: dbNow(new Date(0)) }]);
});
it('RCLS-018: a source that fails skips the category, never reads it as unreferenced, and keeps its marks', async () => {
it('RCLS-018: a source that fails skips the categories it answers for, never reads them as unreferenced', async () => {
await put('files', 'orphan.pdf');
await markWeekOld('files', ['orphan.pdf']);
await seedCovers(['orphan.jpg']);
await markWeekOld('covers', ['orphan.jpg']);
onAsk = (category) => {
if (category === 'covers') throw new Error('trips table locked');
onRead = (id) => {
if (id === 'files') throw new Error('trip_files table locked');
};
const report = await reclaim.run({ now: T0 });
expect(report.categories.covers).toMatchObject({ objects: 5, skipped: 'source_failed' });
expect(report.categories.places.skipped).toBeNull();
expect(logError).toHaveBeenCalledWith(expect.stringContaining('trips table locked'));
expect(await marksOf('covers')).toHaveLength(1);
expect(report.categories.files).toMatchObject({ objects: 1, skipped: 'source_failed' });
expect(report.categories.covers.skipped).toBeNull();
expect(vi.mocked(logError).mock.calls.filter(([line]) => String(line).includes('table locked'))).toHaveLength(2);
expect(await marksOf('files')).toEqual([{ key: 'orphan.pdf', first_seen_at: dbNow(new Date(0)) }]);
});
});
@@ -506,7 +577,7 @@ describe('StorageReclaimService: when a pass runs at all', () => {
it('RCLS-019: a pass refuses with a coded 409 on a demo instance and while another storage job holds the slot', async () => {
const demo = vi.spyOn(t.get(RuntimeEnvService), 'isDemoMode').mockReturnValue(true);
await expect(reclaim.run()).rejects.toMatchObject({ code: 'reclaim_demo' });
expect(thrownBy(() => reclaim.start())).toMatchObject({ code: 'reclaim_demo' });
await expect(reclaim.start()).rejects.toMatchObject({ code: 'reclaim_demo' });
demo.mockRestore();
expect(jobs.claimExclusive('a test')).toBe(true);
@@ -525,8 +596,8 @@ describe('StorageReclaimService: when a pass runs at all', () => {
it('RCLS-020: while a pass holds the slot, a migration is refused with its name, and the slot is free after', async () => {
let refusal: unknown = null;
onAsk = async (category) => {
if (category !== 'covers' || refusal) return;
onRead = async () => {
if (refusal) return;
refusal = await jobs.startMigration('covers', 'backups-local').catch((err: unknown) => err);
};
await seedCovers(['orphan.jpg']);
@@ -542,10 +613,10 @@ describe('StorageReclaimService: when a pass runs at all', () => {
const gate = new Promise<void>((resolve) => {
release = resolve;
});
onAsk = async () => gate;
reclaim.start();
onRead = async () => gate;
await reclaim.start();
expect((await reclaim.state()).running).toBe(true);
expect(thrownBy(() => reclaim.start())).toMatchObject({ code: 'reclaim_busy' });
await expect(reclaim.start()).rejects.toMatchObject({ code: 'reclaim_busy' });
release();
await jobs.exclusiveIdle();
const state = await reclaim.state();
@@ -567,10 +638,10 @@ describe('StorageReclaimService: when a pass runs at all', () => {
});
describe('StorageReclaimService: the delete itself', () => {
it('RCLS-023: a re-check that fails keeps the object', async () => {
it('RCLS-023: a fresh read that fails keeps the object', async () => {
await markedWeekOld(['orphan.jpg']);
onAsk = (_category, keys) => {
if (keys !== null) throw new Error('lookup timed out');
if (keys === null) throw new Error('lookup timed out');
};
const report = await reclaim.run({ now: WEEK_LATER });
expect(report.categories.covers.deleted.objects).toBe(0);
@@ -594,19 +665,21 @@ describe('StorageReclaimService: the delete itself', () => {
expect(await marksOf('covers')).toEqual([]);
});
it('RCLS-025: the re-check before the delete asks about the whole batch at once, and the listing reads each source once', async () => {
it('RCLS-025: a pass reads each source once for every category, and once more, whole, right before a batch goes', async () => {
await markedWeekOld(['a.jpg', 'b.jpg', 'c.jpg'], 12);
const reads: StorageReferenceSourceId[] = [];
const asked: Array<string[] | null> = [];
onRead = (id) => {
reads.push(id);
};
onAsk = (category, keys) => {
if (category === 'covers') asked.push(keys === null ? null : [...keys].sort());
};
const report = await reclaim.run({ now: WEEK_LATER });
expect(report.categories.covers.deleted.objects).toBe(3);
expect(reads.sort()).toEqual([...STORAGE_REFERENCE_SOURCE_IDS].sort());
const freeForm = STORAGE_REFERENCE_SOURCE_IDS.filter((id) => id !== 'auth' && id !== 'files').length;
expect(asked.filter((keys) => keys === null)).toHaveLength(freeForm);
expect(asked.filter((keys) => keys !== null)).toEqual(
Array.from({ length: freeForm }, () => ['a.jpg', 'b.jpg', 'c.jpg']),
);
expect(asked).toEqual(Array.from({ length: freeForm }, () => null));
});
it('RCLS-026: a delete that fails stops deleting in that category, keeps the mark and says why once', async () => {
@@ -655,13 +728,24 @@ describe('StorageReclaimService: the delete itself', () => {
expect(fs.existsSync(objectPath('places', 'place-orphan.jpg'))).toBe(true);
});
it('RCLS-030: nothing is deleted while a backup reads the uploads', async () => {
it('RCLS-030: nothing is deleted while a backup reads the uploads, and the report says so', async () => {
await markedWeekOld(['orphan.jpg']);
await put('places', 'place-orphan.jpg');
await markWeekOld('places', ['place-orphan.jpg']);
['p1.jpg', 'p2.jpg', 'p3.jpg', 'p4.jpg', 'p5.jpg'].forEach((key) => namedIn('places').add(key));
await Promise.all(['p1.jpg', 'p2.jpg', 'p3.jpg', 'p4.jpg', 'p5.jpg'].map((key) => put('places', key)));
jobs.beginBackup();
try {
const report = await reclaim.run({ now: WEEK_LATER });
expect(report.mode).toBe('delete');
expect(report.categories.covers).toMatchObject({ eligible: { objects: 1 }, deleted: { objects: 0 } });
expect(report.categories.covers).toMatchObject({
eligible: { objects: 1 },
deleted: { objects: 0 },
skipped: 'backup_running',
});
expect(report.categories.places).toMatchObject({ deleted: { objects: 0 }, skipped: 'backup_running' });
// A category with nothing due has nothing to say.
expect(report.categories.avatars.skipped).toBeNull();
expect(fs.existsSync(objectPath('covers', 'orphan.jpg'))).toBe(true);
} finally {
jobs.endBackup();
@@ -684,6 +768,9 @@ describe('StorageReclaimService: a pass that stops, and a restore', () => {
for (const category of ['avatars', 'places'] as const) {
expect(report.categories[category]).toMatchObject({ objects: 0, skipped: 'interrupted' });
}
for (const category of ['photos', 'photos-google', 'photos-trek', 'backups'] as const) {
expect(report.categories[category].skipped).toBe('not_reclaimed');
}
expect((await reclaim.state()).lastRun?.stopped).toBe('database_reopened');
expect(await countRows(orm, AuditLog, { action: 'storage.reclaim_deleted' })).toBe(1);
});
@@ -711,7 +798,7 @@ describe('StorageReclaimService: a pass that stops, and a restore', () => {
let restoreAsked = false;
let restoreWent = false;
onAsk = (_category, keys) => {
if (keys === null || restoreAsked) return;
if (keys !== null || restoreAsked) return;
restoreAsked = true;
restoreStarted = jobs.beginRestore().then(() => {
restoreWent = true;
@@ -729,9 +816,14 @@ describe('StorageReclaimService: a pass that stops, and a restore', () => {
}
});
it('RCLS-034: a reopen turns deletion off and drops marks, confirmations and reports, but keeps the install id', async () => {
it('RCLS-034: a reopen turns deletion off and drops marks, confirmations, the queue and reports, keeping the id', async () => {
await markedWeekOld(['orphan.jpg']);
await setAppSetting(orm, 'storage.reclaim_confirmed', JSON.stringify({ installId, counts: { covers: 1 } }));
await setAppSetting(
orm,
'storage.reclaim_confirmed',
JSON.stringify({ installId, categories: { covers: { count: 1, confirmedAt: Date.now() } } }),
);
await orm.repo(StorageReleaseQueue).enqueue([{ category: 'covers', key: 'queued.jpg' }], dbNow(new Date()));
// A restored database carries another install's id and switch.
await setAppSetting(orm, 'install_id', '12345678-aaaa-4bbb-8ccc-1234567890ab');
await setAppSetting(orm, 'storage.reclaim_delete', '12345678-aaaa-4bbb-8ccc-1234567890ab');
@@ -740,8 +832,61 @@ describe('StorageReclaimService: a pass that stops, and a restore', () => {
const reset = ['storage.reclaim_delete', 'storage.reclaim_confirmed', 'storage.reclaim_report'];
expect(await Promise.all(reset.map((key) => readAppSetting(orm, key)))).toEqual([null, null, null]);
expect(await marksOf('covers')).toEqual([]);
expect(await countRows(orm, StorageReleaseQueue)).toBe(0);
expect(await reclaim.state()).toEqual({ deleteEnabled: false, running: false, lastRun: null, lastDeleteRun: null });
});
it('RCLS-040: at boot, a database naming another installation loses its reclaim state and gets this id', async () => {
const install = t.get(StorageInstallService);
await markedWeekOld(['orphan.jpg']);
await orm.repo(StorageReleaseQueue).enqueue([{ category: 'covers', key: 'queued.jpg' }], dbNow(new Date()));
// As this installation's own database: nothing to reset.
expect(await install.reconcile()).toBe(false);
expect((await reclaim.state()).deleteEnabled).toBe(true);
// A database restored at boot, cloned or copied from another one.
await setAppSetting(orm, 'install_id', '12345678-aaaa-4bbb-8ccc-1234567890ab');
await setAppSetting(orm, 'storage.reclaim_delete', '12345678-aaaa-4bbb-8ccc-1234567890ab');
expect(await install.reconcile()).toBe(true);
expect(await readAppSetting(orm, 'install_id')).toBe(installId);
expect(await readAppSetting(orm, 'storage.reclaim_delete')).toBeNull();
expect(await readAppSetting(orm, 'storage.reclaim_report')).toBeNull();
expect(await marksOf('covers')).toEqual([]);
expect(await countRows(orm, StorageReleaseQueue)).toBe(0);
});
it('RCLS-041: the install id file is made once, mode 0600; a garbled one keeps every delete off', async () => {
const file = path.join(dataRoot, 'id');
expect(fs.readFileSync(file, 'utf8').trim()).toBe(installId);
if (process.platform !== 'win32') expect(fs.statSync(file).mode & 0o777).toBe(0o600);
const garbled = path.join(dataRoot, 'garbled');
fs.writeFileSync(garbled, 'not an id\n');
const install = t.get(StorageInstallService);
const paths = t.get(DataPathsService) as { installIdFile: string };
/** The service reads its file once; these cases read another one afresh. */
const reread = (file: string) => {
paths.installIdFile = file;
(install as unknown as { fileId: string | null | undefined }).fileId = undefined;
return install.installId();
};
try {
expect(reread(garbled)).toBeNull();
await expect(reclaim.setDeleteEnabled(true)).rejects.toMatchObject({ code: 'reclaim_install_unknown' });
expect((await reclaim.state()).deleteEnabled).toBe(false);
expect(await install.reconcile()).toBe(false);
} finally {
expect(reread(file)).toBe(installId);
}
// A missing file is made, once.
const fresh = path.join(dataRoot, 'nested', 'fresh-id');
try {
const made = reread(fresh);
expect(made).toMatch(/^[0-9a-f-]{36}$/);
expect(fs.readFileSync(fresh, 'utf8').trim()).toBe(made);
expect(reread(fresh)).toBe(made);
} finally {
expect(reread(file)).toBe(installId);
}
});
});
describe('StorageReclaimService: the switch and the stored reports', () => {
@@ -759,6 +904,31 @@ describe('StorageReclaimService: the switch and the stored reports', () => {
expect(await readsOff('12345678-aaaa-4bbb-8ccc-1234567890ab')).toBe(false);
});
it('RCLS-042: without an install id a pass judges nothing and deletes nothing', async () => {
await markedWeekOld(['orphan.jpg']);
vi.spyOn(t.get(StorageInstallService), 'installId').mockReturnValue(null);
const report = await reclaim.run({ now: WEEK_LATER });
expect(report.mode).toBe('report');
expect(report.categories.covers).toMatchObject({ skipped: 'owner_unknown', deleted: { objects: 0 } });
expect(fs.existsSync(objectPath('covers', 'orphan.jpg'))).toBe(true);
});
it('RCLS-043: a pass waits for a release tick holding the slot instead of losing its night', async () => {
expect(jobs.claimExclusive(STORAGE_RELEASE_JOB)).toBe(true);
const running = reclaim.run({ now: T0 });
await Promise.resolve();
expect(jobs.exclusiveHolder).toBe(STORAGE_RELEASE_JOB);
jobs.releaseExclusive(STORAGE_RELEASE_JOB);
expect((await running).stopped).toBeNull();
// Any other holder still refuses at once.
expect(jobs.claimExclusive('a backfill')).toBe(true);
try {
await expect(reclaim.run({ now: T0 })).rejects.toMatchObject({ code: 'reclaim_busy' });
} finally {
jobs.releaseExclusive('a backfill');
}
});
it('RCLS-036: a stored report that no longer parses reads as never run', async () => {
await setAppSetting(orm, 'storage.reclaim_report', '{not json');
expect((await reclaim.state()).lastRun).toBeNull();
@@ -0,0 +1,85 @@
/**
* StorageReleaseJob: every ten minutes, the due rows of the release queue.
* It hands the registrar the tick's own promise, so the lease covers the whole
* tick, logs at debug only, and contains a failure in one error line.
* RELJ-001 to RELJ-004.
*/
import { logDebug, logError } from '../../../../src/nest/audit/audit-log.logger';
import type { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
import { StorageReleaseJob } from '../../../../src/nest/storage/storage-release.job';
import type { ReleaseTickResult, StorageReleaseService } from '../../../../src/nest/storage/storage-release.service';
import { 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(),
}));
function makeJob(processDue: () => Promise<ReleaseTickResult>, enabled = true) {
const registrar = {
isEnabled: vi.fn(() => enabled),
register: vi.fn((_name: string, _expression: string, _onTick: () => void | Promise<void>) => enabled),
};
const release = { processDue: vi.fn(processDue) };
const job = new StorageReleaseJob(
registrar as unknown as CronRegistrarService,
release as unknown as StorageReleaseService,
);
return { job, registrar, release };
}
const ran = (deleted: number, kept: number): ReleaseTickResult => ({ skipped: null, deleted, kept });
beforeEach(() => vi.clearAllMocks());
describe('StorageReleaseJob', () => {
it('RELJ-001: registers every ten minutes and hands the registrar the tick promise itself', async () => {
let finish: (value: ReleaseTickResult) => void = () => undefined;
const { job, registrar, release } = makeJob(() => new Promise((resolve) => (finish = resolve)));
job.onApplicationBootstrap();
expect(registrar.register).toHaveBeenCalledWith('storage-release', '*/10 * * * *', expect.any(Function));
const [call] = registrar.register.mock.calls;
if (!call) throw new Error('the job registered nothing');
const ticking = call[2]();
expect(ticking).toBeInstanceOf(Promise);
let settled = false;
void (ticking as Promise<void>).then(() => (settled = true));
await Promise.resolve();
expect(settled).toBe(false);
finish(ran(0, 0));
await ticking;
expect(release.processDue).toHaveBeenCalledTimes(1);
});
it('RELJ-002: does not register under the test gate', () => {
const { job, registrar, release } = makeJob(async () => ran(0, 0), false);
job.onApplicationBootstrap();
expect(registrar.register).not.toHaveBeenCalled();
expect(release.processDue).not.toHaveBeenCalled();
});
it('RELJ-003: says at debug what a tick did or why it skipped, and nothing for an empty one', async () => {
const quiet = makeJob(async () => ran(0, 0));
await quiet.job.tick(new Date(5));
expect(quiet.release.processDue).toHaveBeenCalledWith(new Date(5));
expect(logDebug).not.toHaveBeenCalled();
await makeJob(async () => ran(3, 1)).job.tick();
expect(logDebug).toHaveBeenCalledWith('Storage release: deleted 3, kept 1 still named');
await makeJob(async () => ({ skipped: 'busy', deleted: 0, kept: 0 })).job.tick();
expect(logDebug).toHaveBeenCalledWith('Storage release skipped: busy');
expect(logError).not.toHaveBeenCalled();
});
it('RELJ-004: a failure is contained in one error line', async () => {
await makeJob(async () => {
throw new Error('registry not initialized');
}).job.tick();
expect(logError).toHaveBeenCalledWith('Storage release: registry not initialized');
await makeJob(vi.fn<() => Promise<ReleaseTickResult>>().mockRejectedValue('plain')).job.tick();
expect(logError).toHaveBeenCalledWith('Storage release: plain');
});
});
@@ -1,15 +1,23 @@
/**
* StorageReleaseService (#2924): what a user's delete or replace left unnamed
* goes right after it, 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; a failure never reaches the caller.
* RELS-001 to RELS-010.
* 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. RELS-001 to RELS-021.
*/
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,
@@ -18,12 +26,16 @@ import {
type StorageReferenceSource,
type StorageReferenceSourceId,
} from '../../../../src/nest/storage/storage-reclaim.types';
import { StorageReleaseService } from '../../../../src/nest/storage/storage-release.service';
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 { deleteRows } from '../../../helpers/factories/rows';
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';
@@ -42,13 +54,17 @@ vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) =>
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[] }>;
@@ -63,6 +79,9 @@ function fakeSource(id: StorageReferenceSourceId): StorageReferenceSource {
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;
@@ -80,25 +99,49 @@ function exists(category: string, key: string): boolean {
return fs.existsSync(path.join(uploadsRoot, category, key));
}
beforeAll(async () => {
uploadsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-release-uploads-'));
orm = await createTestOrm(db);
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 } }]),
JSON.stringify([{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }, ...extra]),
);
t = await createTestModule({ db, imports: [StorageModule], init: true });
}
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') };
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(() => {
beforeEach(async () => {
vi.restoreAllMocks();
vi.mocked(logWarn).mockClear();
await deleteRows(orm, StorageReleaseQueue);
t.get(StorageReclaimGuardService).forget();
named = new Map();
asked = [];
failWith = null;
@@ -110,141 +153,291 @@ afterAll(async () => {
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', () => {
it('RELS-001: deletes what no row names, keeps what one still does, and answers what went', async () => {
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 released = await release.releaseValues([`/uploads/covers/${UUID}-old.jpg`, `covers/${UUID}-shared.jpg`]);
expect(released).toEqual([{ category: 'covers', key: `${UUID}-old.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 () => {
await put('places', `${UUID}.jpg`);
await put('avatars', `${UUID}.png`);
await put('files', `${UUID}. #4711`);
const released = await release.releaseValues([`https://trip.example.test/uploads/places/${UUID}.jpg?v=2`]);
expect(released).toEqual([{ category: 'places', key: `${UUID}.jpg` }]);
expect(await release.releaseValues([`${UUID}.png`], { bare: 'avatars' })).toEqual([
{ category: 'avatars', key: `${UUID}.png` },
]);
expect(await release.releaseValues([`legacy/${UUID}. #4711`], { bare: 'files', basename: true })).toEqual([
{ category: 'files', key: `${UUID}. #4711` },
]);
// Every source that claims the category was asked about the keys, narrowed, never about the whole category.
expect(
asked
.filter((question) => question.category === 'files')
.map((question) => question.id)
.sort(),
).toEqual(['collections', 'files', 'journey', 'photos', 'place-contrib', 'places', 'trips'].sort());
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 touches a nested key, an owner marker or a category the ledger keeps', async () => {
await put('journey', `thumbs/${UUID}.jpg`);
await put('covers', storageOwnerMarkerKey(UUID));
await put('photos', `${UUID}.jpg`);
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,
]),
).toEqual([]);
expect(exists('journey', `thumbs/${UUID}.jpg`)).toBe(true);
expect(exists('covers', storageOwnerMarkerKey(UUID))).toBe(true);
expect(exists('photos', `${UUID}.jpg`)).toBe(true);
).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([]);
});
});
it('RELS-004: a category missing a required source is left to the nightly pass', async () => {
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'));
expect(await release.releaseIfUnreferenced('avatars', [`${UUID}-a.png`])).toEqual([]);
await release.processDue(due());
expect(exists('avatars', `${UUID}-a.png`)).toBe(true);
expect(await queued('avatars')).toBe(1);
});
it('RELS-005: nothing goes while a restore runs', async () => {
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`);
const jobs = t.get(StorageJobsService);
await release.releaseValues([`/uploads/covers/${UUID}-r.jpg`]);
await jobs.beginRestore();
try {
expect(await release.releaseIfUnreferenced('covers', [`${UUID}-r.jpg`])).toEqual([]);
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-006: a category whose location overlaps another is left alone', async () => {
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 setAppSetting(
orm,
'storage.backends',
JSON.stringify([
{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } },
{ name: 'nested', type: 'local', options: { root: path.join(uploadsRoot, 'covers') } },
]),
);
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 {
expect(await release.releaseIfUnreferenced('covers', [`${UUID}-s.jpg`])).toEqual([]);
await release.processDue(due());
expect(exists('covers', `${UUID}-s.jpg`)).toBe(true);
expect(await queued('covers')).toBe(1);
} finally {
await setAppSetting(
orm,
'storage.backends',
JSON.stringify([{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }]),
);
await setBackends();
await deleteRows(orm, AppSettings, { key: 'storage.categories' });
await storage.reloadConfig();
}
});
it('RELS-007: a source that fails keeps everything and never throws', async () => {
await put('covers', `${UUID}-f.jpg`);
failWith = new Error('places table locked');
await expect(release.releaseValues([`/uploads/covers/${UUID}-f.jpg`])).resolves.toEqual([]);
expect(exists('covers', `${UUID}-f.jpg`)).toBe(true);
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('places table locked'));
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-008: a delete that fails is logged and answered as kept, the others still go', async () => {
await put('covers', `${UUID}-d1.jpg`);
await put('covers', `${UUID}-d2.jpg`);
const realDelete = storage.delete.bind(storage);
vi.spyOn(storage, 'delete').mockImplementation(async (category, name) => {
if (name.endsWith('-d1.jpg')) throw new Error('permission denied');
await realDelete(category, name);
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');
});
const released = await release.releaseIfUnreferenced('covers', [`${UUID}-d1.jpg`, `${UUID}-d2.jpg`]);
expect(released).toEqual([`${UUID}-d2.jpg`]);
expect(exists('covers', `${UUID}-d1.jpg`)).toBe(true);
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('permission denied'));
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-009: an object the facade keeps on delete (a demo) and one that is not there are not reported', async () => {
await put('covers', `${UUID}-k.jpg`);
const keeps = vi.spyOn(storage, 'keepsOnDelete').mockReturnValue(true);
expect(await release.releaseIfUnreferenced('covers', [`${UUID}-k.jpg`])).toEqual([]);
expect(exists('covers', `${UUID}-k.jpg`)).toBe(true);
keeps.mockRestore();
expect(await release.releaseIfUnreferenced('covers', [`${UUID}-missing.jpg`])).toEqual([]);
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-010: 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`]));
expect(await release.releaseValues([`/uploads/covers/${UUID.toUpperCase()}-CASE.JPG`])).toEqual([]);
expect(exists('covers', `${UUID}-case.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);
});
});