cachly
Version:
Type-safe, production-ready in-memory cache system for Node.js and TypeScript with advanced features.
833 lines (707 loc) • 23.4 kB
text/typescript
import {
CacheOptions,
CacheItem,
CacheStats,
CacheConfig,
PersistenceAdapter,
CacheEventMap,
CacheEventType,
WarmupItem,
ProgressiveWarmupItem,
DependencyWarmupItem,
HealthStatus,
Metrics,
CircuitBreakerState,
PartitionInfo,
CacheGroup,
BulkOperation,
CacheTag,
} from './types';
import { CompressionUtil } from './utils/compression';
import { CircuitBreaker } from './utils/CircuitBreaker';
import { PartitioningUtil } from './utils/Partitioning';
import { MonitoringUtil } from './utils/Monitoring';
export class Cachly {
private items = new Map<string, CacheItem>();
private config: Required<CacheConfig>;
private cacheStats: CacheStats;
private persistence?: PersistenceAdapter;
private cleanupInterval?: ReturnType<typeof setInterval>;
private eventEmitter = new (require('events').EventEmitter)() as any;
// Advanced features
private compressionUtil?: CompressionUtil;
private circuitBreaker?: CircuitBreaker;
private partitioningUtil?: PartitioningUtil;
private monitoringUtil?: MonitoringUtil;
private circuitBreakerTrips = 0;
// New features
private tags = new Map<string, CacheTag>();
private groups = new Map<string, CacheGroup>();
private hitHook?: (key: string) => void;
private missHook?: (key: string) => void;
constructor(config: CacheConfig = {}) {
this.config = {
maxItems: 1000,
maxMemory: 0,
defaultTtl: 0,
staleWhileRevalidate: false,
log: false,
namespace: 'main',
persistence: 'none',
evictionPolicy: 'lru',
compression: { enabled: false, algorithm: 'gzip', threshold: 1024 },
circuitBreaker: { enabled: false, failureThreshold: 5, recoveryTimeout: 30000 },
partitioning: { enabled: false, strategy: 'hash', partitions: 4 },
monitoring: { enabled: false, metrics: [] },
distributed: { enabled: false, nodes: [], replication: false, consistency: 'eventual', partitionStrategy: 'consistent-hashing' },
onHit: () => {},
onMiss: () => {},
...config,
};
this.cacheStats = {
hits: 0,
misses: 0,
evictions: 0,
memoryUsage: 0,
keyCount: 0,
totalAccesses: 0,
hitRate: 0,
missRate: 0,
avgLoadTime: 0,
compressionRatio: 0,
};
// Initialize advanced features
if (this.config.compression.enabled) {
this.compressionUtil = new CompressionUtil();
}
if (this.config.circuitBreaker.enabled) {
this.circuitBreaker = new CircuitBreaker(this.config.circuitBreaker);
}
if (this.config.partitioning.enabled) {
this.partitioningUtil = new PartitioningUtil(this.config.partitioning);
}
if (this.config.monitoring.enabled) {
this.monitoringUtil = new MonitoringUtil(this.config.monitoring);
}
if (this.config.persistence !== 'none') {
this.persistence = this.config.persistence as PersistenceAdapter;
this.initializePersistence();
}
if (this.config.defaultTtl > 0) {
this.startCleanupInterval();
}
if (typeof config['onHit'] === 'function') {
this.hitHook = config['onHit'];
}
if (typeof config['onMiss'] === 'function') {
this.missHook = config['onMiss'];
}
}
async set<T>(key: string, value: T, options: Partial<CacheOptions> = {}): Promise<void> {
const startTime = Date.now();
const now = Date.now();
const ttl = options.ttl ?? this.config.defaultTtl;
const expiresAt = ttl && ttl > 0 ? now + ttl : undefined;
const staleAt = options.staleTtl ? now + options.staleTtl : undefined;
let processedValue: any = value;
let originalSize = 0;
let compressedSize = 0;
let compressed = false;
// Apply compression if enabled
if (this.compressionUtil && this.config.compression.enabled) {
try {
const compressionResult = await CompressionUtil.compress(value, this.config.compression);
processedValue = compressionResult.data;
originalSize = compressionResult.originalSize;
compressedSize = compressionResult.compressedSize;
compressed = originalSize !== compressedSize;
if (compressed) {
this.eventEmitter.emit('compress', key, originalSize, compressedSize);
}
} catch (error) {
if (this.config.log) {
console.error(`[Cachly] Compression failed for ${key}:`, error);
}
}
}
const item: CacheItem<any> = {
value: processedValue,
createdAt: now,
expiresAt,
staleAt,
dependsOn: new Set(options.dependsOn || []),
dependents: new Set(),
tags: new Set(options.tags || []),
accessCount: 0,
lastAccessed: now,
compressed,
originalSize,
compressedSize,
};
this.items.set(key, item);
this.updateDependencies(key, item.dependsOn);
this.updateTags(key, item.tags || new Set());
this.updateStats();
this.eventEmitter.emit('set', key, value);
if (this.config.log) {
console.log(`[Cachly] Set: ${key}`);
}
this.evictIfNeeded();
// Record load time for monitoring
if (this.monitoringUtil) {
this.monitoringUtil.recordLoadTime(Date.now() - startTime);
}
}
async get<T>(key: string): Promise<T | undefined> {
const item = this.items.get(key);
if (!item) {
this.cacheStats.misses++;
this.cacheStats.totalAccesses++;
this.eventEmitter.emit('miss', key);
if (this.missHook) this.missHook(key);
return undefined;
}
if (this.isExpired(item)) {
this.delete(key);
this.cacheStats.misses++;
this.cacheStats.totalAccesses++;
this.eventEmitter.emit('miss', key);
if (this.missHook) this.missHook(key);
return undefined;
}
item.accessCount++;
item.lastAccessed = Date.now();
this.cacheStats.hits++;
this.cacheStats.totalAccesses++;
this.eventEmitter.emit('hit', key);
if (this.hitHook) this.hitHook(key);
// Emit partition hit event
if (this.partitioningUtil) {
const partition = this.partitioningUtil.getPartition(key);
this.eventEmitter.emit('partitionHit', partition, key);
}
let result = item.value as T;
// Decompress if needed
if (item.compressed && this.compressionUtil) {
try {
result = await CompressionUtil.decompress(item.value as Buffer, this.config.compression.algorithm);
} catch (error) {
if (this.config.log) {
console.error(`[Cachly] Decompression failed for ${key}:`, error);
}
return undefined;
}
}
return result;
}
async getOrCompute<T>(
key: string,
loader: () => Promise<T>,
options: Partial<CacheOptions> = {}
): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== undefined) {
return cached;
}
// Use circuit breaker if enabled
if (this.circuitBreaker) {
return this.circuitBreaker.execute(key, async () => {
const value = await loader();
await this.set(key, value, options);
return value;
}, this.config.circuitBreaker.fallback);
}
const value = await loader();
await this.set(key, value, options);
return value;
}
async getOrComputeWithStale<T>(
key: string,
loader: () => Promise<T>,
options: Partial<CacheOptions> = {}
): Promise<T> {
let item = this.items.get(key);
if (item && !this.isExpired(item)) {
item.accessCount++;
item.lastAccessed = Date.now();
this.cacheStats.hits++;
this.cacheStats.totalAccesses++;
this.eventEmitter.emit('hit', key);
let result = item.value as T;
if (item.compressed && this.compressionUtil) {
result = await CompressionUtil.decompress(item.value as Buffer, this.config.compression.algorithm);
}
return result;
}
if (item && this.isStale(item) && this.config.staleWhileRevalidate) {
this.cacheStats.hits++;
this.cacheStats.totalAccesses++;
this.eventEmitter.emit('hit', key);
if ((options as any).__testAwaitBackground) {
try {
const value = await loader();
const staleTtl = options.staleTtl ?? 0;
const ttl = options.ttl ?? this.config.defaultTtl;
await this.set(key, value, { ...options, staleTtl, ttl });
// Update item reference for test determinism
item = this.items.get(key);
} catch (error) {
if (this.config.log) {
console.error(`[Cachly] Background refresh failed for ${key}:`, error);
}
}
} else {
setTimeout(async () => {
try {
const value = await loader();
const staleTtl = options.staleTtl ?? 0;
const ttl = options.ttl ?? this.config.defaultTtl;
await this.set(key, value, { ...options, staleTtl, ttl });
const updated = this.items.get(key);
if (updated) {
updated.staleAt = staleTtl ? Date.now() + staleTtl : undefined;
updated.expiresAt = ttl && ttl > 0 ? Date.now() + ttl : undefined;
}
} catch (error) {
if (this.config.log) {
console.error(`[Cachly] Background refresh failed for ${key}:`, error);
}
}
}, 0);
}
let result = item ? (item.value as T) : undefined;
if (item && item.compressed && this.compressionUtil) {
result = await CompressionUtil.decompress(item.value as Buffer, this.config.compression.algorithm);
}
return result as T;
}
const value = await loader();
await this.set(key, value, options);
return value;
}
has(key: string): boolean {
const item = this.items.get(key);
return item !== undefined && !this.isExpired(item);
}
delete(key: string): void {
const item = this.items.get(key);
if (item) {
this.removeDependencies(key, item.dependsOn);
this.removeTags(key, item.tags);
this.invalidateDependentsOnDelete(key);
this.items.delete(key);
this.updateStats();
this.eventEmitter.emit('delete', key);
}
}
clear(): void {
this.items.clear();
this.updateStats();
this.eventEmitter.emit('clear');
}
stats(): CacheStats {
const stats = { ...this.cacheStats };
// Calculate derived stats
if (stats.totalAccesses > 0) {
stats.hitRate = (stats.hits / stats.totalAccesses) * 100;
stats.missRate = (stats.misses / stats.totalAccesses) * 100;
}
// Calculate compression ratio
let totalOriginalSize = 0;
let totalCompressedSize = 0;
let compressedItems = 0;
for (const item of this.items.values()) {
if (item.compressed && item.originalSize && item.compressedSize) {
totalOriginalSize += item.originalSize;
totalCompressedSize += item.compressedSize;
compressedItems++;
}
}
if (totalOriginalSize > 0) {
stats.compressionRatio = CompressionUtil.calculateCompressionRatio(totalOriginalSize, totalCompressedSize);
}
// Add monitoring metrics
if (this.monitoringUtil) {
const monitoringMetrics = this.monitoringUtil.getMetrics();
stats.avgLoadTime = monitoringMetrics.avgLoadTime;
}
return stats;
}
async warm(items: WarmupItem[]): Promise<void> {
const promises = items.map(async (item) => {
try {
const value = await item.loader();
await this.set(item.key, value, {
ttl: item.ttl,
staleTtl: item.staleTtl,
dependsOn: item.dependsOn,
});
} catch (error) {
if (this.config.log) {
console.error(`[Cachly] Warmup failed for ${item.key}:`, error);
}
}
});
await Promise.all(promises);
}
async warmProgressive(items: ProgressiveWarmupItem[]): Promise<void> {
const priorities = ['high', 'medium', 'low'];
for (const priority of priorities) {
const priorityItems = items.filter(item => item.priority === priority);
await this.warm(priorityItems);
}
}
async warmWithDependencies(items: DependencyWarmupItem[]): Promise<void> {
// Sort items by dependency depth
const sortedItems = this.sortByDependencies(items);
await this.warm(sortedItems);
}
private sortByDependencies(items: DependencyWarmupItem[]): DependencyWarmupItem[] {
const dependencyMap = new Map<string, DependencyWarmupItem>();
const visited = new Set<string>();
const sorted: DependencyWarmupItem[] = [];
// Build dependency map
for (const item of items) {
dependencyMap.set(item.key, item);
}
const visit = (item: DependencyWarmupItem) => {
if (visited.has(item.key)) return;
visited.add(item.key);
// Visit dependencies first
for (const dep of item.deps) {
const depItem = dependencyMap.get(dep);
if (depItem) {
visit(depItem);
}
}
sorted.push(item);
};
for (const item of items) {
visit(item);
}
return sorted;
}
health(): HealthStatus {
if (this.monitoringUtil) {
return this.monitoringUtil.health();
}
return {
status: 'healthy',
issues: [],
lastCheck: Date.now(),
uptime: Date.now() - (this as any).startTime || 0,
};
}
metrics(): Metrics {
const stats = this.stats();
const baseMetrics = this.monitoringUtil?.getMetrics() || {
hitRate: 0,
missRate: 0,
avgLoadTime: 0,
memoryEfficiency: 0,
compressionRatio: 0,
circuitBreakerTrips: 0,
partitionDistribution: {},
};
return {
...baseMetrics,
hitRate: stats.hitRate,
missRate: stats.missRate,
avgLoadTime: stats.avgLoadTime,
memoryEfficiency: (stats.memoryUsage / (this.config.maxMemory || 1)) * 100,
compressionRatio: stats.compressionRatio,
circuitBreakerTrips: this.circuitBreakerTrips,
partitionDistribution: this.partitioningUtil?.getPartitionDistribution() || {},
};
}
getCircuitBreakerState(): CircuitBreakerState | undefined {
return this.circuitBreaker?.getState();
}
getPartitionInfo(partitionId: number): PartitionInfo | undefined {
return this.partitioningUtil?.getPartitionInfo(partitionId);
}
getAllPartitions(): PartitionInfo[] {
return this.partitioningUtil?.getAllPartitions() || [];
}
isBalanced(): boolean {
return this.partitioningUtil?.isBalanced() || true;
}
private isExpired(item: CacheItem): boolean {
return item.expiresAt !== undefined && Date.now() > item.expiresAt;
}
private isStale(item: CacheItem): boolean {
return item.staleAt !== undefined && Date.now() > item.staleAt;
}
private updateDependencies(key: string, dependencies: Set<string>): void {
for (const dep of dependencies) {
const depItem = this.items.get(dep);
if (depItem) {
depItem.dependents.add(key);
}
}
}
private removeDependencies(key: string, dependencies: Set<string>): void {
for (const dep of dependencies) {
const depItem = this.items.get(dep);
if (depItem) {
depItem.dependents.delete(key);
}
}
}
private invalidateDependents(key: string): void {
const item = this.items.get(key);
if (!item) return;
const dependents = new Set(item.dependents);
for (const dependent of dependents) {
this.delete(dependent);
}
}
private invalidateDependentsOnDelete(key: string): void {
this.invalidateDependents(key);
}
private evictIfNeeded(): void {
while (this.config.maxItems && this.items.size > this.config.maxItems) {
this.evict();
}
}
private evict(): void {
if (this.config.evictionPolicy === 'lru') {
this.evictLRU();
} else if (this.config.evictionPolicy === 'ttl') {
this.evictTTL();
} else if (this.config.evictionPolicy === 'lfu') {
this.evictLFU();
}
}
private evictLRU(): void {
let oldestKey: string | null = null;
let oldestTime = Date.now();
for (const [key, item] of this.items) {
if (item.lastAccessed < oldestTime) {
oldestTime = item.lastAccessed;
oldestKey = key;
}
}
if (oldestKey) {
this.items.delete(oldestKey);
this.cacheStats.evictions++;
this.eventEmitter.emit('evict', oldestKey, 'lru');
this.updateStats();
}
}
private evictTTL(): void {
const now = Date.now();
for (const [key, item] of this.items) {
if (item.expiresAt && now > item.expiresAt) {
this.items.delete(key);
this.cacheStats.evictions++;
this.eventEmitter.emit('evict', key, 'ttl');
}
}
this.updateStats();
}
private evictLFU(): void {
let leastFrequentKey: string | null = null;
let leastFrequentCount = Infinity;
for (const [key, item] of this.items) {
if (item.accessCount < leastFrequentCount) {
leastFrequentCount = item.accessCount;
leastFrequentKey = key;
}
}
if (leastFrequentKey) {
this.items.delete(leastFrequentKey);
this.cacheStats.evictions++;
this.eventEmitter.emit('evict', leastFrequentKey, 'lfu');
this.updateStats();
}
}
private updateStats(): void {
this.cacheStats.keyCount = this.items.size;
this.cacheStats.memoryUsage = this.estimateMemoryUsage();
}
private estimateMemoryUsage(): number {
let total = 0;
for (const [key, item] of this.items) {
total += key.length * 2;
if (item.compressed && item.compressedSize) {
total += item.compressedSize;
} else {
total += JSON.stringify(item.value).length * 2;
}
total += 100;
}
return total;
}
private startCleanupInterval(): void {
this.cleanupInterval = setInterval(() => {
this.evictTTL();
}, 60000);
}
private initializePersistence(): void {
// Initialize persistence layer if needed
if (this.persistence) {
// Future implementation for persistence initialization
}
}
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.clear();
this.eventEmitter.removeAllListeners();
}
on<K extends CacheEventType>(event: K, listener: CacheEventMap[K]): this {
this.eventEmitter.on(event, listener);
return this;
}
off<K extends CacheEventType>(event: K, listener: CacheEventMap[K]): this {
this.eventEmitter.off(event, listener);
return this;
}
async invalidateByTag(tag: string): Promise<string[]> {
const tagData = this.tags.get(tag);
if (!tagData) return [];
const affectedKeys = Array.from(tagData.keys);
for (const key of affectedKeys) {
this.delete(key);
}
this.tags.delete(tag);
this.eventEmitter.emit('tagInvalidated', tag, affectedKeys);
return affectedKeys;
}
async invalidateByTags(tags: string[]): Promise<Record<string, string[]>> {
const results: Record<string, string[]> = {};
for (const tag of tags) {
results[tag] = await this.invalidateByTag(tag);
}
return results;
}
getKeysByTag(tag: string): string[] {
const tagData = this.tags.get(tag);
return tagData ? Array.from(tagData.keys) : [];
}
getTagsByKey(key: string): string[] {
const item = this.items.get(key);
return item?.tags ? Array.from(item.tags) : [];
}
createGroup(name: string, config: Partial<CacheConfig> = {}): CacheGroup {
const group: CacheGroup = {
name,
keys: new Set(),
config,
};
this.groups.set(name, group);
this.eventEmitter.emit('groupCreated', group);
return group;
}
addToGroup(groupName: string, key: string): boolean {
const group = this.groups.get(groupName);
if (!group) return false;
group.keys.add(key);
return true;
}
removeFromGroup(groupName: string, key: string): boolean {
const group = this.groups.get(groupName);
if (!group) return false;
return group.keys.delete(key);
}
getGroupKeys(groupName: string): string[] {
const group = this.groups.get(groupName);
return group ? Array.from(group.keys) : [];
}
deleteGroup(groupName: string): boolean {
const group = this.groups.get(groupName);
if (!group) return false;
for (const key of group.keys) {
this.delete(key);
}
this.groups.delete(groupName);
this.eventEmitter.emit('groupDeleted', groupName);
return true;
}
async bulk(operation: BulkOperation): Promise<any> {
const startTime = Date.now();
const results: any = {
get: {},
set: [],
delete: [],
invalidateByTag: {},
};
for (const key of operation.get) {
results.get[key] = await this.get(key);
}
for (const { key, value, options } of operation.set) {
await this.set(key, value, options);
results.set.push(key);
}
for (const key of operation.delete) {
this.delete(key);
results.delete.push(key);
}
for (const tag of operation.invalidateByTag) {
results.invalidateByTag[tag] = await this.invalidateByTag(tag);
}
const duration = Date.now() - startTime;
this.eventEmitter.emit('bulkOperation', operation, { results, duration });
return results;
}
getKeys(pattern?: string): string[] {
const keys = Array.from(this.items.keys());
if (!pattern) return keys;
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
return keys.filter(key => regex.test(key));
}
getKeysByPattern(pattern: string): string[] {
return this.getKeys(pattern);
}
getItemsByPattern(pattern: string): Array<{ key: string; item: CacheItem }> {
const keys = this.getKeysByPattern(pattern);
return keys.map(key => ({ key, item: this.items.get(key)! }));
}
getTopKeys(limit: number = 10): Array<{ key: string; accessCount: number }> {
return Array.from(this.items.entries())
.map(([key, item]) => ({ key, accessCount: item.accessCount }))
.sort((a, b) => b.accessCount - a.accessCount)
.slice(0, limit);
}
getLeastUsedKeys(limit: number = 10): Array<{ key: string; accessCount: number }> {
return Array.from(this.items.entries())
.map(([key, item]) => ({ key, accessCount: item.accessCount }))
.sort((a, b) => a.accessCount - b.accessCount)
.slice(0, limit);
}
getKeysByAge(limit: number = 10): Array<{ key: string; age: number }> {
const now = Date.now();
return Array.from(this.items.entries())
.map(([key, item]) => ({ key, age: now - item.createdAt }))
.sort((a, b) => b.age - a.age)
.slice(0, limit);
}
private updateTags(key: string, tags: Set<string>): void {
for (const tag of tags) {
if (!this.tags.has(tag)) {
this.tags.set(tag, { name: tag, keys: new Set(), createdAt: Date.now() });
}
this.tags.get(tag)!.keys.add(key);
}
}
private removeTags(key: string, tags?: Set<string>): void {
if (!tags) return;
for (const tag of tags) {
const tagData = this.tags.get(tag);
if (tagData) {
tagData.keys.delete(key);
if (tagData.keys.size === 0) {
this.tags.delete(tag);
}
}
}
}
setHitHook(hook: (key: string) => void) {
this.hitHook = hook;
}
setMissHook(hook: (key: string) => void) {
this.missHook = hook;
}
}