fix: dedupe inbound messages across providers
This commit is contained in:
34
src/infra/dedupe.test.ts
Normal file
34
src/infra/dedupe.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createDedupeCache } from "./dedupe.js";
|
||||
|
||||
describe("createDedupeCache", () => {
|
||||
it("marks duplicates within TTL", () => {
|
||||
const cache = createDedupeCache({ ttlMs: 1000, maxSize: 10 });
|
||||
expect(cache.check("a", 100)).toBe(false);
|
||||
expect(cache.check("a", 500)).toBe(true);
|
||||
});
|
||||
|
||||
it("expires entries after TTL", () => {
|
||||
const cache = createDedupeCache({ ttlMs: 1000, maxSize: 10 });
|
||||
expect(cache.check("a", 100)).toBe(false);
|
||||
expect(cache.check("a", 1501)).toBe(false);
|
||||
});
|
||||
|
||||
it("evicts oldest entries when over max size", () => {
|
||||
const cache = createDedupeCache({ ttlMs: 10_000, maxSize: 2 });
|
||||
expect(cache.check("a", 100)).toBe(false);
|
||||
expect(cache.check("b", 200)).toBe(false);
|
||||
expect(cache.check("c", 300)).toBe(false);
|
||||
expect(cache.check("a", 400)).toBe(false);
|
||||
});
|
||||
|
||||
it("prunes expired entries even when refreshed keys are older in insertion order", () => {
|
||||
const cache = createDedupeCache({ ttlMs: 100, maxSize: 10 });
|
||||
expect(cache.check("a", 0)).toBe(false);
|
||||
expect(cache.check("b", 50)).toBe(false);
|
||||
expect(cache.check("a", 120)).toBe(false);
|
||||
expect(cache.check("c", 200)).toBe(false);
|
||||
expect(cache.size()).toBe(2);
|
||||
});
|
||||
});
|
||||
59
src/infra/dedupe.ts
Normal file
59
src/infra/dedupe.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type DedupeCache = {
|
||||
check: (key: string | undefined | null, now?: number) => boolean;
|
||||
clear: () => void;
|
||||
size: () => number;
|
||||
};
|
||||
|
||||
type DedupeCacheOptions = {
|
||||
ttlMs: number;
|
||||
maxSize: number;
|
||||
};
|
||||
|
||||
export function createDedupeCache(options: DedupeCacheOptions): DedupeCache {
|
||||
const ttlMs = Math.max(0, options.ttlMs);
|
||||
const maxSize = Math.max(0, Math.floor(options.maxSize));
|
||||
const cache = new Map<string, number>();
|
||||
|
||||
const touch = (key: string, now: number) => {
|
||||
cache.delete(key);
|
||||
cache.set(key, now);
|
||||
};
|
||||
|
||||
const prune = (now: number) => {
|
||||
const cutoff = ttlMs > 0 ? now - ttlMs : undefined;
|
||||
if (cutoff !== undefined) {
|
||||
for (const [entryKey, entryTs] of cache) {
|
||||
if (entryTs < cutoff) {
|
||||
cache.delete(entryKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxSize <= 0) {
|
||||
cache.clear();
|
||||
return;
|
||||
}
|
||||
while (cache.size > maxSize) {
|
||||
const oldestKey = cache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
check: (key, now = Date.now()) => {
|
||||
if (!key) return false;
|
||||
const existing = cache.get(key);
|
||||
if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) {
|
||||
touch(key, now);
|
||||
return true;
|
||||
}
|
||||
touch(key, now);
|
||||
prune(now);
|
||||
return false;
|
||||
},
|
||||
clear: () => {
|
||||
cache.clear();
|
||||
},
|
||||
size: () => cache.size,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user