// services/dbCleanup.ts // Database cleanup module for state keys - SQL-only version import { handleDatabaseError } from "../modules/KvCacheStore.ts"; import { sqlDeleteStateKey, sqlGetAllStateKeys, } from "../db/sqlUserRepository.ts"; const CLEANUP_MINUTE = 10 * 1000; // 10 seconds between cleanup runs const AGE_OUT_TIME = 60 * 60 * 1000; // 1 hour - delete keys older than this const DUPLICATE_KEY_AGE = 5 * 60 * 1000; // 5 minutes - keep only newest key per state_id within this window /** * Singleton class to ensure only one cleanup instance runs at a time * across all user requests on the server. */ class StateKeyCleanupManager { private static instance: StateKeyCleanupManager | null = null; private lastCleanupTimestamp = 0; private isRunning = false; private cleanupPromise: Promise | null = null; // Private constructor to prevent direct instantiation private constructor() { } /** * Get the singleton instance of the cleanup manager */ public static getInstance(): StateKeyCleanupManager { if (!StateKeyCleanupManager.instance) { StateKeyCleanupManager.instance = new StateKeyCleanupManager(); } return StateKeyCleanupManager.instance; } public async runCleanup(): Promise { const now = Date.now(); // Check if cleanup was run recently if ( this.lastCleanupTimestamp && now - this.lastCleanupTimestamp < CLEANUP_MINUTE ) { return; } // Mutex lock: If cleanup is already running, return the existing promise if (this.isRunning && this.cleanupPromise) { return this.cleanupPromise; } // Set mutex lock this.isRunning = true; try { // Create cleanup promise this.cleanupPromise = this.performCleanup(now); await this.cleanupPromise; } finally { // Release mutex lock this.isRunning = false; this.cleanupPromise = null; } } private async performCleanup(now: number): Promise { this.lastCleanupTimestamp = now; try { // Get all state keys from SQL database const allStateKeys = await sqlGetAllStateKeys(); if (allStateKeys.length === 0) { return; } // Group by state_id to identify multiple keys per session const stateIdMap = new Map(); for (const key of allStateKeys) { const stateId = key.state_id; if (!stateIdMap.has(stateId)) { stateIdMap.set(stateId, []); } stateIdMap.get(stateId)!.push(key); } let deletedCount = 0; // Process each state_id group for (const [stateId, keys] of stateIdMap.entries()) { // Sort keys by created_on (newest first) keys.sort((a, b) => b.created_on - a.created_on); // Keep the newest key, delete older ones for this state_id // This handles state key rotation - old rotated keys are cleaned up for (let i = 1; i < keys.length; i++) { const oldKey = keys[i]; const age = now - oldKey.created_on; // Delete old rotated keys (older than 5 minutes) if (age > DUPLICATE_KEY_AGE) { try { await sqlDeleteStateKey(oldKey.seal_key); deletedCount++; console.log( `Cleaned up old state key for ${stateId}, age: ${ Math.floor(age / 1000) }s`, ); } catch (error) { console.error("Error deleting old state key:", error); } } } // Check the newest (current) key const currentKey = keys[0]; const currentAge = now - currentKey.created_on; // Delete if older than 1 hour (expired session) if (currentAge > AGE_OUT_TIME) { try { await sqlDeleteStateKey(currentKey.seal_key); deletedCount++; console.log( `Cleaned up expired state key for ${stateId}, age: ${ Math.floor(currentAge / 1000) }s`, ); } catch (error) { console.error("Error deleting expired state key:", error); } } } if (deletedCount > 0) { console.log( `State key cleanup completed: ${deletedCount} keys deleted`, ); } } catch (error) { console.error("Error during state key cleanup:", error); // Reset the flag on error so cleanup can be retried this.lastCleanupTimestamp = 0; handleDatabaseError(error); return; } } } /** * Exported function that uses the singleton instance to run cleanup. * This ensures only one cleanup runs at a time across all user requests. */ export function runStateKeyCleanup(): Promise { const manager = StateKeyCleanupManager.getInstance(); return manager.runCleanup(); }