Files
OmniRoute/tests/unit/semantic-cache-dual-layer.test.ts
BillyOutlast d8c69b87e3 feat(cache): implement configurable dual-layer semantic caching layer (#1)
* feat(cache): implement dual-layer semantic caching with in-memory and redis vector stores

Implements production-grade, configurable semantic caching for OmniRoute.

- Dual-layer architecture: Layer 1 exact hash match (0 embedding latency) + Layer 2 vector cosine similarity search.
- Backends: in-memory vector store with L2 normalization, LRU and TTL + Redis vector store adapter with fail-open fallback.
- Embedding generation: conversation history normalization, system prompt exclusion, timeout protection.
- Streaming support: serializable SSE stream synthesis ending in data: [DONE]\n\n.
- Request overrides and telemetry headers: X-OmniRoute-Cache (HIT (exact) | HIT (semantic) | MISS), X-OmniRoute-Cache-Similarity, X-OmniRoute-Savings-Tokens, Cache-Control: no-cache, x-omniroute-no-cache, x-omniroute-cache-threshold, x-omniroute-cache-type, x-omniroute-cache-no-store, x-omniroute-cache-key.
- Unit test coverage across dual-layer search, eviction, redis resilience, and streaming replay.

* feat(cache): add default embedding client and live integration test for Lemonade server and Redis

- Add embeddingBaseUrl and embeddingApiKey configuration options to SemanticCacheConfig.
- Implement createDefaultEmbeddingGenerator for automatic OpenAI-compatible embedding integration.
- Add live verification script scripts/ad-hoc/test-semantic-cache-lemonade.ts.
- Add network-aware integration test tests/integration/semantic-cache-lemonade.test.ts for Lemonade harrier-oss-v1-0.6b and Redis vector store.

* fix(cache): address CodeRabbit review recommendations on PR #1

- Multi-tenant partition isolation: support null sentinel in StoreFilter so anonymous requests cannot match authenticated entries.
- Provider propagation: pass routed provider to non-streaming and streaming cache writes.
- Embedding resilience: race generator with timeout promise in generateEmbeddingWithTimeout to guard against uncooperative generators.
- Memory store consistency: replace older entries with identical hash on insert, and safe-guard hashToId deletion in removeEntry.
- Redis store consistency: prune expired/missing entries from candidate sets during search/stats, and delete hash mapping conditionally.
- Token telemetry: use managerResult.tokensSaved when cached response lacks usage data.
- Clova batch timeout: add AbortSignal.timeout(FETCH_TIMEOUT_MS) to fetchClovaEmbeddingBatch.

---------

Co-authored-by: John Smith <you@example.com>
2026-09-03 12:50:57 -04:00

904 lines
29 KiB
TypeScript

import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import {
SemanticCacheManager,
generateDirectHash,
resetSemanticCacheManager,
} from "../../open-sse/services/cache/semanticCacheManager.ts";
import { MemoryVectorStore } from "../../open-sse/services/cache/memoryVectorStore.ts";
import {
RedisVectorStore,
type RedisLike,
} from "../../open-sse/services/cache/redisVectorStore.ts";
import {
cosineSimilarity,
dotProduct,
l2Normalize,
} from "../../open-sse/services/cache/vectorStore.ts";
import {
normalizeConversationForEmbedding,
generateEmbeddingWithTimeout,
} from "../../open-sse/services/cache/embeddingClient.ts";
import { resolveSemanticCacheConfig } from "../../open-sse/config/semanticCacheConfig.ts";
describe("Semantic Cache — Dual-Layer Architecture", () => {
beforeEach(() => {
resetSemanticCacheManager(null);
});
describe("Vector Math & Normalization", () => {
it("computes cosine similarity accurately", () => {
const a = [1, 0, 0];
const b = [1, 0, 0];
const c = [0, 1, 0];
const d = [0.7071, 0.7071, 0];
assert.equal(Math.round(cosineSimilarity(a, b) * 1000) / 1000, 1);
assert.equal(cosineSimilarity(a, c), 0);
assert.ok(cosineSimilarity(a, d) > 0.7 && cosineSimilarity(a, d) < 0.71);
assert.equal(dotProduct([1, 2], [3, 4]), 11);
});
it("handles zero vectors and empty arrays gracefully", () => {
assert.equal(cosineSimilarity([], []), 0);
assert.equal(cosineSimilarity([0, 0], [1, 2]), 0);
});
it("resolves config defaults and overrides properly", () => {
const conf = resolveSemanticCacheConfig({ similarityThreshold: 0.85 });
assert.equal(conf.similarityThreshold, 0.85);
assert.equal(conf.backend, "memory");
});
it("l2Normalize creates unit vector", () => {
const v = [3, 4];
const norm = l2Normalize(v);
assert.equal(Math.round(norm[0] * 10) / 10, 0.6);
assert.equal(Math.round(norm[1] * 10) / 10, 0.8);
const magnitude = Math.sqrt(norm[0] ** 2 + norm[1] ** 2);
assert.equal(Math.round(magnitude * 1000) / 1000, 1);
});
});
describe("Conversation Normalization", () => {
it("extracts clean text from string and array messages", () => {
const msgs = [
{ role: "system", content: "You are a helpful assistant" },
{ role: "user", content: "What is the capital of France?" },
{ role: "assistant", content: "Paris" },
{ role: "user", content: "What is its population?" },
];
const textWithSystem = normalizeConversationForEmbedding(msgs, {
excludeSystemPrompt: false,
historyDepth: 3,
});
assert.ok(textWithSystem.includes("user: What is its population?"));
assert.ok(textWithSystem.includes("assistant: Paris"));
const textNoSystem = normalizeConversationForEmbedding(msgs, {
excludeSystemPrompt: true,
historyDepth: 3,
});
assert.ok(!textNoSystem.includes("system:"));
});
it("handles multipart content objects", () => {
const msgs = [
{
role: "user",
content: [
{ type: "text", text: "Hello" },
{ type: "text", text: "World" },
],
},
];
const text = normalizeConversationForEmbedding(msgs);
assert.equal(text, "user: Hello World");
});
});
describe("Layer 1: Exact Match (Direct Hash)", () => {
it("bypasses embedding generation on exact match (0 embedding latency)", async () => {
let embeddingCalls = 0;
const mockEmbeddingGenerator = async () => {
embeddingCalls++;
return { embedding: [1, 0, 0], inputTokens: 5 };
};
const manager = new SemanticCacheManager(
{ enabled: true },
new MemoryVectorStore(),
mockEmbeddingGenerator
);
const requestBody = {
model: "gpt-4o",
messages: [{ role: "user", content: "How do I reverse a string in JS?" }],
temperature: 0,
};
const responsePayload = {
id: "chatcmpl-test-1",
choices: [
{ message: { role: "assistant", content: "Use str.split('').reverse().join('')" } },
],
usage: { prompt_tokens: 10, completion_tokens: 15 },
};
// 1. Store response
await manager.store({
body: requestBody,
response: responsePayload,
model: "gpt-4o",
provider: "openai",
tokensSaved: 25,
});
// Reset embedding counter after store
embeddingCalls = 0;
// 2. Lookup identical query -> Layer 1 exact match
const result = await manager.lookup({
body: requestBody,
model: "gpt-4o",
provider: "openai",
});
assert.equal(result.hit, true, "Should hit cache");
assert.equal(result.type, "exact", "Should be an exact match hit");
assert.equal(embeddingCalls, 0, "Exact match MUST NOT call embedding generator");
assert.equal(result.tokensSaved, 25);
});
it("isolates direct hashes by apiKeyId (#3740)", () => {
const conv = [{ role: "user", content: "test query" }];
const hashA = generateDirectHash("gpt-4", conv, 0, 1, { apiKeyId: "user-a" });
const hashB = generateDirectHash("gpt-4", conv, 0, 1, { apiKeyId: "user-b" });
const hashAnonymous = generateDirectHash("gpt-4", conv, 0, 1);
assert.notEqual(hashA, hashB);
assert.notEqual(hashA, hashAnonymous);
assert.ok(hashA.startsWith("user-a."));
});
});
describe("Layer 2: Semantic Similarity Match", () => {
it("hits cache when prompt is semantically similar above threshold", async () => {
const embeddings: Record<string, number[]> = {
"user: What is the capital of France?": [0.95, 0.31, 0],
"user: Tell me the capital city of France": [0.94, 0.34, 0],
"user: How to make a chocolate cake?": [0.1, 0.99, 0],
};
const mockEmbeddingGenerator = async (text: string) => {
const vec = embeddings[text] || [0.5, 0.5, 0];
return { embedding: vec, inputTokens: 8 };
};
const manager = new SemanticCacheManager(
{ enabled: true, similarityThreshold: 0.8 },
new MemoryVectorStore(),
mockEmbeddingGenerator
);
// 1. Store first query
await manager.store({
body: {
model: "gpt-4o",
messages: [{ role: "user", content: "What is the capital of France?" }],
temperature: 0,
},
response: {
id: "chatcmpl-paris",
choices: [{ message: { role: "assistant", content: "The capital of France is Paris." } }],
usage: { prompt_tokens: 8, completion_tokens: 10 },
},
model: "gpt-4o",
provider: "openai",
tokensSaved: 18,
});
// 2. Query with different wording (synonymous)
const similarResult = await manager.lookup({
body: {
model: "gpt-4o",
messages: [{ role: "user", content: "Tell me the capital city of France" }],
temperature: 0,
},
model: "gpt-4o",
provider: "openai",
});
assert.equal(similarResult.hit, true, "Similar query should hit cache");
assert.equal(similarResult.type, "semantic", "Should be a semantic hit");
assert.ok(
(similarResult.similarity ?? 0) >= 0.8,
`Similarity score ${similarResult.similarity} should be >= 0.8`
);
// 3. Query with completely different topic
const differentResult = await manager.lookup({
body: {
model: "gpt-4o",
messages: [{ role: "user", content: "How to make a chocolate cake?" }],
temperature: 0,
},
model: "gpt-4o",
provider: "openai",
});
assert.equal(differentResult.hit, false, "Unrelated query should miss cache");
});
it("respects per-request threshold override via x-omniroute-cache-threshold", async () => {
// 0.85 similarity between vectors
const mockEmbeddingGenerator = async (text: string) => {
if (text.includes("query-1")) return { embedding: [1, 0, 0], inputTokens: 5 };
return { embedding: [0.85, 0.5268, 0], inputTokens: 5 }; // dot product = 0.85
};
const manager = new SemanticCacheManager(
{ enabled: true, similarityThreshold: 0.8 },
new MemoryVectorStore(),
mockEmbeddingGenerator
);
await manager.store({
body: { model: "gpt-4o", messages: [{ role: "user", content: "query-1" }], temperature: 0 },
response: { id: "res-1", choices: [{ message: { role: "assistant", content: "ans-1" } }] },
model: "gpt-4o",
provider: "openai",
});
// Normal threshold 0.8: similarity 0.85 >= 0.8 -> HIT
const hitNormal = await manager.lookup({
body: { model: "gpt-4o", messages: [{ role: "user", content: "query-2" }], temperature: 0 },
model: "gpt-4o",
provider: "openai",
});
assert.equal(hitNormal.hit, true);
// Strict per-request threshold 0.95: similarity 0.85 < 0.95 -> MISS
const missStrict = await manager.lookup({
body: { model: "gpt-4o", messages: [{ role: "user", content: "query-2" }], temperature: 0 },
headers: { "x-omniroute-cache-threshold": "0.95" },
model: "gpt-4o",
provider: "openai",
});
assert.equal(missStrict.hit, false);
});
});
describe("Request Overrides & Bypasses", () => {
it("respects Cache-Control: no-cache and x-omniroute-no-cache: true", async () => {
const manager = new SemanticCacheManager({ enabled: true }, new MemoryVectorStore());
const body = {
model: "gpt-4",
messages: [{ role: "user", content: "test" }],
temperature: 0,
};
await manager.store({
body,
response: { id: "1", choices: [] },
model: "gpt-4",
provider: "openai",
});
const bypassedX = await manager.lookup({
body,
headers: { "x-omniroute-no-cache": "true" },
model: "gpt-4",
provider: "openai",
});
assert.equal(bypassedX.hit, false);
assert.equal(bypassedX.bypassed, true);
const bypassedCC = await manager.lookup({
body,
headers: { "cache-control": "no-cache" },
model: "gpt-4",
provider: "openai",
});
assert.equal(bypassedCC.hit, false);
assert.equal(bypassedCC.bypassed, true);
});
it("respects x-omniroute-cache-type: direct and x-omniroute-cache-type: semantic", async () => {
let semCalls = 0;
const mockEmbeddingGenerator = async () => {
semCalls++;
return { embedding: [1, 0], inputTokens: 2 };
};
const manager = new SemanticCacheManager(
{ enabled: true },
new MemoryVectorStore(),
mockEmbeddingGenerator
);
// Direct-only mode skips semantic search completely on miss
const result = await manager.lookup({
body: { model: "m", messages: [{ role: "user", content: "new" }], temperature: 0 },
headers: { "x-omniroute-cache-type": "direct" },
model: "m",
provider: "p",
});
assert.equal(result.hit, false);
assert.equal(semCalls, 0, "direct mode must not call embedding generator");
});
it("respects x-omniroute-cache-no-store: true", async () => {
const manager = new SemanticCacheManager({ enabled: true }, new MemoryVectorStore());
const body = {
model: "gpt-4",
messages: [{ role: "user", content: "no-store-test" }],
temperature: 0,
};
await manager.store({
body,
headers: { "x-omniroute-cache-no-store": "true" },
response: { id: "1", choices: [] },
model: "gpt-4",
provider: "openai",
});
const check = await manager.lookup({
body,
model: "gpt-4",
provider: "openai",
});
assert.equal(check.hit, false, "Entry should not have been stored");
});
});
describe("Streaming SSE Replay", () => {
it("synthesizes valid OpenAI SSE frames ending in data: [DONE]", () => {
const manager = new SemanticCacheManager({ enabled: true });
const entry = {
id: "test",
hash: "h",
promptText: "hi",
model: "gpt-4o",
provider: "openai",
response: {
id: "chatcmpl-stream-test",
created: 123456789,
model: "gpt-4o",
choices: [
{
index: 0,
message: { role: "assistant", content: "Streaming answer content" },
finish_reason: "stop",
},
],
},
tokensSaved: 15,
createdAt: Date.now(),
expiresAt: Date.now() + 60000,
};
const sse = manager.synthesizeSseFromEntry(entry);
assert.ok(sse.startsWith("data: "));
assert.ok(sse.includes("Streaming answer content"));
assert.ok(sse.trimEnd().endsWith("data: [DONE]"));
});
});
describe("MemoryVectorStore Operations & Eviction", () => {
it("evicts oldest entries when exceeding maxEntries", async () => {
const store = new MemoryVectorStore({ maxEntries: 2 });
const now = Date.now();
await store.set(
{
id: "1",
hash: "h1",
promptText: "p1",
model: "m",
provider: "p",
response: {},
tokensSaved: 1,
createdAt: now,
expiresAt: now + 100000,
},
100000
);
await store.set(
{
id: "2",
hash: "h2",
promptText: "p2",
model: "m",
provider: "p",
response: {},
tokensSaved: 1,
createdAt: now,
expiresAt: now + 100000,
},
100000
);
await store.set(
{
id: "3",
hash: "h3",
promptText: "p3",
model: "m",
provider: "p",
response: {},
tokensSaved: 1,
createdAt: now,
expiresAt: now + 100000,
},
100000
);
// Oldest entry 1 should have been evicted
assert.equal(await store.get("1"), null);
assert.notEqual(await store.get("2"), null);
assert.notEqual(await store.get("3"), null);
const stats = await store.getStats();
assert.equal(stats.entries, 2);
});
it("expires entries past TTL", async () => {
const store = new MemoryVectorStore();
const now = Date.now();
await store.set(
{
id: "expired",
hash: "hexp",
promptText: "p",
model: "m",
provider: "p",
response: {},
tokensSaved: 1,
createdAt: now - 5000,
expiresAt: now - 1000, // already expired
},
-1000
);
assert.equal(await store.get("expired"), null);
assert.equal(await store.getByHash("hexp"), null);
});
});
describe("RedisVectorStore with Mock Client & Fail-Open", () => {
it("handles Redis get, set, smembers and fails open on connection errors", async () => {
const redisStorage = new Map<string, string>();
const setStorage = new Map<string, Set<string>>();
const mockClient: RedisLike = {
get: async (k: string) => redisStorage.get(k) || null,
set: async (k: string, v: string) => {
redisStorage.set(k, v);
return "OK";
},
del: async (...keys: string[]) => {
let count = 0;
for (const k of keys) {
if (redisStorage.delete(k)) count++;
}
return count;
},
sadd: async (k: string, ...members: string[]) => {
if (!setStorage.has(k)) setStorage.set(k, new Set());
const s = setStorage.get(k)!;
let added = 0;
for (const m of members) {
if (!s.has(m)) {
s.add(m);
added++;
}
}
return added;
},
srem: async (k: string, ...members: string[]) => {
const s = setStorage.get(k);
if (!s) return 0;
let removed = 0;
for (const m of members) {
if (s.delete(m)) removed++;
}
return removed;
},
smembers: async (k: string) => {
const s = setStorage.get(k);
return s ? Array.from(s) : [];
},
mget: async (...keys: string[]) => {
return keys.map((k) => redisStorage.get(k) || null);
},
keys: async (_p: string) => Array.from(redisStorage.keys()),
};
const redisStore = new RedisVectorStore({ client: mockClient });
const entry = {
id: "redis-entry-1",
hash: "hash-redis",
embedding: [1, 0, 0],
promptText: "hello redis",
model: "gpt-4",
provider: "openai",
response: { id: "res" },
tokensSaved: 10,
createdAt: Date.now(),
expiresAt: Date.now() + 60000,
};
await redisStore.set(entry, 60000);
const fetched = await redisStore.getByHash("hash-redis");
assert.ok(fetched);
assert.equal(fetched.promptText, "hello redis");
const search = await redisStore.searchNearest([1, 0, 0], { model: "gpt-4" }, 0.8);
assert.equal(search.length, 1);
assert.equal(search[0].entry.id, "redis-entry-1");
// Test fail-open resilience when client errors
const failingClient: RedisLike = {
get: async () => {
throw new Error("ECONNREFUSED");
},
set: async () => {
throw new Error("ECONNREFUSED");
},
del: async () => {
throw new Error("ECONNREFUSED");
},
sadd: async () => {
throw new Error("ECONNREFUSED");
},
srem: async () => {
throw new Error("ECONNREFUSED");
},
smembers: async () => {
throw new Error("ECONNREFUSED");
},
mget: async () => {
throw new Error("ECONNREFUSED");
},
keys: async () => {
throw new Error("ECONNREFUSED");
},
};
const brokenStore = new RedisVectorStore({ client: failingClient });
// Should NOT throw, but return null/empty
assert.equal(await brokenStore.get("any"), null);
assert.equal(await brokenStore.getByHash("any"), null);
const emptyResults = await brokenStore.searchNearest([1, 0], {}, 0.8);
assert.deepEqual(emptyResults, []);
});
});
describe("Graceful Degradation", () => {
it("fails open when embedding generation times out or throws", async () => {
const failingGenerator = async () => {
throw new Error("Embedding upstream 503 Service Unavailable");
};
const manager = new SemanticCacheManager(
{ enabled: true },
new MemoryVectorStore(),
failingGenerator
);
const result = await manager.lookup({
body: { model: "gpt-4", messages: [{ role: "user", content: "test" }], temperature: 0 },
model: "gpt-4",
provider: "openai",
});
assert.equal(result.hit, false, "Embedding failure must fail open cleanly");
});
it("handles empty input in generateEmbeddingWithTimeout", async () => {
const res = await generateEmbeddingWithTimeout("", async () => null);
assert.equal(res, null);
});
it("times out uncooperative generators that ignore AbortSignal via Promise.race", async () => {
// Generator that never resolves and completely ignores AbortSignal
const hangingGenerator = () =>
new Promise<{ embedding: number[]; inputTokens: number }>(() => {});
const t0 = Date.now();
const res = await generateEmbeddingWithTimeout("hello", hangingGenerator, { timeoutMs: 50 });
const elapsed = Date.now() - t0;
assert.equal(res, null, "Should return null on timeout");
assert.ok(elapsed >= 45 && elapsed < 500, `Should resolve around 50ms, took ${elapsed}ms`);
});
});
describe("Multi-Tenant Partition Isolation", () => {
it("isolates anonymous requests from authenticated entries in semantic search", async () => {
const store = new MemoryVectorStore();
const manager = new SemanticCacheManager(
{ enabled: true, similarityThreshold: 0.8 },
store,
async () => ({ embedding: [1, 0], inputTokens: 5 })
);
// Store entry under apiKeyId: "tenant-a"
await manager.store({
body: {
model: "gpt-4",
messages: [{ role: "user", content: "sensitive data" }],
temperature: 0,
},
response: { choices: [{ message: { content: "secret answer" } }] },
model: "gpt-4",
provider: "openai",
apiKeyId: "tenant-a",
tokensSaved: 10,
});
// Anonymous query (no apiKeyId) should MISS
const anonResult = await manager.lookup({
body: {
model: "gpt-4",
messages: [{ role: "user", content: "sensitive data paraphrase" }],
temperature: 0,
},
model: "gpt-4",
provider: "openai",
});
assert.equal(anonResult.hit, false, "Anonymous query must NOT hit tenant-a cached entry");
// Another tenant query should MISS
const otherTenantResult = await manager.lookup({
body: {
model: "gpt-4",
messages: [{ role: "user", content: "sensitive data paraphrase" }],
temperature: 0,
},
model: "gpt-4",
provider: "openai",
apiKeyId: "tenant-b",
});
assert.equal(
otherTenantResult.hit,
false,
"Tenant-b query must NOT hit tenant-a cached entry"
);
// Same tenant query should HIT
const sameTenantResult = await manager.lookup({
body: {
model: "gpt-4",
messages: [{ role: "user", content: "sensitive data paraphrase" }],
temperature: 0,
},
model: "gpt-4",
provider: "openai",
apiKeyId: "tenant-a",
});
assert.equal(sameTenantResult.hit, true, "Tenant-a query must HIT tenant-a cached entry");
});
});
describe("MemoryVectorStore Hash Index Consistency", () => {
it("replaces older entry when inserting a new entry with identical hash", async () => {
const store = new MemoryVectorStore();
const now = Date.now();
await store.set(
{
id: "id-old",
hash: "shared-hash-1",
promptText: "prompt 1",
model: "m",
provider: "p",
response: { v: 1 },
tokensSaved: 10,
createdAt: now,
expiresAt: now + 60000,
},
60000
);
// Insert newer entry with same hash but new ID
await store.set(
{
id: "id-new",
hash: "shared-hash-1",
promptText: "prompt 1 updated",
model: "m",
provider: "p",
response: { v: 2 },
tokensSaved: 15,
createdAt: now + 100,
expiresAt: now + 60000,
},
60000
);
// Old entry should be removed from store
assert.equal(await store.get("id-old"), null);
// New entry should be accessible directly and by hash
const byHash = await store.getByHash("shared-hash-1");
assert.equal(byHash?.id, "id-new");
assert.equal((byHash?.response as { v: number }).v, 2);
// Deleting the old ID must not delete the hash mapping for the new ID
await store.delete("id-old");
const byHashAfterOldDelete = await store.getByHash("shared-hash-1");
assert.equal(byHashAfterOldDelete?.id, "id-new");
});
});
describe("RedisVectorStore Stale Set Pruning & Conditional Hash Deletion", () => {
it("prunes expired / missing entries from candidate sets and decrements getStats", async () => {
const redisStorage = new Map<string, string>();
const setStorage = new Map<string, Set<string>>();
const mockClient: RedisLike = {
get: async (k: string) => redisStorage.get(k) || null,
set: async (k: string, v: string) => {
redisStorage.set(k, v);
},
del: async (...keys: string[]) => {
keys.forEach((k) => redisStorage.delete(k));
return keys.length;
},
sadd: async (k: string, ...members: string[]) => {
if (!setStorage.has(k)) setStorage.set(k, new Set());
const set = setStorage.get(k)!;
members.forEach((m) => set.add(m));
return members.length;
},
srem: async (k: string, ...members: string[]) => {
const set = setStorage.get(k);
if (!set) return 0;
let count = 0;
members.forEach((m) => {
if (set.delete(m)) count++;
});
return count;
},
smembers: async (k: string) => Array.from(setStorage.get(k) || []),
mget: async (...keys: string[]) => keys.map((k) => redisStorage.get(k) || null),
};
const store = new RedisVectorStore({ client: mockClient, keyPrefix: "test:" });
const now = Date.now();
// Store entry 1 (active)
await store.set(
{
id: "active-1",
hash: "hash-active",
embedding: [1, 0],
promptText: "active prompt",
model: "gpt-4",
provider: "openai",
response: { text: "active" },
tokensSaved: 10,
createdAt: now,
expiresAt: now + 10000,
},
10000
);
// Store entry 2 (expired)
await store.set(
{
id: "expired-2",
hash: "hash-expired",
embedding: [1, 0],
promptText: "expired prompt",
model: "gpt-4",
provider: "openai",
response: { text: "expired" },
tokensSaved: 10,
createdAt: now - 5000,
expiresAt: now - 1000,
},
-1000
);
// Simulate entry 3 that was deleted from Redis keys directly (e.g. TTL expired naturally in Redis)
await mockClient.sadd!("test:all_ids", "ghost-3");
await mockClient.sadd!("test:model:gpt-4", "ghost-3");
// getStats should count only living entries and prune expired/ghost entries
const stats = await store.getStats();
assert.equal(stats.entries, 1, "Only active-1 should be counted as live");
// searchNearest should also prune stale entries and return only active-1
const nearest = await store.searchNearest([1, 0], { model: "gpt-4" }, 0.8);
assert.equal(nearest.length, 1);
assert.equal(nearest[0].entry.id, "active-1");
});
it("conditionally deletes hash mapping only if matching the entry being deleted", async () => {
const redisStorage = new Map<string, string>();
const setStorage = new Map<string, Set<string>>();
const mockClient: RedisLike = {
get: async (k: string) => redisStorage.get(k) || null,
set: async (k: string, v: string) => {
redisStorage.set(k, v);
},
del: async (...keys: string[]) => {
keys.forEach((k) => redisStorage.delete(k));
return keys.length;
},
sadd: async (k: string, ...members: string[]) => {
if (!setStorage.has(k)) setStorage.set(k, new Set());
members.forEach((m) => setStorage.get(k)!.add(m));
return members.length;
},
srem: async (k: string, ...members: string[]) => {
const set = setStorage.get(k);
if (!set) return 0;
let count = 0;
members.forEach((m) => {
if (set.delete(m)) count++;
});
return count;
},
smembers: async (k: string) => Array.from(setStorage.get(k) || []),
mget: async (...keys: string[]) => keys.map((k) => redisStorage.get(k) || null),
};
const store = new RedisVectorStore({ client: mockClient, keyPrefix: "test:" });
const now = Date.now();
// Store entry 1
await store.set(
{
id: "id-1",
hash: "shared-hash",
embedding: [1, 0],
promptText: "prompt",
model: "gpt-4",
provider: "openai",
response: {},
tokensSaved: 5,
createdAt: now,
expiresAt: now + 60000,
},
60000
);
// Overwrite with entry 2 under same hash
await store.set(
{
id: "id-2",
hash: "shared-hash",
embedding: [1, 0],
promptText: "prompt",
model: "gpt-4",
provider: "openai",
response: {},
tokensSaved: 5,
createdAt: now + 10,
expiresAt: now + 60000,
},
60000
);
// Now attempt to delete id-1 (older entry)
await store.delete("id-1");
// The hash mapping in Redis must STILL point to id-2!
const hashKey = "test:hash:shared-hash";
assert.equal(
redisStorage.get(hashKey),
"id-2",
"Hash mapping must not be deleted by older entry deletion"
);
});
});
});