/** * LRU-based seen event tracker with TTL support. * Prevents unbounded memory growth under high load or abuse. */ export interface SeenTrackerOptions { /** Maximum number of entries to track (default: 100,000) */ maxEntries?: number; /** TTL in milliseconds (default: 1 hour) */ ttlMs?: number; /** Prune interval in milliseconds (default: 10 minutes) */ pruneIntervalMs?: number; } export interface SeenTracker { /** Check if an ID has been seen (also marks it as seen if not) */ has: (id: string) => boolean; /** Mark an ID as seen */ add: (id: string) => void; /** Check if ID exists without marking */ peek: (id: string) => boolean; /** Delete an ID */ delete: (id: string) => void; /** Clear all entries */ clear: () => void; /** Get current size */ size: () => number; /** Stop the pruning timer */ stop: () => void; /** Pre-seed with IDs (useful for restart recovery) */ seed: (ids: string[]) => void; } interface Entry { seenAt: number; // For LRU: track order via doubly-linked list prev: string | null; next: string | null; } /** * Create a new seen tracker with LRU eviction and TTL expiration. */ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker { const maxEntries = options?.maxEntries ?? 100_000; const ttlMs = options?.ttlMs ?? 60 * 60 * 1000; // 1 hour const pruneIntervalMs = options?.pruneIntervalMs ?? 10 * 60 * 1000; // 10 minutes // Main storage const entries = new Map(); // LRU tracking: head = most recent, tail = least recent let head: string | null = null; let tail: string | null = null; // Move an entry to the front (most recently used) function moveToFront(id: string): void { const entry = entries.get(id); if (!entry) return; // Already at front if (head === id) return; // Remove from current position if (entry.prev) { const prevEntry = entries.get(entry.prev); if (prevEntry) prevEntry.next = entry.next; } if (entry.next) { const nextEntry = entries.get(entry.next); if (nextEntry) nextEntry.prev = entry.prev; } // Update tail if this was the tail if (tail === id) { tail = entry.prev; } // Move to front entry.prev = null; entry.next = head; if (head) { const headEntry = entries.get(head); if (headEntry) headEntry.prev = id; } head = id; // If no tail, this is also the tail if (!tail) tail = id; } // Remove an entry from the linked list function removeFromList(id: string): void { const entry = entries.get(id); if (!entry) return; if (entry.prev) { const prevEntry = entries.get(entry.prev); if (prevEntry) prevEntry.next = entry.next; } else { head = entry.next; } if (entry.next) { const nextEntry = entries.get(entry.next); if (nextEntry) nextEntry.prev = entry.prev; } else { tail = entry.prev; } } // Evict the least recently used entry function evictLRU(): void { if (!tail) return; const idToEvict = tail; removeFromList(idToEvict); entries.delete(idToEvict); } // Prune expired entries function pruneExpired(): void { const now = Date.now(); const toDelete: string[] = []; for (const [id, entry] of entries) { if (now - entry.seenAt > ttlMs) { toDelete.push(id); } } for (const id of toDelete) { removeFromList(id); entries.delete(id); } } // Start pruning timer let pruneTimer: ReturnType | undefined; if (pruneIntervalMs > 0) { pruneTimer = setInterval(pruneExpired, pruneIntervalMs); // Don't keep process alive just for pruning if (pruneTimer.unref) pruneTimer.unref(); } function add(id: string): void { const now = Date.now(); // If already exists, update and move to front const existing = entries.get(id); if (existing) { existing.seenAt = now; moveToFront(id); return; } // Evict if at capacity while (entries.size >= maxEntries) { evictLRU(); } // Add new entry at front const newEntry: Entry = { seenAt: now, prev: null, next: head, }; if (head) { const headEntry = entries.get(head); if (headEntry) headEntry.prev = id; } entries.set(id, newEntry); head = id; if (!tail) tail = id; } function has(id: string): boolean { const entry = entries.get(id); if (!entry) { add(id); return false; } // Check if expired if (Date.now() - entry.seenAt > ttlMs) { removeFromList(id); entries.delete(id); add(id); return false; } // Mark as recently used entry.seenAt = Date.now(); moveToFront(id); return true; } function peek(id: string): boolean { const entry = entries.get(id); if (!entry) return false; // Check if expired if (Date.now() - entry.seenAt > ttlMs) { removeFromList(id); entries.delete(id); return false; } return true; } function deleteEntry(id: string): void { if (entries.has(id)) { removeFromList(id); entries.delete(id); } } function clear(): void { entries.clear(); head = null; tail = null; } function size(): number { return entries.size; } function stop(): void { if (pruneTimer) { clearInterval(pruneTimer); pruneTimer = undefined; } } function seed(ids: string[]): void { const now = Date.now(); // Seed in reverse order so first IDs end up at front for (let i = ids.length - 1; i >= 0; i--) { const id = ids[i]; if (!entries.has(id) && entries.size < maxEntries) { const newEntry: Entry = { seenAt: now, prev: null, next: head, }; if (head) { const headEntry = entries.get(head); if (headEntry) headEntry.prev = id; } entries.set(id, newEntry); head = id; if (!tail) tail = id; } } } return { has, add, peek, delete: deleteEntry, clear, size, stop, seed, }; }