forked from bai/curriculum-project-hub
feat: add sender profile cache with LRU eviction
- SenderNameCache: TTL 10min, LRU max 2048 entries, getOrFetch pattern - resolveSenderName: API call with cache integration - Trigger uses cache for approval card resolutions and audit metadata - Add unit tests (9) for cache lifecycle, LRU, expiry, and null handling
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
export interface SenderCacheOptions {
|
||||
readonly ttlMs?: number;
|
||||
readonly maxSize?: number;
|
||||
}
|
||||
|
||||
interface SenderCacheEntry {
|
||||
readonly name: string;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_MS = 600_000;
|
||||
const DEFAULT_MAX_SIZE = 2_048;
|
||||
|
||||
export class SenderNameCache {
|
||||
private readonly ttlMs: number;
|
||||
private readonly maxSize: number;
|
||||
private readonly entries = new Map<string, SenderCacheEntry>();
|
||||
|
||||
constructor(options: SenderCacheOptions = {}) {
|
||||
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||
this.maxSize = Math.max(0, options.maxSize ?? DEFAULT_MAX_SIZE);
|
||||
}
|
||||
|
||||
get(openId: string): string | null {
|
||||
const entry = this.entries.get(openId);
|
||||
if (entry === undefined) return null;
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
this.entries.delete(openId);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.entries.delete(openId);
|
||||
this.entries.set(openId, entry);
|
||||
return entry.name;
|
||||
}
|
||||
|
||||
set(openId: string, name: string): void {
|
||||
if (this.maxSize === 0) return;
|
||||
|
||||
this.entries.delete(openId);
|
||||
this.entries.set(openId, { name, expiresAt: Date.now() + this.ttlMs });
|
||||
|
||||
while (this.entries.size > this.maxSize) {
|
||||
const oldest = this.entries.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.entries.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
async getOrFetch(
|
||||
openId: string,
|
||||
fetchFn: (openId: string) => Promise<string | null>,
|
||||
): Promise<string | null> {
|
||||
const cached = this.get(openId);
|
||||
if (cached !== null) return cached;
|
||||
|
||||
const fetched = await fetchFn(openId);
|
||||
if (fetched !== null) {
|
||||
this.set(openId, fetched);
|
||||
}
|
||||
return fetched;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
this.pruneExpired();
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
private pruneExpired(): void {
|
||||
const now = Date.now();
|
||||
for (const [openId, entry] of this.entries) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.entries.delete(openId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user