feat(media): orphan cleanup — track objects + reap unreferenced attachments
Attachments upload direct-to-storage on attach, so an abandoned attach (removed, cancelled, tab closed) would linger forever. Add IiosMediaObject: a row is recorded when bytes land (MediaService.put); a scheduled sweepOrphans() reaps objects past a grace window (IIOS_MEDIA_ORPHAN_GRACE_MS, default 24h) that no IiosMessagePart references (derived live — no flag to drift). Storage delete via the StoragePort; best-effort + idempotent. Gated on IIOS_MEDIA_GC_INTERVAL_MS (off by default). Migration 20260722193958_media_object_tracking. 10 media tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, PayloadTooLargeException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, type OnModuleDestroy, type OnModuleInit, PayloadTooLargeException } from '@nestjs/common';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { decideOrThrow } from '../platform/fail-closed';
|
||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||
import { STORAGE_PORT, type StoragePort } from './storage.port';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface UploadToken { op: 'put'; objectKey: string; mime: string; maxBytes: number }
|
||||
interface DownloadToken { op: 'get'; objectKey: string; mime: string }
|
||||
|
||||
@@ -17,16 +20,35 @@ interface DownloadToken { op: 'get'; objectKey: string; mime: string }
|
||||
* kernel only ever stores the object key (contentRef) on a MessagePart.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
export class MediaService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly secret = process.env.MEDIA_SECRET?.trim() || 'dev-media-secret';
|
||||
private readonly publicUrl = (process.env.PUBLIC_URL?.trim() || `http://localhost:${process.env.PORT ?? 3200}`).replace(/\/$/, '');
|
||||
/** Objects unreferenced by any message part AND older than this are reaped by the sweep. The grace
|
||||
* window must exceed the longest realistic compose time so an attached-but-unsent file survives. */
|
||||
private readonly orphanGraceMs = Number(process.env.IIOS_MEDIA_ORPHAN_GRACE_MS ?? DAY_MS);
|
||||
private readonly logger = new Logger(MediaService.name);
|
||||
private gcTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(
|
||||
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
||||
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
||||
private readonly actors: ActorResolver,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const ms = Number(process.env.IIOS_MEDIA_GC_INTERVAL_MS ?? 0);
|
||||
if (ms > 0) {
|
||||
this.gcTimer = setInterval(() => {
|
||||
void this.sweepOrphans().catch((err) => this.logger.warn(`media orphan sweep failed: ${(err as Error).message}`));
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.gcTimer) clearInterval(this.gcTimer);
|
||||
}
|
||||
|
||||
/** Authorize an upload and return a short-lived signed PUT url + the object key. */
|
||||
async presignUpload(
|
||||
principal: MessagePrincipal,
|
||||
@@ -44,9 +66,58 @@ export class MediaService {
|
||||
const t = this.verify<UploadToken>(token, 'put');
|
||||
if (data.length > t.maxBytes) throw new PayloadTooLargeException('upload exceeds the presigned size');
|
||||
const { sizeBytes, checksumSha256 } = await this.storage.put(t.objectKey, data, t.mime);
|
||||
// Track the object so the orphan sweep can reap it if it's never referenced by a message part.
|
||||
// Best-effort: tracking must never fail an otherwise-successful upload. scopeId is the key prefix.
|
||||
const scopeId = t.objectKey.split('/')[0] ?? 'unknown';
|
||||
await this.prisma.iiosMediaObject
|
||||
.upsert({
|
||||
where: { objectKey: t.objectKey },
|
||||
create: { scopeId, objectKey: t.objectKey, mime: t.mime, sizeBytes: BigInt(sizeBytes) },
|
||||
update: { sizeBytes: BigInt(sizeBytes) },
|
||||
})
|
||||
.catch((err) => this.logger.warn(`media tracking upsert failed for ${t.objectKey}: ${(err as Error).message}`));
|
||||
return { objectKey: t.objectKey, sizeBytes, checksumSha256 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap orphaned media: objects older than the grace window that NO message part references. A
|
||||
* file attached in a composer uploads immediately (direct-to-storage), so an abandoned attach
|
||||
* (removed, cancelled, tab closed) would otherwise linger forever — there is no draft/commit step.
|
||||
* "Referenced" is derived live from IiosMessagePart.contentRef, so nothing can drift. Returns the
|
||||
* number of objects deleted. Safe to call repeatedly (idempotent); storage delete is best-effort.
|
||||
*/
|
||||
async sweepOrphans(now: number = Date.now(), batch = 1000): Promise<number> {
|
||||
const cutoff = new Date(now - this.orphanGraceMs);
|
||||
const candidates = await this.prisma.iiosMediaObject.findMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
select: { id: true, objectKey: true },
|
||||
take: batch,
|
||||
});
|
||||
if (candidates.length === 0) return 0;
|
||||
|
||||
const keys = candidates.map((c) => c.objectKey);
|
||||
const referenced = new Set(
|
||||
(
|
||||
await this.prisma.iiosMessagePart.findMany({
|
||||
where: { contentRef: { in: keys } },
|
||||
select: { contentRef: true },
|
||||
})
|
||||
)
|
||||
.map((p) => p.contentRef)
|
||||
.filter((ref): ref is string => ref !== null),
|
||||
);
|
||||
|
||||
const orphans = candidates.filter((c) => !referenced.has(c.objectKey));
|
||||
let deleted = 0;
|
||||
for (const o of orphans) {
|
||||
await this.storage.remove(o.objectKey).catch((err) => this.logger.warn(`storage remove failed for ${o.objectKey}: ${(err as Error).message}`));
|
||||
await this.prisma.iiosMediaObject.delete({ where: { id: o.id } }).catch(() => undefined);
|
||||
deleted += 1;
|
||||
}
|
||||
if (deleted > 0) this.logger.log(`media orphan sweep reaped ${deleted} object(s)`);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** Authorize a download and return a short-lived signed GET url (tenant-fenced). */
|
||||
async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> {
|
||||
const scope = await this.actors.resolveScope(principal);
|
||||
|
||||
Reference in New Issue
Block a user