UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

437 lines (436 loc) • 15.5 kB
/** * Image Cache Utility for NeuroLink * * Implements an LRU cache for downloaded images to avoid redundant URL downloads. * Addresses IMG-026: No Caching issue - same URL downloaded multiple times wasting bandwidth. * * Features: * - LRU (Least Recently Used) eviction strategy * - Configurable cache size and TTL * - Cache hit/miss metrics * - Content hash tracking for deduplication * * @module utils/imageCache */ import { createHash } from "crypto"; import { logger } from "./logger.js"; import { normalizeUrlForCache, redactUrlForError } from "./logSanitize.js"; /** * LRU Cache for downloaded images * * Uses URL as primary key and tracks content hashes for deduplication. * Implements LRU eviction and configurable TTL for memory management. */ export class ImageCache { cache = new Map(); // contentHash -> set of normalized urls whose cache entry holds that // content. A Set (not a single url) so that evicting/deleting one url // doesn't drop the dedup mapping for other urls still caching the same // content (#1213). contentHashIndex = new Map(); maxSize; ttlMs; maxImageSize; enabled; stats = { hits: 0, misses: 0, evictions: 0, expirations: 0, totalRequests: 0, }; constructor(config = {}) { // Parse configuration with environment variable overrides const envEnabled = process.env.NEUROLINK_IMAGE_CACHE_ENABLED; const envMaxSize = process.env.NEUROLINK_IMAGE_CACHE_SIZE; const envTtlMs = process.env.NEUROLINK_IMAGE_CACHE_TTL_MS; const envMaxImageSize = process.env.NEUROLINK_IMAGE_MAX_SIZE; // Check if cache is enabled (default: false) this.enabled = envEnabled !== undefined ? envEnabled.toLowerCase() === "true" : false; this.maxSize = this.parseConfigValue(envMaxSize, config.maxSize, 100, 1, 1000); this.ttlMs = this.parseConfigValue(envTtlMs, config.ttlMs, 30 * 60 * 1000, // 30 minutes 1000, // 1 second min 24 * 60 * 60 * 1000); this.maxImageSize = this.parseConfigValue(envMaxImageSize, config.maxImageSize, 10 * 1024 * 1024, // 10MB 1024, // 1KB min 100 * 1024 * 1024); logger.debug("ImageCache initialized", { enabled: this.enabled, maxSize: this.maxSize, ttlMs: this.ttlMs, maxImageSize: this.maxImageSize, }); } /** * Parse a config value with bounds checking */ parseConfigValue(envValue, configValue, defaultValue, min, max) { let value; if (envValue !== undefined) { const parsed = parseInt(envValue, 10); value = isNaN(parsed) ? defaultValue : parsed; } else if (configValue !== undefined) { value = configValue; } else { value = defaultValue; } // Apply bounds if (value < min) { logger.warn(`Cache config value ${value} below minimum ${min}, using min`); return min; } if (value > max) { logger.warn(`Cache config value ${value} above maximum ${max}, using max`); return max; } return value; } /** * Normalize URL for consistent cache key generation. * * Delegates to the shared {@link normalizeUrlForCache} — this normalized * URL is used as the in-memory Map key (see `get`/`set` below), which lives * for the process lifetime, so both tracking-param noise and presigned-URL * auth/signature secrets must be stripped consistently with * `fileDetector.cacheKeyForUrl`'s identical requirement. See * `stripSensitiveUrlParamsForCacheKey`'s doc comment for why a naive * strip-only approach (no hash suffix) would be a correctness bug, not just * a hygiene one. */ normalizeUrl(url) { return normalizeUrlForCache(url); } /** * Generate content hash from image data */ generateContentHash(data) { const buffer = typeof data === "string" ? Buffer.from(data, "base64") : data; return createHash("sha256").update(buffer).digest("hex").substring(0, 16); } /** * Check if an entry is expired based on TTL */ isExpired(entry) { return Date.now() - entry.createdAt > this.ttlMs; } /** * Check if cache is enabled */ isEnabled() { return this.enabled; } /** * Get a cached image by URL * Returns null if not found or expired */ get(url) { // Return null immediately if cache is disabled if (!this.enabled) { return null; } this.stats.totalRequests++; const normalizedUrl = this.normalizeUrl(url); const entry = this.cache.get(normalizedUrl); if (!entry) { this.stats.misses++; logger.debug("Image cache miss", { url: redactUrlForError(normalizedUrl), }); return null; } // Check TTL expiration if (this.isExpired(entry)) { this.stats.expirations++; this.delete(normalizedUrl); logger.debug("Image cache entry expired", { url: redactUrlForError(normalizedUrl), }); return null; } // Update LRU info entry.lastAccessedAt = Date.now(); entry.accessCount++; // Move to end (most recently used) - delete and re-add this.cache.delete(normalizedUrl); this.cache.set(normalizedUrl, entry); this.stats.hits++; logger.debug("Image cache hit", { url: redactUrlForError(normalizedUrl), accessCount: entry.accessCount, }); return entry; } /** * Get a cached image by content hash * Useful for deduplication when the same image is accessed via different URLs */ getByContentHash(contentHash) { const urlSet = this.contentHashIndex.get(contentHash); if (!urlSet) { return null; } // Find the first url whose cache entry is still live, pruning any url // whose entry no longer exists in `this.cache` along the way. Entries // that exist but are expired are left in the set (not "gone") - the // normal TTL/get() path is responsible for reaping those. let liveUrl = null; // Iterate a copy so reaping below can mutate the set safely. for (const url of [...urlSet]) { const entry = this.cache.get(url); if (!entry) { urlSet.delete(url); continue; } if (this.isExpired(entry)) { // Reap expired entries here too (matching get() semantics) so stale // urls don't accumulate when callers primarily use getByContentHash. this.cache.delete(url); urlSet.delete(url); continue; } if (liveUrl === null) { liveUrl = url; } } if (urlSet.size === 0) { this.contentHashIndex.delete(contentHash); } return liveUrl ? this.get(liveUrl) : null; } /** * Store an image in the cache */ set(url, dataUri, contentType, imageData) { // Skip caching if disabled if (!this.enabled) { logger.debug("Image caching disabled, skipping cache storage"); return; } const normalizedUrl = this.normalizeUrl(url); const size = imageData.length; // Skip caching if image exceeds max size if (size > this.maxImageSize) { logger.debug("Image too large to cache", { url: redactUrlForError(normalizedUrl), size, maxSize: this.maxImageSize, }); return; } // Generate content hash const contentHash = this.generateContentHash(imageData); // Check if same content already exists under a different URL const existingUrls = this.contentHashIndex.get(contentHash); if (existingUrls) { for (const existingUrl of existingUrls) { if (existingUrl === normalizedUrl) { continue; } // Content already cached under a different URL - create a shallow copy const existingEntry = this.cache.get(existingUrl); if (existingEntry && !this.isExpired(existingEntry)) { // A dedup hit still adds a cache entry for the new URL, so enforce // capacity first — otherwise many URLs sharing one content hash could // grow the cache past maxSize. while (this.cache.size >= this.maxSize && this.cache.size > 0) { this.evictOldest(); } // Create a shallow copy for the new URL to avoid shared reference issues this.cache.set(normalizedUrl, { ...existingEntry }); // Re-fetch (or re-create) the hash's URL set: the eviction above may // have removed this hash's last url, emptying the set and deleting it // from contentHashIndex — reusing the stale `existingUrls` reference // would add the new url to an orphaned set that the index no longer // points to, leaving a live entry unreachable by content hash // (the exact #1213 failure mode). Never replace an existing mapping. let hashUrls = this.contentHashIndex.get(contentHash); if (!hashUrls) { hashUrls = new Set(); this.contentHashIndex.set(contentHash, hashUrls); } hashUrls.add(normalizedUrl); logger.debug("Image cache dedup hit", { newUrl: redactUrlForError(normalizedUrl), existingUrl: redactUrlForError(existingUrl), }); return; } } } // Evict if at capacity while (this.cache.size >= this.maxSize) { this.evictOldest(); } const now = Date.now(); const entry = { dataUri, contentType, size, contentHash, createdAt: now, lastAccessedAt: now, accessCount: 1, }; this.cache.set(normalizedUrl, entry); let urlSet = this.contentHashIndex.get(contentHash); if (!urlSet) { urlSet = new Set(); this.contentHashIndex.set(contentHash, urlSet); } urlSet.add(normalizedUrl); logger.debug("Image cached", { url: redactUrlForError(normalizedUrl), size, contentHash: contentHash.substring(0, 8), cacheSize: this.cache.size, }); } /** * Delete an entry from the cache */ delete(url) { const normalizedUrl = this.normalizeUrl(url); const entry = this.cache.get(normalizedUrl); if (entry) { // Remove this URL from its content hash's URL set; only drop the // hash entry once no URL maps to that content anymore (#1213). const urlSet = this.contentHashIndex.get(entry.contentHash); if (urlSet) { urlSet.delete(normalizedUrl); if (urlSet.size === 0) { this.contentHashIndex.delete(entry.contentHash); } } this.cache.delete(normalizedUrl); return true; } return false; } /** * Evict the oldest (least recently used) entry */ evictOldest() { // Map maintains insertion order, first entry is oldest const oldestKey = this.cache.keys().next().value; if (oldestKey !== undefined) { const entry = this.cache.get(oldestKey); if (entry) { // Remove only the evicted URL from its content hash's URL set; the // hash entry is dropped only once no URL maps to it anymore // (#1213). const urlSet = this.contentHashIndex.get(entry.contentHash); if (urlSet) { urlSet.delete(oldestKey); if (urlSet.size === 0) { this.contentHashIndex.delete(entry.contentHash); } } } this.cache.delete(oldestKey); this.stats.evictions++; logger.debug("Image cache eviction", { url: redactUrlForError(String(oldestKey)), }); } } /** * Clear all expired entries */ evictExpired() { let evicted = 0; const now = Date.now(); for (const [url, entry] of this.cache.entries()) { if (now - entry.createdAt > this.ttlMs) { this.delete(url); evicted++; this.stats.expirations++; } } if (evicted > 0) { logger.debug(`Evicted ${evicted} expired image cache entries`); } return evicted; } /** * Clear all entries from the cache */ clear() { const size = this.cache.size; this.cache.clear(); this.contentHashIndex.clear(); logger.debug(`Image cache cleared (${size} entries removed)`); } /** * Get cache statistics */ getStats() { let totalBytes = 0; for (const entry of this.cache.values()) { totalBytes += entry.size; } const hitRate = this.stats.totalRequests > 0 ? Number(((this.stats.hits / this.stats.totalRequests) * 100).toFixed(2)) : 0; return { ...this.stats, size: this.cache.size, totalBytes, hitRate, }; } /** * Check if a URL is cached and not expired */ has(url) { const normalizedUrl = this.normalizeUrl(url); const entry = this.cache.get(normalizedUrl); return entry !== null && entry !== undefined && !this.isExpired(entry); } /** * Get the current cache size */ getSize() { return this.cache.size; } /** * Get cache configuration */ getConfig() { return { enabled: this.enabled, maxSize: this.maxSize, ttlMs: this.ttlMs, maxImageSize: this.maxImageSize, }; } } // Global image cache instance let globalImageCache = null; /** * Get the global image cache instance * Creates a new instance if none exists */ export function getImageCache(config) { if (!globalImageCache) { globalImageCache = new ImageCache(config); } return globalImageCache; } /** * Reset the global image cache (useful for testing) */ export function resetImageCache() { if (globalImageCache) { globalImageCache.clear(); globalImageCache = null; } } /** * Get image cache statistics from the global instance */ export function getImageCacheStats() { return globalImageCache ? globalImageCache.getStats() : null; }