import { createClient } from "redis"; export type RollingStatsConfig = { windowSize: number; ttlSeconds: number; }; export type RollingWindowStoreConfig = RollingStatsConfig & { flushIntervalMs: number; maxKeys: number; }; export type RollingSnapshot = { baselineCount: number; mean: number; stddev: number; zscore: number; }; type RollingWindowEntry = { values: number[]; updatedAt: number; dirty: boolean; }; const toNumbers = (values: string[]): number[] => { return values .map((value) => Number(value)) .filter((value) => Number.isFinite(value)); }; export const computeStats = (values: number[]): { mean: number; stddev: number; count: number } => { const count = values.length; if (count === 0) { return { mean: 0, stddev: 0, count: 0 }; } const mean = values.reduce((sum, value) => sum + value, 0) / count; const variance = values.reduce((sum, value) => { const delta = value - mean; return sum + delta * delta; }, 0) / count; return { mean, stddev: Math.sqrt(variance), count }; }; export const computeSnapshot = (baseline: number[], value: number): RollingSnapshot => { const stats = computeStats(baseline); const zscore = stats.stddev === 0 ? 0 : (value - stats.mean) / stats.stddev; return { baselineCount: stats.count, mean: stats.mean, stddev: stats.stddev, zscore }; }; export const createRedisClient = (url: string) => { return createClient({ url }); }; const getOldestKey = (store: Map): string | null => { let oldestKey: string | null = null; let oldestUpdatedAt = Number.POSITIVE_INFINITY; for (const [key, entry] of store) { if (entry.updatedAt < oldestUpdatedAt) { oldestUpdatedAt = entry.updatedAt; oldestKey = key; } } return oldestKey; }; export class RollingWindowStore { private readonly store = new Map(); private readonly ttlMs: number; private readonly windowSize: number; private readonly maxKeys: number; constructor(private readonly config: RollingWindowStoreConfig) { this.ttlMs = Math.max(0, config.ttlSeconds * 1000); this.windowSize = Math.max(1, config.windowSize); this.maxKeys = Math.max(1, config.maxKeys); } get size(): number { return this.store.size; } update(key: string, value: number, now = Date.now()): RollingSnapshot { this.prune(now); const existing = this.store.get(key); const baseline = existing?.values ?? []; const snapshot = computeSnapshot(baseline, value); const nextValues = [value, ...baseline].slice(0, this.windowSize); this.store.set(key, { values: nextValues, updatedAt: now, dirty: true }); this.enforceMaxKeys(); return snapshot; } prune(now = Date.now()): number { if (this.ttlMs <= 0) { return 0; } let removed = 0; for (const [key, entry] of this.store) { if (now - entry.updatedAt > this.ttlMs) { this.store.delete(key); removed += 1; } } return removed; } async hydrateFromRedis( client: ReturnType, keys: string[], now = Date.now() ): Promise { for (const key of keys) { const values = toNumbers(await client.lRange(key, 0, this.windowSize - 1)); if (values.length === 0) { continue; } this.store.set(key, { values, updatedAt: now, dirty: false }); } this.enforceMaxKeys(); } async flushToRedis(client: ReturnType): Promise { let flushed = 0; for (const [key, entry] of this.store) { if (!entry.dirty) { continue; } const multi = client.multi(); multi.lTrim(key, 1, 0); for (let idx = entry.values.length - 1; idx >= 0; idx -= 1) { const value = entry.values[idx]; if (typeof value === "number" && Number.isFinite(value)) { multi.lPush(key, value.toString()); } } if (this.config.ttlSeconds > 0) { multi.expire(key, this.config.ttlSeconds); } await multi.exec(); entry.dirty = false; flushed += 1; } return flushed; } private enforceMaxKeys(): void { while (this.store.size > this.maxKeys) { const oldestKey = getOldestKey(this.store); if (!oldestKey) { break; } this.store.delete(oldestKey); } } }