Make LRU doubly-linked list

This commit is contained in:
Andras Schmelczer 2025-11-16 21:40:03 +00:00
parent c37cb3df83
commit 4478ae24d8
2 changed files with 250 additions and 23 deletions

View file

@ -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);
});
});

View file

@ -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<VaultUpdateId, Uint8Array>;
private usageOrder: VaultUpdateId[];
private readonly cache: Map<VaultUpdateId, LRUNode>;
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);
}
}