#194: orphan-file sweep + drop Attachment.deletedAt #249

Merged
fable-5 merged 2 commits from feat/194-orphan-sweep into main 2026-07-30 13:45:57 +02:00
12 changed files with 364 additions and 31 deletions

View File

@ -0,0 +1,4 @@
-- Issue #194: attachments have exactly one deletion semantics (hard delete
-- by sweep, purge, or manual removal) — the never-written soft-delete
-- marker goes away.
ALTER TABLE "attachments" DROP COLUMN "deleted_at";

View File

@ -529,26 +529,28 @@ model PondUsage {
/// `<uploadsDir>/<pondId>/<id>` (FileStorageService); this row carries the
/// metadata needed to serve and account for it. `pageId` starts unset —
/// images are uploaded before the page referencing them is known
/// (paste-then-insert, issue #28) — and is set on every page state save to
/// whichever page's document currently embeds the file (issue #31,
/// `PagesService.saveState`); the trash-purge job uses that link to delete
/// a purged page's files. Not touched when an image is later removed from
/// its page's content — an orphan-file sweep to reclaim those is a
/// separate future maintenance job (operations.md), not this one.
/// `deletedAt` stays unused for now — purge hard-deletes attachments
/// directly rather than soft-deleting them first — reserved for that same
/// future orphan-sweep job.
/// (paste-then-insert, issue #28) — and is claimed on every collab persist
/// by whichever page's document embeds the file (issue #31), or at upload
/// for the page attachments panel (#61); the trash-purge job uses that
/// link to delete a purged page's files. A row whose `pageId` is STILL
/// null after a grace period was claimed by nothing and is reclaimed by
/// the nightly orphan-file sweep (issue #194, OrphanSweepService).
/// Claimed files are deliberately NOT auto-reclaimed when the content
/// stops referencing them: the page attachments panel lists them as
/// user-managed objects (insert is optional there), so "not embedded" is
/// not "unused" — the pond file manager is the human cleanup path.
/// Deletion is hard everywhere (sweep, purge, manual) — there is no
/// soft-delete state on attachments (issue #194 removed `deletedAt`).
model Attachment {
id String @id @default(uuid())
pondId String @map("pond_id")
pageId String? @map("page_id")
fileName String @map("file_name")
mimeType String @map("mime_type")
sizeBytes Int @map("size_bytes")
storagePath String @map("storage_path")
uploadedBy String @map("uploaded_by")
createdAt DateTime @default(now()) @map("created_at")
deletedAt DateTime? @map("deleted_at")
id String @id @default(uuid())
pondId String @map("pond_id")
pageId String? @map("page_id")
fileName String @map("file_name")
mimeType String @map("mime_type")
sizeBytes Int @map("size_bytes")
storagePath String @map("storage_path")
uploadedBy String @map("uploaded_by")
createdAt DateTime @default(now()) @map("created_at")
pond Pond @relation(fields: [pondId], references: [id])
page Page? @relation(fields: [pageId], references: [id])

View File

@ -1,5 +1,5 @@
import { createReadStream } from 'node:fs';
import { access, mkdir, rm, writeFile } from 'node:fs/promises';
import { access, mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Readable } from 'node:stream';
@ -46,4 +46,37 @@ export class FileStorageService {
async delete(pondId: string, fileId: string): Promise<void> {
await rm(this.pathFor(pondId, fileId), { force: true });
}
/**
* Every stored file with its modification time, for the orphan sweep's
* volumedatabase direction (issue #194, ADR 0011). A missing uploads
* directory is an empty volume, not an error.
*/
async listStored(): Promise<{ pondId: string; fileId: string; mtimeMs: number }[]> {
const root = this.config.env.UPLOADS_DIR;
const result: { pondId: string; fileId: string; mtimeMs: number }[] = [];
let pondDirs: string[];
try {
pondDirs = await readdir(root);
} catch {
return result;
}
for (const pondId of pondDirs) {
let files: string[];
try {
files = await readdir(join(root, pondId));
} catch {
continue; // not a directory or vanished mid-walk
}
for (const fileId of files) {
try {
const info = await stat(join(root, pondId, fileId));
if (info.isFile()) result.push({ pondId, fileId, mtimeMs: info.mtimeMs });
} catch {
// vanished mid-walk — the next sweep sees the truth
}
}
}
return result;
}
}

View File

@ -301,7 +301,6 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200);
const stillThere = await prisma.attachment.findUnique({ where: { id: uploaded.body.id } });
expect(stillThere).not.toBeNull();
expect(stillThere?.deletedAt).toBeNull();
});
it('lists a page attachment for the page and links it (#61)', async () => {

View File

@ -1,16 +1,38 @@
import { Module } from '@nestjs/common';
import { Module, OnModuleInit } from '@nestjs/common';
import { CommonModule } from '../common/common.module';
import { PondsModule } from '../ponds/ponds.module';
import { QuotasModule } from '../quotas/quotas.module';
import { SchedulerModule } from '../scheduler/scheduler.module';
import { SchedulerService } from '../scheduler/scheduler.service';
import { FileStorageService } from './file-storage.service';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
import { OrphanSweepService } from './orphan-sweep.service';
/** Nightly, per operations.md's maintenance-jobs table (issue #194). */
const ORPHAN_SWEEP_CADENCE_SECONDS = 24 * 60 * 60;
@Module({
imports: [PondsModule, QuotasModule],
imports: [CommonModule, PondsModule, QuotasModule, SchedulerModule],
controllers: [FilesController],
providers: [FilesService, FileStorageService],
providers: [FilesService, FileStorageService, OrphanSweepService],
exports: [FileStorageService, FilesService],
})
export class FilesModule {}
export class FilesModule implements OnModuleInit {
constructor(
private readonly scheduler: SchedulerService,
private readonly sweep: OrphanSweepService,
) {}
onModuleInit(): void {
this.scheduler.register({
name: 'orphan-file-sweep',
cadenceSeconds: ORPHAN_SWEEP_CADENCE_SECONDS,
run: async () => {
await this.sweep.sweep();
},
});
}
}

View File

@ -208,7 +208,7 @@ export class FilesService {
/** Attachments linked to a page, for its attachments section (#61). */
async listForPage(pageId: string): Promise<AttachmentListItemView[]> {
const rows = await this.prisma.attachment.findMany({
where: { pageId, deletedAt: null },
where: { pageId },
orderBy: { createdAt: 'desc' },
include: { uploader: true, page: true },
});
@ -219,7 +219,7 @@ export class FilesService {
async listForPond(pondId: string): Promise<PondFilesView> {
const [rows, usage, storageBytesLimit] = await Promise.all([
this.prisma.attachment.findMany({
where: { pondId, deletedAt: null },
where: { pondId },
orderBy: { createdAt: 'desc' },
include: { uploader: true, page: true },
}),

View File

@ -0,0 +1,179 @@
import { existsSync } from 'node:fs';
import { utimes, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { OrphanSweepService } from './orphan-sweep.service';
const HOUR = 60 * 60 * 1000;
/**
* Orphan-file sweep (issue #194): unclaimed attachments past the grace
* period are reclaimed (row, file, quota), fresh ones are protected
* (paste-then-insert), claimed ones are never touched the page
* attachments panel is a legitimate reference and stray files without a
* database row disappear once old enough.
*/
describe.skipIf(!hasTestDb)('orphan file sweep (e2e, issue #194)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'orphan sweep pass 1';
const ids: Record<string, string> = {};
const cookies: Record<string, string> = {};
let pondId: string;
let pageId: string;
const api = () => request(app.getHttpServer());
const fileOnDisk = (fondId: string, fileId: string) =>
join(process.env.UPLOADS_DIR!, fondId, fileId);
async function makeUser(handle: string, siteAdmin = false): Promise<void> {
const users = app.get(UsersService);
const username = `os-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Sweep ${handle}`,
password,
locale: 'en',
});
ids[handle] = user.id;
await users.markEmailVerified(user.id);
if (siteAdmin) {
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
}
cookies[handle] = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
}
/** A real upload via the pond route (pageId stays null = unclaimed). */
async function uploadUnclaimed(name: string): Promise<string> {
const res = await api()
.post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', cookies.owner!)
.attach('file', Buffer.from(`bytes of ${name}`), name)
.expect(201);
return res.body.id as string;
}
function backdate(attachmentId: string, ageMs: number): Promise<unknown> {
return prisma.attachment.update({
where: { id: attachmentId },
data: { createdAt: new Date(Date.now() - ageMs) },
});
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
await makeUser('owner');
await makeUser('admin', true);
await api()
.put(`/api/v1/admin/quotas/user/${ids.owner!}/additional_ponds`)
.set('Cookie', cookies.admin!)
.send({ value: 5 })
.expect(200);
const pond = await api()
.post('/api/v1/ponds')
.set('Cookie', cookies.owner!)
.send({ name: `Sweep Pond ${suffix}` })
.expect(201);
pondId = pond.body.id;
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookies.owner!)
.send({ title: `Sweep Page ${suffix}` })
.expect(201);
pageId = page.body.id;
});
afterAll(async () => {
const all = Object.values(ids);
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: all } } });
await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } });
const ponds = await prisma.pond.findMany({
where: { ownerId: { in: all } },
select: { id: true },
});
const pondIds = ponds.map((p) => p.id);
await prisma.attachment.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.pond.deleteMany({ where: { id: { in: pondIds } } });
await prisma.watch.deleteMany({ where: { userId: { in: all } } });
await prisma.session.deleteMany({ where: { userId: { in: all } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
await prisma.user.deleteMany({ where: { id: { in: all } } });
await prisma.$disconnect();
await app.close();
});
it('reclaims unclaimed attachments past the grace period, protects fresh and claimed ones', async () => {
const oldUnclaimed = await uploadUnclaimed('old-unclaimed.txt');
const freshUnclaimed = await uploadUnclaimed('fresh-unclaimed.txt');
const oldClaimed = await api()
.post(`/api/v1/pages/${pageId}/files`)
.set('Cookie', cookies.owner!)
.attach('file', Buffer.from('panel asset'), 'panel-asset.txt')
.expect(201);
await backdate(oldUnclaimed, 25 * HOUR);
await backdate(oldClaimed.body.id, 25 * HOUR);
const usageBefore = await prisma.pondUsage.findUnique({ where: { pondId } });
const reclaimedBytes = (
await prisma.attachment.findUniqueOrThrow({
where: { id: oldUnclaimed },
})
).sizeBytes;
const result = await app.get(OrphanSweepService).sweep();
expect(result.reclaimed).toBeGreaterThanOrEqual(1);
// The old unclaimed upload is gone: row, file, quota.
expect(await prisma.attachment.findUnique({ where: { id: oldUnclaimed } })).toBeNull();
expect(existsSync(fileOnDisk(pondId, oldUnclaimed))).toBe(false);
const usageAfter = await prisma.pondUsage.findUnique({ where: { pondId } });
expect(Number(usageBefore!.storageBytesUsed) - Number(usageAfter!.storageBytesUsed)).toBe(
reclaimedBytes,
);
// The fresh unclaimed upload survives (paste-then-insert grace).
expect(await prisma.attachment.findUnique({ where: { id: freshUnclaimed } })).not.toBeNull();
expect(existsSync(fileOnDisk(pondId, freshUnclaimed))).toBe(true);
// The claimed panel asset survives despite its age — never swept.
expect(
await prisma.attachment.findUnique({ where: { id: oldClaimed.body.id } }),
).not.toBeNull();
expect(existsSync(fileOnDisk(pondId, oldClaimed.body.id))).toBe(true);
});
it('removes stray files without a database row once they are old enough', async () => {
const dir = join(process.env.UPLOADS_DIR!, pondId);
await mkdir(dir, { recursive: true });
const oldStray = join(dir, `stray-old-${suffix}`);
const freshStray = join(dir, `stray-fresh-${suffix}`);
await writeFile(oldStray, 'stray bytes');
await writeFile(freshStray, 'stray bytes');
const past = new Date(Date.now() - 25 * HOUR);
await utimes(oldStray, past, past);
const result = await app.get(OrphanSweepService).sweep();
expect(existsSync(oldStray)).toBe(false);
expect(existsSync(freshStray)).toBe(true);
expect(result.strays).toBeGreaterThanOrEqual(1);
});
});

