272 lines
6.1 KiB
TypeScript
272 lines
6.1 KiB
TypeScript
/**
|
|
* 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<string, Entry>();
|
|
|
|
// 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<typeof setInterval> | 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,
|
|
};
|
|
}
|