lrufy
Version:
A feature-rich LRU cache implementation with TTL support, custom sizing, and event hooks
461 lines (405 loc) • 11 kB
text/typescript
import { DoublyLinkedList, Node } from "./linked-list";
/**
* Options for an individual cache item
*/
export interface ItemOptions {
/**
* Time-to-live in milliseconds
*/
ttl?: number;
/**
* Size of the item (defaults to 1 if size calculation is not provided)
*/
size?: number;
}
/**
* Function to calculate the size of a cached item
*/
export type SizeCalculator<K, V> = (value: V, key: K) => number;
/**
* Function called when an item is disposed from the cache
*/
export type DisposeCallback<K, V> = (
value: V,
key: K,
reason: EvictionReason
) => void | Promise<void>;
/**
* Statistics about cache performance
*/
export interface CacheStats {
/**
* Total number of items in the cache
*/
size: number;
/**
* Number of successful cache hits
*/
hits: number;
/**
* Number of cache misses
*/
misses: number;
/**
* Total size of all items in the cache (if sizeCalculation is used)
*/
totalSize: number;
/**
* Ratio of hits to total accesses
*/
hitRate: number;
}
/**
* Reason an item was evicted from the cache
*/
export enum EvictionReason {
/**
* Item was manually deleted
*/
DELETED = "deleted",
/**
* Item was evicted due to capacity constraints
*/
EVICTED = "evicted",
/**
* Item expired due to TTL
*/
EXPIRED = "expired",
/**
* Item was overwritten with a new value
*/
OVERWRITTEN = "overwritten",
/**
* Cache was manually cleared
*/
CACHE_CLEAR = "clear",
}
/**
* Options for configuring the LRU cache
*/
export interface LRUCacheOptions<K, V> {
/**
* Maximum number of items in the cache
*/
max?: number;
/**
* Maximum size of all items in the cache combined
*/
maxSize?: number;
/**
* Default time-to-live for cache items in milliseconds
*/
ttl?: number;
/**
* Function to calculate the size of items
*/
sizeCalculation?: SizeCalculator<K, V>;
/**
* Function called when an item is disposed
*/
dispose?: DisposeCallback<K, V> | null;
/**
* Whether to not call dispose when an item is overwritten
*/
noDisposeOnSet?: boolean;
/**
* Whether to allow returning stale (expired) items before removing them
*/
allowStale?: boolean;
/**
* How often to check for and remove expired items (ms), disabled if 0
*/
pruneInterval?: number;
/**
* Whether to dispose of items asynchronously
*/
asyncDispose?: boolean;
}
// Custom type for internal options that includes required fields
interface InternalLRUCacheOptions<K, V> {
max: number;
maxSize: number;
ttl: number;
sizeCalculation: SizeCalculator<K, V>;
dispose: DisposeCallback<K, V> | null;
noDisposeOnSet: boolean;
allowStale: boolean;
pruneInterval: number;
asyncDispose: boolean;
}
/**
* A fully-featured Least Recently Used (LRU) cache implementation
* with optional TTL, custom sizing, and event hooks
*/
export class LRUCache<K = string, V = any> {
private cache: Map<K, Node<K, V>>;
private list: DoublyLinkedList<K, V>;
private readonly options: InternalLRUCacheOptions<K, V>;
private totalSize: number = 0;
private pruneTimer: NodeJS.Timeout | null = null;
private hits: number = 0;
private misses: number = 0;
/**
* Creates a new LRU cache instance
* @param options - Configuration options for the cache
*/
constructor(options: LRUCacheOptions<K, V> = {}) {
this.options = {
max: options.max ?? Infinity,
maxSize: options.maxSize ?? Infinity,
ttl: options.ttl ?? 0,
sizeCalculation: options.sizeCalculation ?? (() => 1),
dispose: options.dispose ?? null,
noDisposeOnSet: options.noDisposeOnSet ?? false,
allowStale: options.allowStale ?? false,
pruneInterval: options.pruneInterval ?? 0,
asyncDispose: options.asyncDispose ?? false,
};
this.cache = new Map<K, Node<K, V>>();
this.list = new DoublyLinkedList<K, V>();
if (this.options.pruneInterval > 0) {
this.startPruneTimer();
}
}
/**
* Gets the current size of the cache
*/
get size(): number {
return this.cache.size;
}
/**
* Retrieves an item from the cache
* @param key - The key to retrieve
* @returns The cached value or undefined if not found
*/
get(key: K): V | undefined {
const node = this.cache.get(key);
if (!node) {
this.misses++;
return undefined;
}
const now = Date.now();
if (node.expiry !== null && node.expiry < now) {
// Item is expired
if (this.options.allowStale) {
this.hits++;
return node.value;
} else {
this.delete(key, EvictionReason.EXPIRED);
this.misses++;
return undefined;
}
}
// Move to front of list to mark as most recently used
this.list.moveToFront(node);
this.hits++;
return node.value;
}
/**
* Checks if a key exists in the cache without updating its recency
* @param key - The key to check
* @returns True if the key exists and is not expired
*/
has(key: K): boolean {
const node = this.cache.get(key);
if (!node) return false;
if (node.expiry !== null && node.expiry < Date.now()) {
if (!this.options.allowStale) {
return false;
}
}
return true;
}
/**
* Gets an item without updating its recency
* @param key - The key to retrieve
* @returns The cached value or undefined if not found
*/
peek(key: K): V | undefined {
const node = this.cache.get(key);
if (!node) return undefined;
if (node.expiry !== null && node.expiry < Date.now()) {
if (!this.options.allowStale) {
return undefined;
}
}
return node.value;
}
/**
* Adds or updates an item in the cache
* @param key - The key to set
* @param value - The value to cache
* @param options - Options for this specific item
* @returns The cache instance for chaining
*/
set(key: K, value: V, options: ItemOptions = {}): this {
const existing = this.cache.get(key);
if (existing) {
// Handle disposing of the old value if being overwritten
if (!this.options.noDisposeOnSet) {
this.disposeItem(
existing.key,
existing.value,
EvictionReason.OVERWRITTEN
);
}
// Remove from the linked list and update totalSize
this.list.remove(existing);
this.totalSize -= existing.size;
}
// Calculate TTL for this item
const ttl = options.ttl ?? this.options.ttl;
const expiry = ttl > 0 ? Date.now() + ttl : null;
// Calculate size for this item
const size = options.size ?? this.options.sizeCalculation(value, key);
// Create and add the new node
const node = new Node<K, V>(key, value);
node.size = size;
node.expiry = expiry;
this.list.addToFront(node);
this.cache.set(key, node);
this.totalSize += size;
// Prune if needed
this.prune();
return this;
}
/**
* Removes an item from the cache
* @param key - The key to remove
* @param reason - The reason for removal (for dispose callback)
* @returns True if the item was found and removed
*/
delete(key: K, reason: EvictionReason = EvictionReason.DELETED): boolean {
const node = this.cache.get(key);
if (!node) return false;
this.disposeItem(node.key, node.value, reason);
this.cache.delete(key);
this.list.remove(node);
this.totalSize -= node.size;
return true;
}
/**
* Clears all items from the cache
*/
clear(): void {
// Call dispose on all items if needed
if (this.options.dispose) {
for (const [key, node] of this.cache.entries()) {
this.disposeItem(key, node.value, EvictionReason.CACHE_CLEAR);
}
}
this.cache.clear();
this.list.clear();
this.totalSize = 0;
}
/**
* Retrieves cache statistics
* @returns An object with cache statistics
*/
getStats(): CacheStats {
const totalAccesses = this.hits + this.misses;
return {
size: this.cache.size,
hits: this.hits,
misses: this.misses,
totalSize: this.totalSize,
hitRate: totalAccesses > 0 ? this.hits / totalAccesses : 0,
};
}
/**
* Removes all expired items from the cache
* @returns Number of items pruned
*/
prune(): number {
const now = Date.now();
let pruned = 0;
// First, remove expired items
for (const [key, node] of this.cache.entries()) {
if (node.expiry !== null && node.expiry <= now) {
this.delete(key, EvictionReason.EXPIRED);
pruned++;
}
}
// Then enforce size constraints
while (
(this.options.max < Infinity && this.cache.size > this.options.max) ||
(this.options.maxSize < Infinity && this.totalSize > this.options.maxSize)
) {
const node = this.list.removeTail();
if (!node) break;
this.cache.delete(node.key);
this.disposeItem(node.key, node.value, EvictionReason.EVICTED);
this.totalSize -= node.size;
pruned++;
}
return pruned;
}
/**
* Serializes the cache to a JSON-friendly format
* @returns An array of entries that can be used to reconstruct the cache
*/
serialize(): Array<[K, V, ItemOptions]> {
const result: Array<[K, V, ItemOptions]> = [];
for (const [key, node] of this.cache.entries()) {
const options: ItemOptions = {
size: node.size,
};
if (node.expiry !== null) {
const ttl = node.expiry - Date.now();
if (ttl > 0) {
options.ttl = ttl;
} else if (!this.options.allowStale) {
continue; // Skip expired items
}
}
result.push([key, node.value, options]);
}
return result;
}
/**
* Loads serialized data into the cache
* @param data - Serialized cache data from serialize()
* @returns The cache instance for chaining
*/
deserialize(data: Array<[K, V, ItemOptions]>): this {
this.clear();
for (const [key, value, options] of data) {
this.set(key, value, options);
}
return this;
}
/**
* Starts the automatic pruning timer
*/
private startPruneTimer(): void {
if (this.pruneTimer) {
clearInterval(this.pruneTimer);
}
this.pruneTimer = setInterval(() => {
this.prune();
}, this.options.pruneInterval);
// Prevent the timer from keeping the process alive
if (this.pruneTimer.unref) {
this.pruneTimer.unref();
}
}
/**
* Handles disposing of an item
*/
private disposeItem(key: K, value: V, reason: EvictionReason): void {
if (this.options.dispose === null) return;
try {
if (this.options.asyncDispose) {
Promise.resolve(this.options.dispose(value, key, reason)).catch(
(err) => {
console.error("Async dispose error:", err);
}
);
} else {
this.options.dispose(value, key, reason);
}
} catch (e) {
console.error("Error in dispose callback:", e);
}
}
}