@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
JavaScript
/**
* 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;
}