View File

@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { ClockService } from '../common/clock.service';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service';
import { FileStorageService } from './file-storage.service';
/**
* Nightly orphan-file sweep (issue #194, ADR 0011) with two directions:
*
* 1. UNCLAIMED ROWS: an attachment whose `pageId` is still null after the
* grace period was claimed by nothing not by a collab persist (which
* claims every embedded image, apps/collab persistence), not by a page
* upload (#61), not by an import (`linkAttachmentsToPage`). The pond
* file manager flags exactly these as orphans; the sweep reclaims them
* (row + file + quota). The grace period protects paste-then-insert:
* an upload is unclaimed until the ~2 s-debounced persist runs.
*
* 2. STRAY FILES: bytes on the uploads volume without a database row
* (volumeDB drift, e.g. a crash between file write and row insert).
* Removed once older than the grace period; no quota to correct the
* reservation was rolled back with the failed upload.
*
* Deliberately NOT swept: claimed attachments whose page content no longer
* references them. The page attachments panel lists claimed files as
* user-managed objects inserting into the document is optional there
* so "not embedded" is not "unused"; auto-deleting would destroy panel
* assets. Humans clean those up in the panel or the pond file manager.
*/
const GRACE_MS = 24 * 60 * 60 * 1000;
@Injectable()
export class OrphanSweepService {
constructor(
private readonly prisma: PrismaService,
private readonly storage: FileStorageService,
private readonly quotas: QuotaService,
private readonly clock: ClockService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(OrphanSweepService.name);
}
async sweep(): Promise<{ reclaimed: number; strays: number }> {
const cutoff = this.clock.now().getTime() - GRACE_MS;
const unclaimed = await this.prisma.attachment.findMany({
where: { pageId: null, createdAt: { lte: new Date(cutoff) } },
});
for (const attachment of unclaimed) {
// File first (idempotent), then row + quota — a crash in between
// leaves a row the next sweep finishes, never untracked bytes.
await this.storage.delete(attachment.pondId, attachment.id);
await this.prisma.attachment.deleteMany({ where: { id: attachment.id } });
await this.quotas.release(attachment.pondId, attachment.sizeBytes);
this.logger.info(
{ attachmentId: attachment.id, pondId: attachment.pondId },
'audit: orphaned attachment reclaimed',
);
}
let strays = 0;
const stored = await this.storage.listStored();
const ids = new Set(
(await this.prisma.attachment.findMany({ select: { id: true } })).map((a) => a.id),
);
for (const file of stored) {
if (ids.has(file.fileId) || file.mtimeMs > cutoff) continue;
await this.storage.delete(file.pondId, file.fileId);
strays += 1;
this.logger.info(
{ fileId: file.fileId, pondId: file.pondId },
'audit: stray file without database row removed',
);
}
return { reclaimed: unclaimed.length, strays };
}
}

