// Copyright © Aptos Foundation // SPDX-License-Identifier: Apache-2.0 /** * Maximum number of entries to keep in the cache to prevent unbounded memory growth. * When this limit is exceeded, the oldest entries are evicted. */ const MAX_CACHE_SIZE = 1000; /** * Interval in milliseconds for periodic cleanup of expired cache entries. */ const CLEANUP_INTERVAL_MS = 60000; // 1 minute /** * Minimum required key length to help ensure uniqueness. * Keys should include a namespace prefix (e.g., "module-abi-", "ledger-info-"). */ const MIN_KEY_LENGTH = 10; /** * Set of known cache key prefixes to help detect potential collisions. * Each caller should use a unique prefix. */ const knownKeyPrefixes = new Set(); /** * Validates a cache key for basic security requirements. * @param key - The cache key to validate * @throws Error if the key is invalid */ function validateCacheKey(key: string): void { if (!key || typeof key !== "string") { throw new Error("Cache key must be a non-empty string"); } if (key.length < MIN_KEY_LENGTH) { throw new Error( `Cache key "${key}" is too short. Keys should be at least ${MIN_KEY_LENGTH} characters and include a namespace prefix.`, ); } // Extract prefix (everything before the last hyphen or the first 10 chars) const prefixMatch = key.match(/^([a-z]+-[a-z]+-)/i); if (prefixMatch) { knownKeyPrefixes.add(prefixMatch[1]); } } /** * Cache entry structure with value, timestamp, and last access time for LRU eviction. */ interface CacheEntry { value: T; timestamp: number; lastAccess: number; ttlMs?: number; } /** * The global cache Map shared across all functions with LRU eviction support. * SECURITY: All cache keys should include a unique namespace prefix to prevent * collisions between different caching use cases (e.g., "module-abi-", "ledger-info-"). * @group Implementation * @category Utils */ const cache = new Map>(); /** * Track whether cleanup interval is set up. */ let cleanupIntervalId: ReturnType | null = null; /** * Sets up periodic cleanup of expired cache entries if not already running. * This prevents memory leaks from accumulating expired entries. */ function ensureCleanupInterval(): void { if (cleanupIntervalId === null) { cleanupIntervalId = setInterval(() => { const now = Date.now(); const keysToDelete: string[] = []; cache.forEach((entry, key) => { // Remove entries that have expired if (entry.ttlMs !== undefined && now - entry.timestamp > entry.ttlMs) { keysToDelete.push(key); } }); keysToDelete.forEach((key) => cache.delete(key)); // If cache is empty, stop the cleanup interval to avoid unnecessary overhead if (cache.size === 0 && cleanupIntervalId !== null) { clearInterval(cleanupIntervalId); cleanupIntervalId = null; } }, CLEANUP_INTERVAL_MS); // Ensure the interval doesn't prevent Node.js from exiting if (typeof cleanupIntervalId === "object" && "unref" in cleanupIntervalId) { cleanupIntervalId.unref(); } } } /** * Evicts the least recently used entries when cache exceeds maximum size. */ function evictIfNeeded(): void { if (cache.size >= MAX_CACHE_SIZE) { // Find and remove the least recently accessed entries (remove ~10% of cache) const entriesToRemove = Math.ceil(MAX_CACHE_SIZE * 0.1); const entries = Array.from(cache.entries()).sort((a, b) => a[1].lastAccess - b[1].lastAccess); for (let i = 0; i < entriesToRemove && i < entries.length; i += 1) { cache.delete(entries[i][0]); } } } /** * A memoize higher-order function to cache the response of an async function. * This function helps to improve performance by avoiding repeated calls to the same async function with the same arguments * within a specified time-to-live (TTL). * * Features: * - LRU eviction when cache exceeds MAX_CACHE_SIZE entries * - Periodic cleanup of expired entries to prevent memory leaks * * @param func The async function to cache the result of. * @param key The cache key used to store the result. * @param ttlMs The time-to-live in milliseconds for cached data. * @returns The cached or latest result. * @group Implementation * @category Utils */ export function memoizeAsync( func: (...args: any[]) => Promise, key: string, ttlMs?: number, ): (...args: any[]) => Promise { // Validate key format on first call to catch misuse early validateCacheKey(key); return async (...args: any[]) => { const now = Date.now(); // Check if the cached result exists and is within TTL if (cache.has(key)) { const entry = cache.get(key)!; if (ttlMs === undefined || now - entry.timestamp <= ttlMs) { // Update last access time for LRU entry.lastAccess = now; return entry.value; } // Entry expired, remove it cache.delete(key); } // If not cached or TTL expired, compute the result const result = await func(...args); // Evict old entries if needed before adding new one evictIfNeeded(); // Cache the result with a timestamp cache.set(key, { value: result, timestamp: now, lastAccess: now, ttlMs }); // Ensure cleanup is running ensureCleanupInterval(); return result; }; } /** * Caches the result of a function call to improve performance on subsequent calls with the same arguments. * * Features: * - LRU eviction when cache exceeds MAX_CACHE_SIZE entries * - Periodic cleanup of expired entries to prevent memory leaks * * @param key - The key to cache on, all accesses by this key will return the cached value. * @param func - The function whose result will be cached. * @param ttlMs - The time-to-live in milliseconds for cached data. * @returns A memoized version of the provided function that returns the cached result if available and within TTL. * @group Implementation * @category Utils */ export function memoize(func: (...args: any[]) => T, key: string, ttlMs?: number): (...args: any[]) => T { // Validate key format on first call to catch misuse early validateCacheKey(key); return (...args: any[]) => { const now = Date.now(); // Check if the cached result exists and is within TTL if (cache.has(key)) { const entry = cache.get(key)!; if (ttlMs === undefined || now - entry.timestamp <= ttlMs) { // Update last access time for LRU entry.lastAccess = now; return entry.value; } // Entry expired, remove it cache.delete(key); } // If not cached or TTL expired, compute the result const result = func(...args); // Evict old entries if needed before adding new one evictIfNeeded(); // Cache the result with a timestamp cache.set(key, { value: result, timestamp: now, lastAccess: now, ttlMs }); // Ensure cleanup is running ensureCleanupInterval(); return result; }; } /** * Clears all entries from the memoization cache. * Useful for testing or when you need to force fresh data. * @group Implementation * @category Utils */ export function clearMemoizeCache(): void { cache.clear(); } /** * Returns the current size of the memoization cache. * Useful for monitoring and debugging. * @group Implementation * @category Utils */ export function getMemoizeCacheSize(): number { return cache.size; }