diff --git a/packages/router-core/src/lru-cache.ts b/packages/router-core/src/lru-cache.ts index ee3ec7d7a0..6eadee26f0 100644 --- a/packages/router-core/src/lru-cache.ts +++ b/packages/router-core/src/lru-cache.ts @@ -13,57 +13,56 @@ export function createLRUCache( let newest: Node | undefined const touch = (entry: Node) => { - if (!entry.next) return - if (!entry.prev) { - entry.next.prev = undefined - oldest = entry.next - entry.next = undefined - if (newest) { - entry.prev = newest - newest.next = entry - } + const next = entry.next + if (!next) { + return + } + const prev = entry.prev + if (prev) { + prev.next = next } else { - entry.prev.next = entry.next - entry.next.prev = entry.prev - entry.next = undefined - if (newest) { - newest.next = entry - entry.prev = newest - } + oldest = next } + next.prev = prev + entry.prev = newest + entry.next = undefined + newest!.next = entry newest = entry } return { get(key) { const entry = cache.get(key) - if (!entry) return undefined + if (!entry) { + return undefined + } touch(entry) return entry.value }, set(key, value) { + const entry = cache.get(key) + if (entry) { + entry.value = value + touch(entry) + return + } if (cache.size >= max && oldest) { - const toDelete = oldest - cache.delete(toDelete.key) - if (toDelete.next) { - oldest = toDelete.next - toDelete.next.prev = undefined - } - if (toDelete === newest) { + cache.delete(oldest.key) + oldest = oldest.next + if (oldest) { + oldest.prev = undefined + } else { newest = undefined } } - const existing = cache.get(key) - if (existing) { - existing.value = value - touch(existing) + const newEntry: Node = { key, value, prev: newest } + if (newest) { + newest.next = newEntry } else { - const entry: Node = { key, value, prev: newest } - if (newest) newest.next = entry - newest = entry - if (!oldest) oldest = entry - cache.set(key, entry) + oldest = newEntry } + newest = newEntry + cache.set(key, newEntry) }, clear() { cache.clear() diff --git a/packages/router-core/tests/lru-cache.bench.ts b/packages/router-core/tests/lru-cache.bench.ts new file mode 100644 index 0000000000..e875cce3a9 --- /dev/null +++ b/packages/router-core/tests/lru-cache.bench.ts @@ -0,0 +1,87 @@ +import { bench, describe } from 'vitest' +import { createLRUCache } from '../src/lru-cache' + +const keys1000 = Array.from({ length: 1000 }, (_, i) => `key-${i}`) +const missing1000 = Array.from({ length: 1000 }, (_, i) => `missing-${i}`) +const new1000 = Array.from({ length: 1000 }, (_, i) => `new-${i}`) + +function fillCache( + cache: { set: (key: string, value: number) => void }, + count: number, +) { + for (let i = 0; i < count; i++) { + cache.set(keys1000[i]!, i) + } +} + +describe('LRU cache', () => { + bench('newest hit', () => { + const cache = createLRUCache(1000) + fillCache(cache, 1000) + for (let i = 0; i < 1000; i++) { + cache.get(keys1000[999]!) + } + }) + + bench('rotating hit', () => { + const cache = createLRUCache(1000) + fillCache(cache, 1000) + for (let i = 0; i < 1000; i++) { + cache.get(keys1000[i]!) + } + }) + + bench('update newest while full', () => { + const cache = createLRUCache(1000) + fillCache(cache, 1000) + for (let i = 0; i < 1000; i++) { + cache.set(keys1000[999]!, i) + } + }) + + bench('update oldest while full', () => { + const cache = createLRUCache(1000) + fillCache(cache, 1000) + for (let i = 0; i < 1000; i++) { + cache.set(keys1000[0]!, i) + } + }) + + bench('update rotating entries while full', () => { + const cache = createLRUCache(1000) + fillCache(cache, 1000) + for (let i = 0; i < 1000; i++) { + cache.set(keys1000[i]!, i + 1) + } + }) + + bench('miss-heavy get', () => { + const cache = createLRUCache(64) + fillCache(cache, 64) + for (let i = 0; i < 1000; i++) { + cache.get(missing1000[i]!) + } + }) + + bench('insert churn', () => { + const cache = createLRUCache(64) + fillCache(cache, 64) + for (let i = 0; i < 1000; i++) { + cache.set(new1000[i]!, i) + } + }) + + bench('mixed workload', () => { + const cache = createLRUCache(64) + fillCache(cache, 64) + for (let i = 0; i < 1000; i++) { + cache.get(keys1000[i % 8]!) + if (i % 10 === 0) { + cache.get(missing1000[i]!) + } + if (i % 20 === 0) { + cache.set(new1000[i]!, i) + } + } + }) +}) diff --git a/packages/router-core/tests/lru.test.ts b/packages/router-core/tests/lru.test.ts index f601816596..d7ee74a52d 100644 --- a/packages/router-core/tests/lru.test.ts +++ b/packages/router-core/tests/lru.test.ts @@ -21,4 +21,121 @@ describe('LRU Cache', () => { expect(cache.get('b')).toBeUndefined() expect(cache.get('a')).toBe(1) }) + it('does not change order on a missing get', () => { + const cache = createLRUCache(2) + cache.set('a', 1) + cache.set('b', 2) + expect(cache.get('x')).toBeUndefined() + cache.set('c', 3) + expect(cache.get('a')).toBeUndefined() + expect(cache.get('b')).toBe(2) + }) + it('moves updated entries to most recently used before capacity is reached', () => { + const cache = createLRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('a', 3) + cache.set('c', 4) + cache.set('d', 5) + expect(cache.get('b')).toBeUndefined() + expect(cache.get('a')).toBe(3) + }) + it('updates existing entries without evicting when full', () => { + const cache = createLRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + cache.set('b', 4) + expect(cache.get('a')).toBe(1) + expect(cache.get('b')).toBe(4) + }) + it('moves updated oldest entries to most recently used when full', () => { + const cache = createLRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + cache.set('a', 4) + cache.set('d', 5) + expect(cache.get('b')).toBeUndefined() + expect(cache.get('a')).toBe(4) + }) + it('keeps updated newest entries most recently used when full', () => { + const cache = createLRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + cache.set('c', 4) + cache.set('d', 5) + expect(cache.get('a')).toBeUndefined() + expect(cache.get('c')).toBe(4) + }) + it('handles repeated updates without duplicating entries', () => { + const cache = createLRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + cache.set('a', 4) + cache.set('a', 5) + cache.set('b', 6) + cache.set('d', 7) + expect(cache.get('c')).toBeUndefined() + expect(cache.get('a')).toBe(5) + expect(cache.get('b')).toBe(6) + }) + it('moves middle entries to most recently used on get', () => { + const cache = createLRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + expect(cache.get('b')).toBe(2) + cache.set('d', 4) + expect(cache.get('a')).toBeUndefined() + expect(cache.get('c')).toBe(3) + expect(cache.get('b')).toBe(2) + }) + it('clears entries and reuses the cache', () => { + const cache = createLRUCache(2) + cache.clear() + cache.set('a', 1) + cache.set('b', 2) + cache.get('a') + cache.clear() + cache.clear() + expect(cache.get('a')).toBeUndefined() + cache.set('c', 3) + cache.set('d', 4) + cache.set('e', 5) + expect(cache.get('c')).toBeUndefined() + expect(cache.get('d')).toBe(4) + }) + it('handles a max size of one', () => { + const cache = createLRUCache(1) + cache.set('a', undefined) + expect(cache.get('a')).toBeUndefined() + cache.set('a', 2) + expect(cache.get('a')).toBe(2) + cache.set('b', 3) + expect(cache.get('a')).toBeUndefined() + expect(cache.get('b')).toBe(3) + }) + it('caches undefined values', () => { + const cache = createLRUCache(2) + cache.set('a', undefined) + cache.set('b', 2) + expect(cache.get('a')).toBeUndefined() + cache.set('c', 3) + expect(cache.get('b')).toBeUndefined() + }) + it('uses non-string keys by identity', () => { + const cache = createLRUCache(2) + const a = { id: 'a' } + const b = { id: 'b' } + const aLike = { id: 'a' } + cache.set(a, 1) + cache.set(b, 2) + expect(cache.get(aLike)).toBeUndefined() + expect(cache.get(a)).toBe(1) + cache.set({ id: 'c' }, 3) + expect(cache.get(b)).toBeUndefined() + }) })