diff --git a/frontend/sync-client/src/utils/fix-sized-cache.test.ts b/frontend/sync-client/src/utils/fix-sized-cache.test.ts index ff36e438..46bc4144 100644 --- a/frontend/sync-client/src/utils/fix-sized-cache.test.ts +++ b/frontend/sync-client/src/utils/fix-sized-cache.test.ts @@ -59,4 +59,181 @@ describe("fixedSizeDocumentCache", () => { cache.put(1, doc1); assert.equal(cache.get(1), undefined); }); + + it("multipleEvictionsInSinglePut", async () => { + const cache = new FixedSizeDocumentCache(10); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + const doc3 = new Uint8Array([5, 6]); + const doc4 = new Uint8Array([7, 8, 9, 10, 11, 12, 13, 14]); // 8 bytes + + cache.put(1, doc1); + cache.put(2, doc2); + cache.put(3, doc3); + // Cache now has 6 bytes total + + cache.put(4, doc4); // Should evict doc1 and doc2 to make room (total: 2+8=10) + assert.equal(cache.get(1), undefined); // Evicted + assert.equal(cache.get(2), undefined); // Evicted + assert.equal(cache.get(3), doc3); // Still present + assert.equal(cache.get(4), doc4); + }); + + it("clearCache", async () => { + const cache = new FixedSizeDocumentCache(10); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + + cache.put(1, doc1); + cache.put(2, doc2); + assert.equal(cache.get(1), doc1); + assert.equal(cache.get(2), doc2); + + cache.clear(); + assert.equal(cache.get(1), undefined); + assert.equal(cache.get(2), undefined); + + // Should be able to add entries after clear + cache.put(3, doc1); + assert.equal(cache.get(3), doc1); + }); + + it("getNonExistentKey", async () => { + const cache = new FixedSizeDocumentCache(10); + const doc1 = new Uint8Array([1, 2]); + cache.put(1, doc1); + assert.equal(cache.get(999), undefined); + }); + + it("updateEntryWithDifferentSizeTriggeringEviction", async () => { + const cache = new FixedSizeDocumentCache(6); + const doc1_v1 = new Uint8Array([1, 2]); + const doc1_v2 = new Uint8Array([1, 2, 3, 4]); // Larger version + const doc2 = new Uint8Array([5, 6]); + const doc3 = new Uint8Array([7, 8]); + + cache.put(1, doc1_v1); + cache.put(2, doc2); + cache.put(3, doc3); + + // Update doc1 with larger version, should evict doc2 + cache.put(1, doc1_v2); + + assert.equal(cache.get(1), doc1_v2); + assert.equal(cache.get(2), undefined); // Evicted + assert.equal(cache.get(3), doc3); + }); + + it("singleItemCache", async () => { + const cache = new FixedSizeDocumentCache(2); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + + cache.put(1, doc1); + assert.equal(cache.get(1), doc1); + + cache.put(2, doc2); + assert.equal(cache.get(1), undefined); // Evicted + assert.equal(cache.get(2), doc2); + }); + + it("multipleGetsOnSameEntry", async () => { + const cache = new FixedSizeDocumentCache(4); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + const doc3 = new Uint8Array([5, 6]); + + cache.put(1, doc1); + cache.put(2, doc2); + + // Multiple gets on doc1 + cache.get(1); + cache.get(1); + cache.get(1); + + // Order should be: 2 (LRU), 1 (MRU) + cache.put(3, doc3); + + assert.equal(cache.get(1), doc1); + assert.equal(cache.get(2), undefined); // Evicted + assert.equal(cache.get(3), doc3); + }); + + it("exactlySizedEntry", async () => { + const cache = new FixedSizeDocumentCache(4); + const doc1 = new Uint8Array([1, 2, 3, 4]); // Exactly cache size + + cache.put(1, doc1); + assert.equal(cache.get(1), doc1); + + const doc2 = new Uint8Array([5, 6]); + cache.put(2, doc2); + + // doc1 should be evicted to make room for doc2 + assert.equal(cache.get(1), undefined); + assert.equal(cache.get(2), doc2); + }); + + it("updateEntryMakesItMostRecent", async () => { + const cache = new FixedSizeDocumentCache(6); + const doc1_v1 = new Uint8Array([1, 2]); + const doc1_v2 = new Uint8Array([3, 4]); + const doc2 = new Uint8Array([5, 6]); + const doc3 = new Uint8Array([7, 8]); + const doc4 = new Uint8Array([9, 10]); + + cache.put(1, doc1_v1); + cache.put(2, doc2); + cache.put(3, doc3); + + // Update doc1 (should move it to most recent) + cache.put(1, doc1_v2); + + // Order should be: 2 (LRU), 3, 1 (MRU) + // Adding doc4 should evict doc2 + cache.put(4, doc4); + + assert.equal(cache.get(1), doc1_v2); + assert.equal(cache.get(2), undefined); // Evicted + assert.equal(cache.get(3), doc3); + assert.equal(cache.get(4), doc4); + }); + + it("alternatingAccessPattern", async () => { + const cache = new FixedSizeDocumentCache(4); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + const doc3 = new Uint8Array([5, 6]); + + cache.put(1, doc1); + cache.put(2, doc2); + + // Alternate access between doc1 and doc2 + cache.get(1); + cache.get(2); + cache.get(1); + cache.get(2); + + // Order should be: 1, 2 (MRU) + cache.put(3, doc3); + + assert.equal(cache.get(1), undefined); // Evicted + assert.equal(cache.get(2), doc2); + assert.equal(cache.get(3), doc3); + }); + + it("zeroByteDocs", async () => { + const cache = new FixedSizeDocumentCache(2); + const doc1 = new Uint8Array([]); + const doc2 = new Uint8Array([]); + const doc3 = new Uint8Array([1, 2]); + + cache.put(1, doc1); + cache.put(2, doc2); + cache.put(3, doc3); + + assert.equal(cache.get(1), doc1); + assert.equal(cache.get(2), doc2); + assert.equal(cache.get(3), doc3); + }); }); diff --git a/frontend/sync-client/src/utils/fix-sized-cache.ts b/frontend/sync-client/src/utils/fix-sized-cache.ts index 78baac14..7adee7b0 100644 --- a/frontend/sync-client/src/utils/fix-sized-cache.ts +++ b/frontend/sync-client/src/utils/fix-sized-cache.ts @@ -2,27 +2,39 @@ import type { VaultUpdateId } from "../persistence/database"; +// Doubly-linked list node for O(1) LRU operations +class LRUNode { + public constructor( + public key: VaultUpdateId, + public value: Uint8Array, + public prev: LRUNode | null = null, + public next: LRUNode | null = null + ) {} +} + // evicting the least recently used documents when the size limit is exceeded. export class FixedSizeDocumentCache { private readonly maxSizeInBytes: number; private currentSizeInBytes: number; - private readonly cache: Map; - private usageOrder: VaultUpdateId[]; + private readonly cache: Map; + private head: LRUNode | null; // Least recently used + private tail: LRUNode | null; // Most recently used public constructor(maxSizeInBytes: number) { this.maxSizeInBytes = maxSizeInBytes; this.currentSizeInBytes = 0; this.cache = new Map(); - this.usageOrder = []; + this.head = null; + this.tail = null; } public get(updateId: VaultUpdateId): Uint8Array | undefined { - const entry = this.cache.get(updateId); - if (entry) { - this.usageOrder = this.usageOrder.filter((id) => id !== updateId); - this.usageOrder.push(updateId); - return entry; + const node = this.cache.get(updateId); + if (node) { + this.moveToTail(node); + return node.value; } + return undefined; } @@ -33,31 +45,69 @@ export class FixedSizeDocumentCache { } // If the document is already in the cache, update it - const existingEntry = this.cache.get(updateId); - if (existingEntry != null) { - this.currentSizeInBytes -= existingEntry.byteLength; + const existingNode = this.cache.get(updateId); + if (existingNode != null) { + this.currentSizeInBytes -= existingNode.value.byteLength; + this.removeNode(existingNode); this.cache.delete(updateId); - this.usageOrder = this.usageOrder.filter((id) => id !== updateId); } - this.cache.set(updateId, content); - this.usageOrder.push(updateId); + + const newNode = new LRUNode(updateId, content); + this.cache.set(updateId, newNode); + this.addToTail(newNode); this.currentSizeInBytes += content.byteLength; // Evict least recently used documents if over size limit - while ( - this.currentSizeInBytes > this.maxSizeInBytes && - this.usageOrder.length > 0 - ) { - const lruUpdateId = this.usageOrder.shift()!; // eslint-disable-line @typescript-eslint/no-non-null-assertion - const lruEntry = this.cache.get(lruUpdateId)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion - this.cache.delete(lruUpdateId); - this.currentSizeInBytes -= lruEntry.byteLength; + while (this.currentSizeInBytes > this.maxSizeInBytes && this.head) { + const lruNode = this.head; + this.removeNode(lruNode); + this.cache.delete(lruNode.key); + this.currentSizeInBytes -= lruNode.value.byteLength; } } public clear(): void { this.cache.clear(); - this.usageOrder = []; + this.head = null; + this.tail = null; this.currentSizeInBytes = 0; } + + private removeNode(node: LRUNode): void { + if (node.prev) { + node.prev.next = node.next; + } else { + this.head = node.next; + } + + if (node.next) { + node.next.prev = node.prev; + } else { + this.tail = node.prev; + } + + node.prev = null; + node.next = null; + } + + private addToTail(node: LRUNode): void { + node.prev = this.tail; + node.next = null; + + if (this.tail) { + this.tail.next = node; + } + + this.tail = node; + + this.head ??= node; + } + + private moveToTail(node: LRUNode): void { + if (node === this.tail) { + return; + } + this.removeNode(node); + this.addToTail(node); + } }