View File

@ -117,7 +117,7 @@ export class ExportService {
const attachmentRows =
referenced.size > 0
? await this.prisma.attachment.findMany({
where: { id: { in: [...referenced] }, pondId, deletedAt: null },
where: { id: { in: [...referenced] }, pondId },
select: { id: true, mimeType: true },
})
: [];
@ -293,7 +293,7 @@ export class ExportService {
const dataUriById = new Map<string, string>();
if (ids.length === 0) return dataUriById;
const attachments = await this.prisma.attachment.findMany({
where: { id: { in: ids }, pondId, deletedAt: null },
where: { id: { in: ids }, pondId },
select: { id: true, mimeType: true },
});
for (const attachment of attachments) {

View File

@ -17,9 +17,10 @@ test('lists maintenance jobs and triggers one manually', async ({ browser }) =>
const jobsTable = page.locator('.system-jobs__table');
await expect(jobsTable).toBeVisible();
// All registered jobs appear (language-neutral: row count + button).
// 5 → 6 with issue #194: the orphan-file sweep joined the job table.
// Keep in sync with the scheduler registrations: trash-purge,
// version-thinning, page-compaction, data-export-purge, notification-digest.
await expect(jobsTable.locator('tbody tr')).toHaveCount(5);
await expect(jobsTable.locator('tbody tr')).toHaveCount(6);
const firstRow = jobsTable.locator('tbody tr').first();
await firstRow.getByRole('button').click();

View File

@ -110,6 +110,18 @@ monitoring, structured logs, backup alerting — no dedicated metrics stack.
Job outcomes are visible in the Site Admin UI (last run, status) — that
panel is the operator's single glance for instance health.
Orphan file sweep (issue #194): nightly, two directions. Attachments
still unclaimed (`pageId` null) after a 24 h grace period — claimed by
no collab persist, page upload, or import — are reclaimed (row, file,
quota); the grace period protects the paste-then-insert window. Files on
the uploads volume without a database row (drift after a crashed upload)
are removed once older than the grace period. Claimed attachments whose
page content no longer embeds them are deliberately NOT auto-deleted:
the page attachments panel lists them as user-managed objects (insert is
optional there), so cleanup of those is a human decision in the panel or
the pond file manager. Attachment deletion is hard everywhere — the
unused `deleted_at` column was removed with #194.
Pond purge (issue #193): a trashed pond past the trash retention is
removed with everything it holds — pages (cascading versions, comments,
content cache incl. the search vector, update log, mentions, label

View File

@ -100,7 +100,7 @@ chain`_
- [x] **Backup-Ziele einschränkbar** — Allowlist, WebDAV/rsync per Deploy
vollständig deaktivierbar · 2 AT · #192
- [x] **Pond-Purge implementieren** — getrashte Ponds bleiben ewig liegen · 3 AT · #193
- [ ] **Orphan-File-Sweep** implementieren, `Attachment.deletedAt` nutzen
- [x] **Orphan-File-Sweep** implementieren, `Attachment.deletedAt` nutzen
oder entfernen · 2 AT · #194
- [ ] **Papierkorb aus dem Suchindex** entfernen statt query-seitig filtern · 2 AT · #195
- [ ] **Retention-Job für `audit_log`** · 1 AT · #196