import { logger } from '../logger'; import fs from 'fs/promises'; import path from 'path'; import cron from 'node-cron'; import { redis } from '../lib/redis'; const TEMP_DIR = '/tmp'; const GLOBAL_MAX_AGE = 30 * 60 * 1000; // 30 minutes const PER_TENANT_QUOTA_MB = 100; // 100MB per tenant limit const CLEANUP_LOCK_KEY = 'lock:cleanup:temp_files'; const LOCK_TTL = 1800; // 30 minutes in seconds /** * Cleanup Service * * Manages /tmp storage by enforcing age limits and per-tenant quotas. * Uses a Redis lock to ensure only one instance runs maintenance at a time. */ export async function cleanTempFiles() { // 0. Acquire distributed lock const lock = await redis.set(CLEANUP_LOCK_KEY, 'locked', 'EX', LOCK_TTL, 'NX'); if (!lock) { logger.info('[CLEANUP] Maintenance already in progress on another instance. Skipping.'); return; } const now = Date.now(); try { const folders = ['', 'audio', 'images', 'documents']; const tenantUsage: Record = {}; const tenantFiles: Record = {}; for (const folder of folders) { const dirPath = path.join(TEMP_DIR, folder); try { await fs.access(dirPath); } catch { continue; } const files = await fs.readdir(dirPath); for (const file of files) { const filePath = path.join(dirPath, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) continue; // 1. Check Age (Global cleanup) const age = now - stats.mtimeMs; if (age > GLOBAL_MAX_AGE) { await fs.unlink(filePath); logger.info(`[CLEANUP] Deleted old file (Age): ${filePath}`); continue; } // 2. Track usage by tenant (Assumes files are named orgId_filename) const orgId = file.split('_')[0]; if (orgId && orgId.length > 10) { // Basic orgId check tenantUsage[orgId] = (tenantUsage[orgId] || 0) + stats.size; if (!tenantFiles[orgId]) tenantFiles[orgId] = []; tenantFiles[orgId].push({ path: filePath, size: stats.size, mtime: stats.mtimeMs }); } } } // 3. Enforce Quotas for (const orgId in tenantUsage) { const usageMB = tenantUsage[orgId] / (1024 * 1024); if (usageMB > PER_TENANT_QUOTA_MB) { logger.warn(`[CLEANUP] Quota exceeded for Org ${orgId}: ${usageMB.toFixed(2)}MB. Cleaning oldest files...`); // Sort by mtime (oldest first) const files = tenantFiles[orgId].sort((a, b) => a.mtime - b.mtime); let freed = 0; const targetToFree = (usageMB - (PER_TENANT_QUOTA_MB * 0.8)) * 1024 * 1024; // Free up to 80% of quota for (const file of files) { await fs.unlink(file.path); freed += file.size; if (freed >= targetToFree) break; } logger.info(`[CLEANUP] Freed ${(freed / (1024 * 1024)).toFixed(2)}MB for Org ${orgId}`); } } } catch (err: any) { logger.error({ err }, '[CLEANUP] Error during maintenance'); } finally { // 4. Release lock await redis.del(CLEANUP_LOCK_KEY); } } /** * Starts a cron job that runs every 30 minutes. */ export function startCleanupCron() { cron.schedule('*/30 * * * *', async () => { logger.info('[CLEANUP] 🧹 Starting scheduled maintenance...'); await cleanTempFiles(); }); // Also run once at startup cleanTempFiles(); }