Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 35 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(options?)`

Expand Down Expand Up @@ -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<string>({ 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
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Cache Hooks

Monitor cache lifecycle events with optional hooks:
Expand Down
20 changes: 17 additions & 3 deletions docs/src/routes/docs/api/memory-cache/+page.svx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string>({ 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.
Expand Down Expand Up @@ -568,5 +584,3 @@ const cache = new MemoryCache<string>({
- **Performance**: Keep hooks lightweight to avoid impacting cache performance.
- **Batch operations**: `clear()`, `deleteByPrefix()`, and `deleteByMagicString()` call `onDelete` once per deleted entry.



3 changes: 2 additions & 1 deletion docs/src/routes/examples/+page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const EXAMPLES: Record<string, ExampleEntry> = {
},
'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',
Expand Down
33 changes: 20 additions & 13 deletions docs/src/routes/examples/lru-eviction/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
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'
import RotateCcw from '@lucide/svelte/icons/rotate-ccw'
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()
Expand All @@ -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'
}
</script>
Expand All @@ -52,29 +52,36 @@
<li>
<Lightbulb />
<span>
LRU means <code>least recently used</code>: the oldest untouched
key is the first candidate when the cache reaches capacity.
LRU means <code>least recently used</code>: the oldest untouched key is the first
candidate when the cache reaches capacity.
</span>
</li>
<li>
<MousePointer />
<span>
Clicking an entry calls <code>get(key)</code>, moving that key to
the MRU end of the list.
Clicking an entry calls <code>get(key)</code>, moving that key to the MRU end of the
list.
</span>
</li>
<li>
<Plus />
<span>
<code>add entry</code> writes a new item. If <code>size === maxSize</code>,
the current LRU key is evicted before the new key lands.
<code>add entry</code> writes a new item. If <code>size === maxSize</code>
after expired entries are pruned, the current LRU key is evicted.
</span>
</li>
<li>
<Clock />
<span>
When <code>ttl</code> and <code>maxSize</code> are both configured, expired entries are
removed before any valid LRU entry is evicted.
</span>
</li>
<li>
<RotateCcw />
<span>
Changing <code>maxSize</code> resets the demo so capacity,
rank, and eviction behavior stay easy to compare.
Changing <code>maxSize</code> resets the demo so capacity, rank, and eviction behavior
stay easy to compare.
</span>
</li>
</ul>
Expand All @@ -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`}
Expand Down
47 changes: 46 additions & 1 deletion src/cache.lru.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MemoryCache } from './cache.js'

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<string>({ maxSize: 2 })

Expand Down Expand Up @@ -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<string>({
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)
})
})

// ==========================================
Expand Down
11 changes: 8 additions & 3 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,8 +507,9 @@ export class MemoryCache<T> {

/**
* 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
Expand All @@ -522,7 +523,11 @@ export class MemoryCache<T> {
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) {
Expand Down
Loading