import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common'; import type { QueueHandle } from '@mosaic/queue'; import type { LogService } from '@mosaic/log'; import { LOG_SERVICE } from '../log/log.tokens.js'; import { REDIS } from './gc.tokens.js'; export interface GCResult { sessionId: string; cleaned: { valkeyKeys?: number; logsDemoted?: number; tempFilesRemoved?: number; }; } export interface GCSweepResult { orphanedSessions: number; totalCleaned: GCResult[]; duration: number; } export interface FullGCResult { valkeyKeys: number; logsDemoted: number; jobsPurged: number; tempFilesRemoved: number; duration: number; } @Injectable() export class SessionGCService implements OnModuleInit { private readonly logger = new Logger(SessionGCService.name); constructor( @Inject(REDIS) private readonly redis: QueueHandle['redis'], @Inject(LOG_SERVICE) private readonly logService: LogService, ) {} onModuleInit(): void { // Fire-and-forget: run full GC asynchronously so it does not block the // NestJS bootstrap chain. Cold-start GC typically takes 100–500 ms // depending on Valkey key count; deferring it removes that latency from // the TTFB of the first HTTP request. this.fullCollect() .then((result) => { this.logger.log( `Full GC complete: ${result.valkeyKeys} Valkey keys, ` + `${result.logsDemoted} logs demoted, ` + `${result.jobsPurged} jobs purged, ` + `${result.tempFilesRemoved} temp dirs removed ` + `(${result.duration}ms)`, ); }) .catch((err: unknown) => { this.logger.error('Cold-start GC failed', err instanceof Error ? err.stack : String(err)); }); } /** * Scan Valkey for all keys matching a pattern using SCAN (non-blocking). * KEYS is avoided because it blocks the Valkey event loop for the full scan * duration, which can cause latency spikes under production key volumes. */ private async scanKeys(pattern: string): Promise { const collected: string[] = []; let cursor = '0'; do { const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); cursor = nextCursor; collected.push(...keys); } while (cursor !== '0'); return collected; } /** * Immediate cleanup for a single session (call from destroySession). */ async collect(sessionId: string): Promise { const result: GCResult = { sessionId, cleaned: {} }; // 1. Valkey: delete all session-scoped keys const pattern = `mosaic:session:${sessionId}:*`; const valkeyKeys = await this.scanKeys(pattern); if (valkeyKeys.length > 0) { await this.redis.del(...valkeyKeys); result.cleaned.valkeyKeys = valkeyKeys.length; } // 2. PG: demote hot-tier agent_logs for this session to warm const cutoff = new Date(); // demote all hot logs for this session const logsDemoted = await this.logService.logs.promoteToWarm(cutoff); if (logsDemoted > 0) { result.cleaned.logsDemoted = logsDemoted; } return result; } /** * Sweep GC — find orphaned artifacts from dead sessions. * System-wide operation: only call from admin-authorized paths or internal * scheduled jobs. Individual session cleanup is handled by collect(). */ async sweepOrphans(): Promise { const start = Date.now(); const cleaned: GCResult[] = []; // 1. Find all session-scoped Valkey keys (non-blocking SCAN) const allSessionKeys = await this.scanKeys('mosaic:session:*'); // Extract unique session IDs from keys const sessionIds = new Set(); for (const key of allSessionKeys) { const match = key.match(/^mosaic:session:([^:]+):/); if (match) sessionIds.add(match[1]!); } // 2. For each session ID, collect stale keys for (const sessionId of sessionIds) { const gcResult = await this.collect(sessionId); if (Object.keys(gcResult.cleaned).length > 0) { cleaned.push(gcResult); } } return { orphanedSessions: cleaned.length, totalCleaned: cleaned, duration: Date.now() - start, }; } /** * Full GC — aggressive collection for cold start. * Assumes no sessions survived the restart. */ async fullCollect(): Promise { const start = Date.now(); // 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN) const sessionKeys = await this.scanKeys('mosaic:session:*'); if (sessionKeys.length > 0) { await this.redis.del(...sessionKeys); } // 2. NOTE: channel keys are NOT collected on cold start // (discord/telegram plugins may reconnect and resume) // 3. PG: demote stale hot-tier logs older than 24h to warm const hotCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); const logsDemoted = await this.logService.logs.promoteToWarm(hotCutoff); // 4. No summarization job purge API available yet const jobsPurged = 0; return { valkeyKeys: sessionKeys.length, logsDemoted, jobsPurged, tempFilesRemoved: 0, duration: Date.now() - start, }; } }