@comic-vine/in-memory-store
Version:
In-memory store implementations for Comic Vine client caching, deduplication, and rate limiting
726 lines (721 loc) • 21.5 kB
JavaScript
'use strict';
var safeStringify = require('fast-safe-stringify');
var crypto = require('crypto');
var client = require('@comic-vine/client');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var safeStringify__default = /*#__PURE__*/_interopDefault(safeStringify);
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__default.default(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 = crypto.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 = client.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 client.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"
};
}
};
exports.AdaptiveRateLimitStore = AdaptiveRateLimitStore;
exports.InMemoryCacheStore = InMemoryCacheStore;
exports.InMemoryDedupeStore = InMemoryDedupeStore;
exports.InMemoryRateLimitStore = InMemoryRateLimitStore;
//# sourceMappingURL=index.cjs.map
//# sourceMappingURL=index.cjs.map