diff --git a/README.md b/README.md index d032699..22953fd 100644 --- a/README.md +++ b/README.md @@ -186,24 +186,24 @@ await Promise.all(promises) // fetchExpensiveData called only once #### Methods -| Method | Description | -| ------------------------------ | ---------------------------------------------------- | -| `get(key)` | Retrieves a value from the cache | -| `set(key, value)` | Stores a value in the cache | -| `getOrSet(key, fetcher)` | Gets cached value or fetches and caches on miss | -| `has(key)` | Checks if a key exists (useful for cached undefined) | -| `delete(key)` | Removes a specific entry | -| `deleteAsync(key)` | Async version of delete | -| `clear()` | Removes all entries | -| `deleteByPrefix(prefix)` | Removes entries starting with prefix | -| `deleteByMagicString(pattern)` | Removes entries matching wildcard pattern | -| `size()` | Returns the number of entries in cache | -| `keys()` | Returns array of all cache keys | -| `values()` | Returns array of all cached values | -| `entries()` | Returns array of [key, value] pairs | -| `getStats()` | Returns cache statistics (hits, misses, etc.) | -| `resetStats()` | Resets statistics counters to zero | -| `prune()` | Removes all expired entries, returns count | +| Method | Description | +| ------------------------------ | ------------------------------------------------------- | +| `get(key)` | Retrieves a value from the cache | +| `set(key, value)` | Stores a value, pruning expired entries before eviction | +| `getOrSet(key, fetcher)` | Gets cached value or fetches and caches on miss | +| `has(key)` | Checks if a key exists (useful for cached undefined) | +| `delete(key)` | Removes a specific entry | +| `deleteAsync(key)` | Async version of delete | +| `clear()` | Removes all entries | +| `deleteByPrefix(prefix)` | Removes entries starting with prefix | +| `deleteByMagicString(pattern)` | Removes entries matching wildcard pattern | +| `size()` | Returns the number of entries in cache | +| `keys()` | Returns array of all cache keys | +| `values()` | Returns array of all cached values | +| `entries()` | Returns array of [key, value] pairs | +| `getStats()` | Returns cache statistics (hits, misses, etc.) | +| `resetStats()` | Resets statistics counters to zero | +| `prune()` | Removes all expired entries, returns count | ### `@cached(options?)` @@ -263,6 +263,23 @@ cache.resetStats() const prunedCount = cache.prune() ``` +When `ttl` and `maxSize` are both configured, writes reclaim expired entries +before evicting the least recently used valid entry: + +```typescript +const cache = new MemoryCache({ maxSize: 2, ttl: 1000 }) + +cache.set('stale', 'old') + +// ... 750ms pass ... + +cache.set('fresh', 'new') + +// ... another 300ms pass; stale expires, fresh is still valid ... + +cache.set('next', 'value') // prunes stale; fresh remains cached +``` + ## Cache Hooks Monitor cache lifecycle events with optional hooks: diff --git a/docs/src/routes/docs/api/memory-cache/+page.svx b/docs/src/routes/docs/api/memory-cache/+page.svx index fee8242..93b4423 100644 --- a/docs/src/routes/docs/api/memory-cache/+page.svx +++ b/docs/src/routes/docs/api/memory-cache/+page.svx @@ -134,7 +134,7 @@ await Promise.all(promises) // fetchExpensiveData called only once ### set(key, value) -Stores a value in the cache. If the cache is full and this is a new key, the least recently used entry is evicted. Setting a value (new or update) moves the entry to the most-recently-used position. +Stores a value in the cache. If the cache is full and this is a new key, expired entries are pruned before the least recently used entry is evicted. Setting a value (new or update) moves the entry to the most-recently-used position. ```typescript set(key: string, value: T): void @@ -152,6 +152,22 @@ cache.set('count', 42) cache.set('user', { name: 'John', age: 30 }) ``` +When `ttl` and `maxSize` are both active, expired entries are reclaimed first: + +```typescript +const cache = new MemoryCache({ maxSize: 2, ttl: 1000 }) + +cache.set('a', 'expires') + +// ... 750ms pass ... + +cache.set('b', 'valid') + +// ... another 300ms pass; a expires, b is still valid ... + +cache.set('c', 'new') // removes expired a instead of evicting valid b +``` + ### has(key) Checks if a key exists in the cache and hasn't expired. This is useful for distinguishing between cache misses and cached `undefined` values. @@ -568,5 +584,3 @@ const cache = new MemoryCache({ - **Performance**: Keep hooks lightweight to avoid impacting cache performance. - **Batch operations**: `clear()`, `deleteByPrefix()`, and `deleteByMagicString()` call `onDelete` once per deleted entry. - - diff --git a/docs/src/routes/examples/+page.ts b/docs/src/routes/examples/+page.ts index 4d96f86..2fc8d6e 100644 --- a/docs/src/routes/examples/+page.ts +++ b/docs/src/routes/examples/+page.ts @@ -16,7 +16,8 @@ const EXAMPLES: Record = { }, 'lru-eviction': { title: 'LRU Eviction', - description: 'See how least recently used items are evicted when the cache is full.' + description: + 'See how expired entries are pruned before least recently used items are evicted.' }, 'cache-statistics': { title: 'Cache Statistics', diff --git a/docs/src/routes/examples/lru-eviction/+page.svelte b/docs/src/routes/examples/lru-eviction/+page.svelte index f6a2035..0f354e7 100644 --- a/docs/src/routes/examples/lru-eviction/+page.svelte +++ b/docs/src/routes/examples/lru-eviction/+page.svelte @@ -5,6 +5,7 @@ getBreadcrumbContext, getSeoContext } from '@humanspeak/docs-kit' + import Clock from '@lucide/svelte/icons/clock' import Lightbulb from '@lucide/svelte/icons/lightbulb' import MousePointer from '@lucide/svelte/icons/mouse-pointer' import Plus from '@lucide/svelte/icons/plus' @@ -12,8 +13,7 @@ import { demoCodeSample } from '$lib/demo-loaders' import LruEviction from '$lib/examples/lru-eviction/demos/Default.svelte' - const SOURCE_URL = - 'https://github.com/humanspeak/memory-cache/blob/main/docs/src/lib/examples/' + const SOURCE_URL = 'https://github.com/humanspeak/memory-cache/blob/main/docs/src/lib/examples/' const breadcrumbs = getBreadcrumbContext() const seo = getSeoContext() @@ -26,10 +26,10 @@ if (seo) { seo.title = 'LRU Eviction | Examples | Memory Cache' seo.description = - 'See how least recently used items are evicted when the cache reaches capacity. Interactive demo of LRU eviction in @humanspeak/memory-cache for TypeScript.' + 'See how least recently used items are evicted when the cache reaches capacity, and how expired entries are pruned first. Interactive demo of LRU eviction in @humanspeak/memory-cache for TypeScript.' seo.ogTitle = 'LRU Eviction' seo.ogTagline = 'Watch least recently used entries leave the cache' - seo.ogFeatures = ['Max Size', 'Access Order', 'Eviction', 'Live State'] + seo.ogFeatures = ['Max Size', 'Access Order', 'TTL Pruning', 'Live State'] seo.ogSlug = 'examples-lru-eviction' } @@ -52,29 +52,36 @@
  • - LRU means least recently used: the oldest untouched - key is the first candidate when the cache reaches capacity. + LRU means least recently used: the oldest untouched key is the first + candidate when the cache reaches capacity.
  • - Clicking an entry calls get(key), moving that key to - the MRU end of the list. + Clicking an entry calls get(key), moving that key to the MRU end of the + list.
  • - add entry writes a new item. If size === maxSize, - the current LRU key is evicted before the new key lands. + add entry writes a new item. If size === maxSize + after expired entries are pruned, the current LRU key is evicted. + +
  • +
  • + + + When ttl and maxSize are both configured, expired entries are + removed before any valid LRU entry is evicted.
  • - Changing maxSize resets the demo so capacity, - rank, and eviction behavior stay easy to compare. + Changing maxSize resets the demo so capacity, rank, and eviction behavior + stay easy to compare.
  • @@ -84,7 +91,7 @@ figId="FIG-001" tag="EVICTION-POLICY" title={{ prefix: 'trace lru ', accent: 'eviction', end: '.' }} - description="Fill a bounded `MemoryCache`, access entries, and watch the least recently used key leave when capacity is reached." + description="Fill a bounded `MemoryCache`, access entries, and watch how expired entries are reclaimed before least recently used valid keys leave." sheetLabel="SHEET 01 / 01" barCells={[{ k: 'pattern', v: 'least recently used' }]} sourceUrl={`${SOURCE_URL}lru-eviction/demos/Default.svelte`} diff --git a/src/cache.lru.test.ts b/src/cache.lru.test.ts index 9886549..f5c1177 100644 --- a/src/cache.lru.test.ts +++ b/src/cache.lru.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MemoryCache } from './cache.js' describe('MemoryCache LRU Eviction', () => { @@ -6,6 +6,14 @@ describe('MemoryCache LRU Eviction', () => { // MAX SIZE EVICTION // ========================================== describe('Max Size Eviction', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + it('should evict oldest entry when max size is reached', () => { const sizeCache = new MemoryCache({ maxSize: 2 }) @@ -88,6 +96,43 @@ describe('MemoryCache LRU Eviction', () => { expect(nanCache.get('key0')).toBe('value0') expect(nanCache.get('key199')).toBe('value199') }) + + it('should prune expired entries before evicting valid LRU entries', () => { + const onEvict = vi.fn() + const onExpire = vi.fn() + const sizeCache = new MemoryCache({ + maxSize: 2, + ttl: 100, + hooks: { onEvict, onExpire } + }) + + sizeCache.set('expires-first', 'old') + vi.advanceTimersByTime(50) + sizeCache.set('valid-lru', 'fresh') + + // Make the older entry most-recently-used while it is still valid. + // Without pruning, adding a new key would evict valid-lru because + // it is now the LRU entry even though expires-first has expired. + expect(sizeCache.get('expires-first')).toBe('old') + + vi.advanceTimersByTime(51) + sizeCache.set('new-entry', 'new') + + expect(sizeCache.get('valid-lru')).toBe('fresh') + expect(sizeCache.get('new-entry')).toBe('new') + expect(sizeCache.has('expires-first')).toBe(false) + + expect(onEvict).not.toHaveBeenCalled() + expect(onExpire).toHaveBeenCalledTimes(1) + expect(onExpire).toHaveBeenCalledWith({ + key: 'expires-first', + value: 'old', + source: 'prune' + }) + expect(sizeCache.getStats().evictions).toBe(0) + expect(sizeCache.getStats().expirations).toBe(1) + expect(sizeCache.size()).toBe(2) + }) }) // ========================================== diff --git a/src/cache.ts b/src/cache.ts index 5a2e30d..7760e53 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -507,8 +507,9 @@ export class MemoryCache { /** * Stores a value in the cache. If the cache is full and this is a new key, - * the least recently used entry is evicted to make room. Setting a value - * (new or update) moves the entry to the most-recently-used position. + * expired entries are pruned before the least recently used entry is evicted + * to make room. Setting a value (new or update) moves the entry to the + * most-recently-used position. * * @param {string} key - The key under which to store the value * @param {T} value - The value to cache @@ -522,7 +523,11 @@ export class MemoryCache { set(key: string, value: T): void { const isNewKey = !this.cache.has(key) - // Remove LRU entry if cache is full and this is a new key (skip if maxSize is 0) + if (isNewKey && this.maxSize > 0 && this.cache.size >= this.maxSize) { + this.prune() + } + + // Remove LRU entry if cache is still full and this is a new key (skip if maxSize is 0) if (isNewKey && this.maxSize > 0 && this.cache.size >= this.maxSize) { const oldestKey = this.cache.keys().next().value if (oldestKey) {