UNPKG

@comic-vine/in-memory-store

Version:

In-memory store implementations for Comic Vine client caching, deduplication, and rate limiting

717 lines (715 loc) 21.2 kB
import safeStringify from 'fast-safe-stringify'; import { randomUUID } from 'crypto'; import { DEFAULT_RATE_LIMIT, AdaptiveCapacityCalculator } from '@comic-vine/client'; var __async = (__this, __arguments, generator) => { return new Promise((resolve, reject) => { var fulfilled = (value) => { try { step(generator.next(value)); } catch (e) { reject(e); } }; var rejected = (value) => { try { step(generator.throw(value)); } catch (e) { reject(e); } }; var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected); step((generator = generator.apply(__this, __arguments)).next()); }); }; var InMemoryCacheStore = class { // running total of memory usage in bytes constructor(options = {}) { this.cache = /* @__PURE__ */ new Map(); this.totalSize = 0; var _a, _b, _c, _d; const cleanupIntervalMs = (_a = options.cleanupIntervalMs) != null ? _a : 6e4; this.maxItems = (_b = options.maxItems) != null ? _b : 1e3; this.maxMemoryBytes = (_c = options.maxMemoryBytes) != null ? _c : 50 * 1024 * 1024; this.evictionRatio = (_d = options.evictionRatio) != null ? _d : 0.1; if (cleanupIntervalMs > 0) { this.cleanupInterval = setInterval(() => { this.cleanup(); }, cleanupIntervalMs); if (typeof this.cleanupInterval.unref === "function") { this.cleanupInterval.unref(); } } } get(hash) { return __async(this, null, function* () { const item = this.cache.get(hash); if (!item || item.expiresAt > 0 && Date.now() > item.expiresAt) { this.cache.delete(hash); return void 0; } item.lastAccessed = Date.now(); this.cache.set(hash, item); return item.value; }); } set(hash, value, ttlSeconds) { return __async(this, null, function* () { const now = Date.now(); const expiresAt = ttlSeconds === 0 ? 0 : now + ttlSeconds * 1e3; const existing = this.cache.get(hash); if (existing) { this.totalSize -= existing.size; } const entrySize = this.estimateEntrySize(hash, value); this.totalSize += entrySize; this.cache.set(hash, { value, expiresAt, lastAccessed: now, size: entrySize }); this.enforceMemoryLimits(); }); } delete(hash) { return __async(this, null, function* () { const existing = this.cache.get(hash); if (existing) { this.totalSize -= existing.size; } this.cache.delete(hash); }); } clear() { return __async(this, null, function* () { this.cache.clear(); this.totalSize = 0; }); } /** * Get statistics about cache usage */ getStats() { const now = Date.now(); let expired = 0; for (const [_hash, item] of this.cache) { if (item.expiresAt > 0 && now > item.expiresAt) { expired++; } } const memoryUsageBytes = this.calculateMemoryUsage(); return { totalItems: this.cache.size, expired, memoryUsageBytes, maxItems: this.maxItems, maxMemoryBytes: this.maxMemoryBytes, memoryUtilization: memoryUsageBytes / this.maxMemoryBytes, itemUtilization: this.cache.size / this.maxItems }; } /** * Clean up expired cache entries */ cleanup() { const now = Date.now(); const toDelete = []; for (const [hash, item] of this.cache) { if (item.expiresAt > 0 && now > item.expiresAt) { toDelete.push(hash); } } for (const hash of toDelete) { const item = this.cache.get(hash); if (item) { this.totalSize -= item.size; } this.cache.delete(hash); } } /** * Enforce memory and item count limits using LRU eviction */ enforceMemoryLimits() { if (this.cache.size > this.maxItems) { this.evictLRUItems(this.cache.size - this.maxItems); return; } const memoryUsage = this.totalSize; if (memoryUsage > this.maxMemoryBytes) { const itemsToEvict = Math.max( 1, Math.floor(this.cache.size * this.evictionRatio) ); this.evictLRUItems(itemsToEvict); } } /** * Evict the least recently used items */ evictLRUItems(count) { const entries = Array.from(this.cache.entries()).sort( ([, a], [, b]) => a.lastAccessed - b.lastAccessed ); for (let i = 0; i < count && i < entries.length; i++) { const entry = entries[i]; if (entry) { const [key, item] = entry; this.totalSize -= item.size; this.cache.delete(key); } } } /** * Calculate rough memory usage */ calculateMemoryUsage() { return this.totalSize; } /** * Get items sorted by last accessed time (for debugging/monitoring) */ getLRUItems(limit = 10) { const entries = Array.from(this.cache.entries()).sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed).slice(0, limit); return entries.map(([hash, item]) => ({ hash, lastAccessed: new Date(item.lastAccessed), size: item.size })); } /** * Destroy the cache and cleanup resources */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = void 0; } this.cache.clear(); this.totalSize = 0; } /** * Estimate the size in bytes of a cache entry (key + value + metadata) */ estimateEntrySize(hash, value) { var _a; let size = 0; size += hash.length * 2; size += 16; try { const json = (_a = safeStringify(value)) != null ? _a : ""; size += json.length * 2; } catch (e) { size += 1024; } return size; } }; function deferred() { let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } var InMemoryDedupeStore = class { constructor({ /** Job timeout in milliseconds. Defaults to 5 minutes. */ jobTimeoutMs = 3e5, /** Cleanup interval in milliseconds. Defaults to 1 minute. */ cleanupIntervalMs = 6e4 } = {}) { this.jobs = /* @__PURE__ */ new Map(); this.totalJobsProcessed = 0; this.destroyed = false; this.jobTimeoutMs = jobTimeoutMs; if (cleanupIntervalMs > 0) { this.cleanupInterval = setInterval(() => { this.cleanup(); }, cleanupIntervalMs); } } waitFor(hash) { return __async(this, null, function* () { var _a; if (this.destroyed) { return void 0; } const job = this.jobs.get(hash); if (!job) { return void 0; } const jobTimedOut = this.jobTimeoutMs > 0 && Date.now() - job.createdAt > this.jobTimeoutMs; if (jobTimedOut) { this.cleanup(); return void 0; } if (job.completed) { if (job.error) { return void 0; } return (_a = job.result) != null ? _a : void 0; } try { return yield job.promise; } catch (e) { return void 0; } }); } register(hash) { return __async(this, null, function* () { const existingJob = this.jobs.get(hash); if (existingJob) { return existingJob.jobId; } const jobId = randomUUID(); const { promise, resolve, reject } = deferred(); const job = { jobId, promise, resolve, reject, createdAt: Date.now(), completed: false }; this.jobs.set(hash, job); this.totalJobsProcessed++; return jobId; }); } complete(hash, value) { return __async(this, null, function* () { const job = this.jobs.get(hash); if (job && !job.completed) { job.completed = true; job.result = value; job.resolve(value); } }); } fail(hash, error) { return __async(this, null, function* () { const job = this.jobs.get(hash); if (job && !job.completed) { job.completed = true; job.error = error; job.reject(error); } }); } isInProgress(hash) { return __async(this, null, function* () { const job = this.jobs.get(hash); if (!job) { return false; } const isJobExpired = this.jobTimeoutMs > 0 && Date.now() - job.createdAt > this.jobTimeoutMs; if (isJobExpired) { this.cleanup(); return false; } return !job.completed; }); } /** * Get statistics about current dedupe jobs */ getStats() { const now = Date.now(); let expiredJobs = 0; let oldestJobAgeMs = 0; let activeJobs = 0; for (const [_hash, job] of this.jobs) { const ageMs = now - job.createdAt; if (this.jobTimeoutMs > 0 && ageMs > this.jobTimeoutMs) { expiredJobs++; } if (ageMs > oldestJobAgeMs) { oldestJobAgeMs = ageMs; } if (!job.completed) { activeJobs++; } } return { activeJobs, totalJobsProcessed: this.totalJobsProcessed, expiredJobs, oldestJobAgeMs }; } /** * Clean up expired jobs */ cleanup() { if (this.jobTimeoutMs <= 0) { return; } const now = Date.now(); const toDelete = []; for (const [hash, job] of this.jobs) { if (now - job.createdAt > this.jobTimeoutMs) { if (!job.completed) { job.completed = true; job.error = new Error( "Job timeout: Request took too long to complete" ); job.reject(job.error); } toDelete.push(hash); } } for (const hash of toDelete) { this.jobs.delete(hash); } } /** * Clear all jobs */ clear() { for (const [_hash, job] of this.jobs) { if (!job.completed) { job.completed = true; job.error = new Error("DedupeStore cleared"); job.reject(job.error); } } this.jobs.clear(); } /** * Destroy the store and clean up resources */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = void 0; } this.clear(); this.destroyed = true; } }; var InMemoryRateLimitStore = class { constructor({ /** Global/default rate-limit config applied when a resource-specific override is not provided. */ defaultConfig = DEFAULT_RATE_LIMIT, /** Optional per-resource overrides. */ resourceConfigs = /* @__PURE__ */ new Map(), /** Cleanup interval in milliseconds. Defaults to 1 minute. */ cleanupIntervalMs = 6e4 } = {}) { this.limits = /* @__PURE__ */ new Map(); this.resourceConfigs = /* @__PURE__ */ new Map(); this.totalRequests = 0; this.defaultConfig = defaultConfig; this.resourceConfigs = resourceConfigs; if (cleanupIntervalMs > 0) { this.cleanupInterval = setInterval(() => { this.cleanup(); }, cleanupIntervalMs); } } canProceed(resource) { return __async(this, null, function* () { const config = this.resourceConfigs.get(resource) || this.defaultConfig; const info = this.getOrCreateRateLimitInfo(resource, config); this.cleanupExpiredRequests(info); return info.requests.length < info.limit; }); } record(resource) { return __async(this, null, function* () { const config = this.resourceConfigs.get(resource) || this.defaultConfig; const info = this.getOrCreateRateLimitInfo(resource, config); this.cleanupExpiredRequests(info); const now = Date.now(); info.requests.push(now); this.totalRequests++; info.resetTime = now + config.windowMs; }); } getStatus(resource) { return __async(this, null, function* () { const config = this.resourceConfigs.get(resource) || this.defaultConfig; const info = this.getOrCreateRateLimitInfo(resource, config); this.cleanupExpiredRequests(info); return { remaining: Math.max(0, info.limit - info.requests.length), resetTime: new Date(info.resetTime), limit: info.limit }; }); } reset(resource) { return __async(this, null, function* () { const info = this.limits.get(resource); if (info) { this.totalRequests = Math.max( 0, this.totalRequests - info.requests.length ); } this.limits.delete(resource); }); } getWaitTime(resource) { return __async(this, null, function* () { const config = this.resourceConfigs.get(resource) || this.defaultConfig; const info = this.getOrCreateRateLimitInfo(resource, config); this.cleanupExpiredRequests(info); if (info.limit === 0) { return Math.max(0, info.resetTime - Date.now()); } if (info.requests.length < info.limit) { return 0; } const oldestRequest = info.requests[0]; if (oldestRequest === void 0) { return 0; } const timeUntilOldestExpires = oldestRequest + config.windowMs - Date.now(); return Math.max(0, timeUntilOldestExpires); }); } /** * Set rate limit configuration for a specific resource */ setResourceConfig(resource, config) { this.resourceConfigs.set(resource, config); this.limits.delete(resource); } /** * Get rate limit configuration for a resource */ getResourceConfig(resource) { return this.resourceConfigs.get(resource) || this.defaultConfig; } /** * Get statistics for all resources */ getStats() { let activeResources = 0; let rateLimitedResources = 0; for (const [_resource, info] of this.limits) { this.cleanupExpiredRequests(info); if (info.requests.length > 0) { activeResources++; if (info.requests.length >= info.limit) { rateLimitedResources++; } } } return { totalResources: this.limits.size, activeResources, rateLimitedResources, totalRequests: this.totalRequests }; } /** * Clear all rate limit data */ clear() { this.limits.clear(); this.totalRequests = 0; } /** * Clean up expired requests for all resources */ cleanup() { for (const [_resource, info] of this.limits) { this.cleanupExpiredRequests(info); } } /** * Destroy the store and clean up resources */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = void 0; } this.clear(); } getOrCreateRateLimitInfo(resource, config) { let info = this.limits.get(resource); if (!info) { info = { requests: [], limit: config.limit, windowMs: config.windowMs, resetTime: Date.now() + config.windowMs }; this.limits.set(resource, info); } return info; } cleanupExpiredRequests(info) { const now = Date.now(); const cutoff = now - info.windowMs; const initialLength = info.requests.length; while (info.requests.length > 0 && info.requests[0] < cutoff) { info.requests.shift(); } const expiredCount = initialLength - info.requests.length; this.totalRequests = Math.max(0, this.totalRequests - expiredCount); if (info.requests.length === 0) { info.resetTime = now + info.windowMs; } } }; var AdaptiveRateLimitStore = class { constructor(options = {}) { this.activityMetrics = /* @__PURE__ */ new Map(); this.lastCapacityUpdate = /* @__PURE__ */ new Map(); this.cachedCapacity = /* @__PURE__ */ new Map(); this.capacityCalculator = new AdaptiveCapacityCalculator( options.adaptiveConfig ); } canProceed(resource, priority = "background") { return __async(this, null, function* () { const metrics = this.getOrCreateActivityMetrics(resource); const capacity = this.calculateCurrentCapacity(resource, metrics); if (priority === "background" && capacity.backgroundPaused) { return false; } const currentUserRequests = this.getCurrentUsage( metrics.recentUserRequests ); const currentBackgroundRequests = this.getCurrentUsage( metrics.recentBackgroundRequests ); if (priority === "user") { return currentUserRequests < capacity.userReserved; } else { return currentBackgroundRequests < capacity.backgroundMax; } }); } record(resource, priority = "background") { return __async(this, null, function* () { const metrics = this.getOrCreateActivityMetrics(resource); const now = Date.now(); if (priority === "user") { metrics.recentUserRequests.push(now); this.cleanupOldRequests(metrics.recentUserRequests); } else { metrics.recentBackgroundRequests.push(now); this.cleanupOldRequests(metrics.recentBackgroundRequests); } metrics.userActivityTrend = this.capacityCalculator.calculateActivityTrend( metrics.recentUserRequests ); }); } getStatus(resource) { return __async(this, null, function* () { const metrics = this.getOrCreateActivityMetrics(resource); const capacity = this.calculateCurrentCapacity(resource, metrics); const currentUserUsage = this.getCurrentUsage(metrics.recentUserRequests); const currentBackgroundUsage = this.getCurrentUsage( metrics.recentBackgroundRequests ); return { remaining: capacity.userReserved - currentUserUsage + (capacity.backgroundMax - currentBackgroundUsage), resetTime: new Date( Date.now() + this.capacityCalculator.config.monitoringWindowMs ), limit: this.getResourceLimit(resource), adaptive: { userReserved: capacity.userReserved, backgroundMax: capacity.backgroundMax, backgroundPaused: capacity.backgroundPaused, recentUserActivity: this.capacityCalculator.getRecentActivity( metrics.recentUserRequests ), reason: capacity.reason } }; }); } reset(resource) { return __async(this, null, function* () { this.activityMetrics.delete(resource); this.cachedCapacity.delete(resource); this.lastCapacityUpdate.delete(resource); }); } getWaitTime(resource, priority = "background") { return __async(this, null, function* () { const canProceed = yield this.canProceed(resource, priority); if (canProceed) { return 0; } const metrics = this.getOrCreateActivityMetrics(resource); const capacity = this.calculateCurrentCapacity(resource, metrics); if (priority === "background" && capacity.backgroundPaused) { const lastUpdate = this.lastCapacityUpdate.get(resource) || 0; const nextUpdate = lastUpdate + this.capacityCalculator.config.recalculationIntervalMs; return Math.max(0, nextUpdate - Date.now()); } const monitoringWindow = this.capacityCalculator.config.monitoringWindowMs; const requests = priority === "user" ? metrics.recentUserRequests : metrics.recentBackgroundRequests; if (requests.length === 0) { return 0; } const oldestRequest = Math.min(...requests); const waitTime = oldestRequest + monitoringWindow - Date.now(); return Math.max(0, waitTime); }); } calculateCurrentCapacity(resource, metrics) { const lastUpdate = this.lastCapacityUpdate.get(resource) || 0; const recalcInterval = this.capacityCalculator.config.recalculationIntervalMs; if (Date.now() - lastUpdate < recalcInterval) { return this.cachedCapacity.get(resource) || this.getDefaultCapacity(resource); } const totalLimit = this.getResourceLimit(resource); const capacity = this.capacityCalculator.calculateDynamicCapacity( resource, totalLimit, metrics ); this.cachedCapacity.set(resource, capacity); this.lastCapacityUpdate.set(resource, Date.now()); return capacity; } getOrCreateActivityMetrics(resource) { if (!this.activityMetrics.has(resource)) { this.activityMetrics.set(resource, { recentUserRequests: [], recentBackgroundRequests: [], userActivityTrend: "none" }); } return this.activityMetrics.get(resource); } getCurrentUsage(requests) { const oneHourAgo = Date.now() - 36e5; return requests.filter((timestamp) => timestamp > oneHourAgo).length; } cleanupOldRequests(requests) { const cutoff = Date.now() - this.capacityCalculator.config.monitoringWindowMs; while (requests.length > 0 && requests[0] < cutoff) { requests.shift(); } } getResourceLimit(_resource) { return 200; } getDefaultCapacity(resource) { const totalLimit = this.getResourceLimit(resource); return { userReserved: Math.floor(totalLimit * 0.3), backgroundMax: Math.floor(totalLimit * 0.7), backgroundPaused: false, reason: "Default capacity allocation" }; } }; export { AdaptiveRateLimitStore, InMemoryCacheStore, InMemoryDedupeStore, InMemoryRateLimitStore }; //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map