Skip to content

Commit 2871f4a

Browse files
committed
add lru memory tests
1 parent 5f09a6a commit 2871f4a

File tree

3 files changed

+346
-1
lines changed

3 files changed

+346
-1
lines changed

internal-packages/cache/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"superjson": "^2.2.1"
1515
},
1616
"scripts": {
17-
"typecheck": "tsc --noEmit"
17+
"typecheck": "tsc --noEmit",
18+
"test": "vitest --run",
19+
"test:watch": "vitest"
1820
}
1921
}
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
import { LRUMemoryStore, createLRUMemoryStore } from "./lruMemory.js";
3+
import type { Entry } from "@unkey/cache/stores";
4+
5+
function createEntry<T>(value: T, freshUntil: number, staleUntil: number): Entry<T> {
6+
return { value, freshUntil, staleUntil };
7+
}
8+
9+
describe("LRUMemoryStore", () => {
10+
let store: LRUMemoryStore<string, string>;
11+
12+
beforeEach(() => {
13+
store = new LRUMemoryStore({ max: 5, name: "test-store" });
14+
});
15+
16+
describe("basic operations", () => {
17+
it("should set and get a value", async () => {
18+
const entry = createEntry("test-value", Date.now() + 60000, Date.now() + 120000);
19+
20+
const setResult = await store.set("ns", "key1", entry);
21+
expect(setResult.err).toBeUndefined();
22+
23+
const getResult = await store.get("ns", "key1");
24+
expect(getResult.err).toBeUndefined();
25+
expect(getResult.val).toEqual(entry);
26+
});
27+
28+
it("should return undefined for missing keys", async () => {
29+
const result = await store.get("ns", "nonexistent");
30+
expect(result.err).toBeUndefined();
31+
expect(result.val).toBeUndefined();
32+
});
33+
34+
it("should remove a single key", async () => {
35+
const entry = createEntry("value", Date.now() + 60000, Date.now() + 120000);
36+
await store.set("ns", "key1", entry);
37+
38+
const removeResult = await store.remove("ns", "key1");
39+
expect(removeResult.err).toBeUndefined();
40+
41+
const getResult = await store.get("ns", "key1");
42+
expect(getResult.val).toBeUndefined();
43+
});
44+
45+
it("should remove multiple keys", async () => {
46+
const entry = createEntry("value", Date.now() + 60000, Date.now() + 120000);
47+
await store.set("ns", "key1", entry);
48+
await store.set("ns", "key2", entry);
49+
await store.set("ns", "key3", entry);
50+
51+
const removeResult = await store.remove("ns", ["key1", "key2"]);
52+
expect(removeResult.err).toBeUndefined();
53+
54+
expect((await store.get("ns", "key1")).val).toBeUndefined();
55+
expect((await store.get("ns", "key2")).val).toBeUndefined();
56+
expect((await store.get("ns", "key3")).val).not.toBeUndefined();
57+
});
58+
});
59+
60+
describe("namespace isolation", () => {
61+
it("should isolate keys by namespace", async () => {
62+
const entry1 = createEntry("value1", Date.now() + 60000, Date.now() + 120000);
63+
const entry2 = createEntry("value2", Date.now() + 60000, Date.now() + 120000);
64+
65+
await store.set("ns1", "key", entry1);
66+
await store.set("ns2", "key", entry2);
67+
68+
const result1 = await store.get("ns1", "key");
69+
const result2 = await store.get("ns2", "key");
70+
71+
expect(result1.val?.value).toBe("value1");
72+
expect(result2.val?.value).toBe("value2");
73+
});
74+
});
75+
76+
describe("TTL expiration", () => {
77+
it("should return undefined for expired entries (past staleUntil)", async () => {
78+
const entry = createEntry("value", Date.now() - 2000, Date.now() - 1000); // Already expired
79+
80+
await store.set("ns", "expired-key", entry);
81+
82+
const result = await store.get("ns", "expired-key");
83+
expect(result.val).toBeUndefined();
84+
});
85+
86+
it("should return entry that is stale but not expired", async () => {
87+
const now = Date.now();
88+
// Fresh until 1 second ago, stale until 1 hour from now
89+
const entry = createEntry("value", now - 1000, now + 3600000);
90+
91+
await store.set("ns", "stale-key", entry);
92+
93+
const result = await store.get("ns", "stale-key");
94+
expect(result.val).not.toBeUndefined();
95+
expect(result.val?.value).toBe("value");
96+
});
97+
98+
it("should delete expired entry on get", async () => {
99+
const entry = createEntry("value", Date.now() - 2000, Date.now() - 1000);
100+
await store.set("ns", "key", entry);
101+
102+
// First get should return undefined and delete
103+
await store.get("ns", "key");
104+
105+
// Size should reflect deletion
106+
expect(store.size).toBe(0);
107+
});
108+
});
109+
110+
describe("LRU eviction", () => {
111+
it("should evict least recently used items when at capacity", async () => {
112+
const entry = (val: string) => createEntry(val, Date.now() + 60000, Date.now() + 120000);
113+
114+
// Fill the cache (max: 5)
115+
await store.set("ns", "key1", entry("value1"));
116+
await store.set("ns", "key2", entry("value2"));
117+
await store.set("ns", "key3", entry("value3"));
118+
await store.set("ns", "key4", entry("value4"));
119+
await store.set("ns", "key5", entry("value5"));
120+
121+
expect(store.size).toBe(5);
122+
123+
// Add one more - should evict key1 (least recently used)
124+
await store.set("ns", "key6", entry("value6"));
125+
126+
expect(store.size).toBe(5);
127+
expect((await store.get("ns", "key1")).val).toBeUndefined(); // Evicted
128+
expect((await store.get("ns", "key6")).val?.value).toBe("value6"); // Present
129+
});
130+
131+
it("should update LRU order on get", async () => {
132+
const entry = (val: string) => createEntry(val, Date.now() + 60000, Date.now() + 120000);
133+
134+
// Fill the cache
135+
await store.set("ns", "key1", entry("value1"));
136+
await store.set("ns", "key2", entry("value2"));
137+
await store.set("ns", "key3", entry("value3"));
138+
await store.set("ns", "key4", entry("value4"));
139+
await store.set("ns", "key5", entry("value5"));
140+
141+
// Access key1 to make it recently used
142+
await store.get("ns", "key1");
143+
144+
// Add new item - should evict key2 (now least recently used)
145+
await store.set("ns", "key6", entry("value6"));
146+
147+
expect((await store.get("ns", "key1")).val?.value).toBe("value1"); // Still present
148+
expect((await store.get("ns", "key2")).val).toBeUndefined(); // Evicted
149+
});
150+
151+
it("should update LRU order on set (update existing)", async () => {
152+
const entry = (val: string) => createEntry(val, Date.now() + 60000, Date.now() + 120000);
153+
154+
// Fill the cache
155+
await store.set("ns", "key1", entry("value1"));
156+
await store.set("ns", "key2", entry("value2"));
157+
await store.set("ns", "key3", entry("value3"));
158+
await store.set("ns", "key4", entry("value4"));
159+
await store.set("ns", "key5", entry("value5"));
160+
161+
// Update key1 to make it recently used
162+
await store.set("ns", "key1", entry("updated-value1"));
163+
164+
// Add new item - should evict key2 (now least recently used)
165+
await store.set("ns", "key6", entry("value6"));
166+
167+
expect((await store.get("ns", "key1")).val?.value).toBe("updated-value1");
168+
expect((await store.get("ns", "key2")).val).toBeUndefined(); // Evicted
169+
});
170+
});
171+
172+
describe("hard limit enforcement", () => {
173+
it("should never exceed max size regardless of write rate", async () => {
174+
const smallStore = new LRUMemoryStore<string, number>({ max: 10 });
175+
const entry = (val: number) => createEntry(val, Date.now() + 60000, Date.now() + 120000);
176+
177+
// Write 1000 items rapidly
178+
for (let i = 0; i < 1000; i++) {
179+
await smallStore.set("ns", `key${i}`, entry(i));
180+
// Verify size never exceeds max
181+
expect(smallStore.size).toBeLessThanOrEqual(10);
182+
}
183+
184+
expect(smallStore.size).toBe(10);
185+
});
186+
187+
it("should maintain most recent items when at capacity", async () => {
188+
const smallStore = new LRUMemoryStore<string, number>({ max: 3 });
189+
const entry = (val: number) => createEntry(val, Date.now() + 60000, Date.now() + 120000);
190+
191+
// Write items sequentially
192+
await smallStore.set("ns", "key1", entry(1));
193+
await smallStore.set("ns", "key2", entry(2));
194+
await smallStore.set("ns", "key3", entry(3));
195+
await smallStore.set("ns", "key4", entry(4));
196+
await smallStore.set("ns", "key5", entry(5));
197+
198+
// Only the 3 most recent should remain
199+
expect((await smallStore.get("ns", "key1")).val).toBeUndefined();
200+
expect((await smallStore.get("ns", "key2")).val).toBeUndefined();
201+
expect((await smallStore.get("ns", "key3")).val?.value).toBe(3);
202+
expect((await smallStore.get("ns", "key4")).val?.value).toBe(4);
203+
expect((await smallStore.get("ns", "key5")).val?.value).toBe(5);
204+
});
205+
});
206+
207+
describe("utility methods", () => {
208+
it("should report correct size", async () => {
209+
const entry = createEntry("value", Date.now() + 60000, Date.now() + 120000);
210+
211+
expect(store.size).toBe(0);
212+
213+
await store.set("ns", "key1", entry);
214+
expect(store.size).toBe(1);
215+
216+
await store.set("ns", "key2", entry);
217+
expect(store.size).toBe(2);
218+
219+
await store.remove("ns", "key1");
220+
expect(store.size).toBe(1);
221+
});
222+
223+
it("should clear all items", async () => {
224+
const entry = createEntry("value", Date.now() + 60000, Date.now() + 120000);
225+
226+
await store.set("ns1", "key1", entry);
227+
await store.set("ns2", "key2", entry);
228+
await store.set("ns3", "key3", entry);
229+
230+
expect(store.size).toBe(3);
231+
232+
store.clear();
233+
234+
expect(store.size).toBe(0);
235+
expect((await store.get("ns1", "key1")).val).toBeUndefined();
236+
});
237+
238+
it("should use custom name", () => {
239+
const customStore = new LRUMemoryStore({ max: 10, name: "custom-name" });
240+
expect(customStore.name).toBe("custom-name");
241+
});
242+
243+
it("should use default name when not provided", () => {
244+
const defaultStore = new LRUMemoryStore({ max: 10 });
245+
expect(defaultStore.name).toBe("lru-memory");
246+
});
247+
});
248+
249+
describe("createLRUMemoryStore helper", () => {
250+
it("should create a store with specified max size", async () => {
251+
const helperStore = createLRUMemoryStore(3);
252+
const entry = (val: number) => createEntry(val, Date.now() + 60000, Date.now() + 120000);
253+
254+
await helperStore.set("ns", "key1", entry(1));
255+
await helperStore.set("ns", "key2", entry(2));
256+
await helperStore.set("ns", "key3", entry(3));
257+
await helperStore.set("ns", "key4", entry(4));
258+
259+
expect(helperStore.size).toBe(3);
260+
expect((await helperStore.get("ns", "key1")).val).toBeUndefined();
261+
});
262+
263+
it("should accept custom name", () => {
264+
const namedStore = createLRUMemoryStore(10, "my-cache");
265+
expect(namedStore.name).toBe("my-cache");
266+
});
267+
});
268+
269+
describe("complex value types", () => {
270+
it("should handle object values", async () => {
271+
const objectStore = new LRUMemoryStore<string, { id: number; data: string[] }>({ max: 5 });
272+
const complexValue = { id: 123, data: ["a", "b", "c"] };
273+
const entry = createEntry(complexValue, Date.now() + 60000, Date.now() + 120000);
274+
275+
await objectStore.set("ns", "obj-key", entry);
276+
277+
const result = await objectStore.get("ns", "obj-key");
278+
expect(result.val?.value).toEqual(complexValue);
279+
});
280+
281+
it("should handle null and undefined values", async () => {
282+
const nullStore = new LRUMemoryStore<string, null | undefined>({ max: 5 });
283+
284+
const nullEntry = createEntry(null, Date.now() + 60000, Date.now() + 120000);
285+
const undefinedEntry = createEntry(undefined, Date.now() + 60000, Date.now() + 120000);
286+
287+
await nullStore.set("ns", "null-key", nullEntry);
288+
await nullStore.set("ns", "undefined-key", undefinedEntry);
289+
290+
expect((await nullStore.get("ns", "null-key")).val?.value).toBeNull();
291+
expect((await nullStore.get("ns", "undefined-key")).val?.value).toBeUndefined();
292+
});
293+
});
294+
295+
describe("concurrent operations", () => {
296+
it("should handle concurrent writes safely", async () => {
297+
const concurrentStore = new LRUMemoryStore<string, number>({ max: 100 });
298+
const entry = (val: number) => createEntry(val, Date.now() + 60000, Date.now() + 120000);
299+
300+
// Simulate concurrent writes
301+
const writes = Array.from({ length: 50 }, (_, i) =>
302+
concurrentStore.set("ns", `key${i}`, entry(i))
303+
);
304+
305+
await Promise.all(writes);
306+
307+
expect(concurrentStore.size).toBe(50);
308+
});
309+
310+
it("should handle concurrent reads and writes", async () => {
311+
const concurrentStore = new LRUMemoryStore<string, number>({ max: 100 });
312+
const entry = (val: number) => createEntry(val, Date.now() + 60000, Date.now() + 120000);
313+
314+
// Pre-populate
315+
for (let i = 0; i < 50; i++) {
316+
await concurrentStore.set("ns", `key${i}`, entry(i));
317+
}
318+
319+
// Mix of reads and writes
320+
const operations = [
321+
...Array.from({ length: 25 }, (_, i) => concurrentStore.get("ns", `key${i}`)),
322+
...Array.from({ length: 25 }, (_, i) =>
323+
concurrentStore.set("ns", `new-key${i}`, entry(i + 100))
324+
),
325+
];
326+
327+
await Promise.all(operations);
328+
329+
// Should not exceed max
330+
expect(concurrentStore.size).toBeLessThanOrEqual(100);
331+
});
332+
});
333+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
include: ["**/*.test.ts"],
6+
globals: true,
7+
isolate: true,
8+
testTimeout: 10_000,
9+
},
10+
});

0 commit comments

Comments
 (0)