UNPKG

@contiva/sap-integration-suite-client

Version:
1,116 lines 123 kB
"use strict"; /** * Redis-based cache manager for SAP API responses * * @module cache-manager */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.CacheManager = void 0; const redis_1 = require("redis"); const cache_config_1 = require("./cache-config"); const crypto = __importStar(require("crypto")); const cache_update_helper_1 = require("../utils/cache-update-helper"); const cache_logger_1 = require("../utils/cache-logger"); /** * Manages caching operations with Redis */ class CacheManager { /** * Creates a new CacheManager instance * * @param connectionString - Redis connection string * @param enabled - Whether caching is enabled * @param encryptionSecret - Optional secret for encrypting cache values (recommended: use OAuth client secret) * @param maxQueueLength - Maximum revalidation queue length (default: 500, increased from 100 for P2-2 fix) * @param queueDropStrategy - Strategy for handling queue overflow: 'oldest' (drop oldest tasks), 'newest' (drop newest/incoming tasks), 'warn' (only warn, no limit) */ constructor(connectionString, enabled = true, encryptionSecret, maxQueueLength = 500, queueDropStrategy = 'oldest') { this.client = null; this.isConnected = false; this.isEnabled = false; this.connectPromise = null; this.encryptionKey = null; this.encryptionEnabled = false; this.connectedAt = null; // FIX P2-3: In-Memory Fallback Cache bei Redis-Ausfall // Verhindert, dass alle Requests zu SAP gehen wenn Redis down ist this._memoryFallbackCache = new Map(); this._memoryFallbackMaxSize = 100; // Max 100 Einträge im Memory Fallback this._memoryFallbackEnabled = true; // Failover and retry configuration this.maxReconnectAttempts = 5; this.reconnectDelay = 1000; // Start with 1 second this.maxReconnectDelay = 30000; // Max 30 seconds this.currentReconnectAttempt = 0; this.isReconnecting = false; // Per-hostname rate-limiting queues for background revalidations (prevents 429 errors) this._revalidationQueues = new Map(); this._revalidationExecuting = new Map(); this._revalidationProcessing = new Map(); this._revalidationConcurrency = 1; this._revalidationDelayMs = 1000; this._maxQueueLengthPerHost = 500; this._queueDropStrategy = 'oldest'; this._revalidationSessionCount = 0; this._revalidationSessionStart = 0; /** * Tracks ongoing revalidation operations to prevent duplicate SAP API calls * * Maps cache keys to their revalidation promises. When multiple requests * arrive for the same stale cache key, subsequent requests will be skipped * while the first revalidation is in progress. * * Memory Management: * - Keys are automatically removed after revalidation completes (success or failure) * - All entries are cleared when the cache manager is closed * * @private * @since 1.x.x (Queue Deduplication Feature) */ this._revalidationInProgress = new Map(); this.connectionString = connectionString; this.isEnabled = enabled; this._maxQueueLengthPerHost = maxQueueLength; this._queueDropStrategy = queueDropStrategy; // Initialize encryption if secret is provided if (encryptionSecret) { this.initializeEncryption(encryptionSecret); } // Don't initialize in constructor - let caller await it } /** * Initializes encryption key from the provided secret * Uses PBKDF2 to derive a secure encryption key * * FIX P3-1: Salt is now tenant-specific by including a hash of the secret * This ensures different tenants (with different secrets) get different encryption keys * even if the static salt prefix is the same. * * @param secret - The secret to derive the encryption key from */ initializeEncryption(secret) { try { // Use PBKDF2 to derive a 256-bit key from the secret // FIX P3-1: Create a tenant-specific salt by combining static prefix with secret hash // This ensures: different secret → different salt → different encryption key // The secretHash is deterministic, so same secret always produces same key (needed for cache reads) const staticPrefix = 'sap-cache-encryption-v2'; // Bumped version for migration safety const secretHash = crypto.createHash('sha256').update(secret).digest('hex').substring(0, 16); const salt = `${staticPrefix}:${secretHash}`; this.encryptionKey = crypto.pbkdf2Sync(secret, salt, 100000, 32, 'sha256'); this.encryptionEnabled = true; console.log('[CacheManager] Cache encryption enabled (AES-256-GCM with tenant-specific salt)'); } catch (error) { console.error('[CacheManager] Failed to initialize encryption:', error); this.encryptionKey = null; this.encryptionEnabled = false; } } /** * Encrypts data using AES-256-GCM * * @param data - The data to encrypt * @returns Encrypted data with IV prepended */ encrypt(data) { if (!this.encryptionEnabled || !this.encryptionKey) { return data; } try { // Generate a random IV for each encryption const iv = crypto.randomBytes(16); // Create cipher with AES-256-GCM const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv); // Encrypt the data let encrypted = cipher.update(data, 'utf8', 'hex'); encrypted += cipher.final('hex'); // Get the auth tag const authTag = cipher.getAuthTag(); // Combine IV + authTag + encrypted data // Format: [IV(16 bytes)][AuthTag(16 bytes)][EncryptedData] return iv.toString('hex') + authTag.toString('hex') + encrypted; } catch (error) { console.error('[CacheManager] Encryption failed:', error); // Return unencrypted data as fallback return data; } } /** * Decrypts data using AES-256-GCM * * @param encryptedData - The encrypted data with IV prepended * @returns Decrypted data */ decrypt(encryptedData) { if (!this.encryptionEnabled || !this.encryptionKey) { return encryptedData; } try { // Extract IV (first 32 hex chars = 16 bytes) const iv = Buffer.from(encryptedData.slice(0, 32), 'hex'); // Extract auth tag (next 32 hex chars = 16 bytes) const authTag = Buffer.from(encryptedData.slice(32, 64), 'hex'); // Extract encrypted data (rest) const encrypted = encryptedData.slice(64); // Create decipher const decipher = crypto.createDecipheriv('aes-256-gcm', this.encryptionKey, iv); decipher.setAuthTag(authTag); // Decrypt the data let decrypted = decipher.update(encrypted, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } catch (error) { console.error('[CacheManager] Decryption failed:', error); // Try to return as-is (might be unencrypted legacy data) return encryptedData; } } /** * Initializes and connects to Redis * Must be called before using the cache manager */ async connect() { // If already connected, return immediately if (this.isConnected) { return; } // If a connection is already in progress, wait for it if (this.connectPromise) { return this.connectPromise; } // Otherwise, start a new connection if (this.isEnabled && this.connectionString && this.connectionString.trim().length > 0) { this.connectPromise = this.initialize() .then(() => { this.connectPromise = null; }) .catch((error) => { this.connectPromise = null; // Disable cache manager on connection failure this.disable(); throw error; }); return this.connectPromise; } // If cache is disabled or connection string is empty, don't try to connect if (!this.isEnabled) { return Promise.resolve(); } // If connection string is empty, disable cache and return if (!this.connectionString || this.connectionString.trim().length === 0) { this.disable(); return Promise.resolve(); } } /** * Initializes the Redis client connection */ async initialize() { try { // Validate connection string is not empty if (!this.connectionString || this.connectionString.trim().length === 0) { throw new Error('Redis connection string is empty or invalid'); } // Parse the connection string (Azure Redis format) // Format: host:port,password=xxx,ssl=True,abortConnect=False // Also supports: redis://host:port or rediss://host:port let connectionPart = this.connectionString.split(',')[0]; // Remove protocol prefix if present (redis:// or rediss://) connectionPart = connectionPart.replace(/^redis[s]?:\/\//, ''); if (!connectionPart || !connectionPart.includes(':')) { throw new Error(`Invalid Redis connection string format. Expected format: host:port,password=xxx,ssl=True or redis://host:port`); } const [host, port] = connectionPart.split(':'); if (!host || !host.trim() || !port || !port.trim()) { throw new Error(`Invalid Redis connection string format. Missing host or port.`); } // Validate and parse port const portNumber = parseInt(port.trim(), 10); if (isNaN(portNumber) || portNumber < 0 || portNumber > 65535) { throw new Error(`Invalid Redis port: ${port.trim()}. Port must be a number between 0 and 65535.`); } // Parse additional parameters from connection string const parts = this.connectionString.split(','); let password = ''; let ssl = false; // Check if SSL is indicated by protocol (rediss://) if (this.connectionString.startsWith('rediss://')) { ssl = true; } for (const part of parts) { if (part.startsWith('password=')) { password = part.substring('password='.length); } else if (part.startsWith('ssl=')) { ssl = part.substring('ssl='.length).toLowerCase() === 'true'; } } this.client = (0, redis_1.createClient)({ socket: { host: host.trim(), port: portNumber, tls: ssl, connectTimeout: 5000, // 5 second timeout for connection attempts reconnectStrategy: (retries) => { // Exponential backoff with max delay if (retries >= this.maxReconnectAttempts) { console.error(`[CacheManager] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`); return false; // Stop reconnecting } const delay = Math.min(this.reconnectDelay * Math.pow(2, retries), this.maxReconnectDelay); console.log(`[CacheManager] Reconnection attempt ${retries + 1}/${this.maxReconnectAttempts} in ${delay}ms...`); return delay; }, }, password, }); // Error handling with failover support this.client.on('error', (err) => { console.error('[CacheManager] Redis client error:', err); this.isConnected = false; // Don't trigger reconnection if we're already reconnecting if (!this.isReconnecting) { this.isReconnecting = true; } }); this.client.on('connect', () => { console.log('[CacheManager] Redis client connected'); this.isConnected = true; this.connectedAt = Date.now(); this.currentReconnectAttempt = 0; // Reset reconnection counter on success this.isReconnecting = false; }); this.client.on('reconnecting', () => { console.log('[CacheManager] Redis client reconnecting...'); this.isConnected = false; this.isReconnecting = true; this.currentReconnectAttempt++; }); this.client.on('disconnect', () => { console.log('[CacheManager] Redis client disconnected'); this.isConnected = false; }); this.client.on('ready', () => { console.log('[CacheManager] Redis client ready'); this.isConnected = true; this.connectedAt = Date.now(); this.currentReconnectAttempt = 0; this.isReconnecting = false; }); // Connect to Redis with timeout const connectPromise = this.client.connect(); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error('Redis connection timeout after 5 seconds')); }, 5000); }); await Promise.race([connectPromise, timeoutPromise]); } catch (error) { console.error('[CacheManager] Failed to initialize Redis:', error); this.isConnected = false; // Clean up client on error if (this.client) { try { // Remove all event listeners to prevent memory leaks this.client.removeAllListeners('error'); this.client.removeAllListeners('connect'); this.client.removeAllListeners('disconnect'); } catch (_a) { // Ignore cleanup errors } this.client = null; } // Disable cache manager if connection fails this.disable(); throw error; // Re-throw to allow caller to handle } } /** * Gets cached data by key * Falls back to in-memory cache if Redis is unavailable (P2-3 fix) * * @param key - The cache key * @returns Cached data or null if not found */ async get(key) { // FIX P2-3: Try Redis first, fallback to memory cache if (!this.isEnabled) { return null; } // Try Redis if connected if (this.isConnected && this.client) { try { const encryptedData = await this.client.get(key); if (encryptedData) { // Decrypt if encryption is enabled const data = this.decrypt(encryptedData); const cachedData = JSON.parse(data); // Also store in memory fallback for resilience if (this._memoryFallbackEnabled) { this._setMemoryFallback(key, cachedData); } return cachedData; } } catch (error) { console.error('[CacheManager] Error getting from Redis, trying memory fallback:', error); // Fall through to memory fallback } } // FIX P2-3: Fallback to memory cache when Redis unavailable if (this._memoryFallbackEnabled) { const memoryEntry = this._memoryFallbackCache.get(key); if (memoryEntry) { // Check if not expired if (Date.now() < memoryEntry.expiresAt) { if (process.env.DEBUG === 'true') { console.log(`[CacheManager] 💾 Memory fallback HIT for key: ${key.substring(0, 60)}...`); } return memoryEntry.data; } else { // Expired - remove from memory cache this._memoryFallbackCache.delete(key); } } } return null; } /** * Stores data in the in-memory fallback cache * Implements simple LRU eviction when max size is reached * * @param key - The cache key * @param data - The cached data * @private */ _setMemoryFallback(key, data) { // Simple LRU: Remove oldest entry if at max capacity if (this._memoryFallbackCache.size >= this._memoryFallbackMaxSize) { const oldestKey = this._memoryFallbackCache.keys().next().value; if (oldestKey) { this._memoryFallbackCache.delete(oldestKey); } } // Calculate expiration (use 1 hour for memory fallback, shorter than Redis TTL) const expiresAt = Date.now() + (60 * 60 * 1000); // 1 hour this._memoryFallbackCache.set(key, { data, expiresAt }); } /** * Sets cached data with options * Also stores in memory fallback for resilience (P2-3 fix) * * @param key - The cache key * @param data - The data to cache * @param options - Cache options (TTL and revalidation time) */ async set(key, data, options) { if (!this.isEnabled) { return; } // FIX P2-3: Store in memory fallback even if Redis is down // This ensures we have data to serve if Redis fails later const now = Date.now(); const cachedDataForMemory = { data, cachedAt: now, expiresAt: now + (options.ttl * 1000), revalidateAfter: now + (options.revalidateAfter * 1000), }; if (this._memoryFallbackEnabled) { this._setMemoryFallback(key, cachedDataForMemory); } // If Redis not connected, we've at least stored in memory if (!this.isConnected || !this.client) { if (process.env.DEBUG === 'true') { cache_logger_1.cacheLogger.debug('Cache set: Redis unavailable, stored in memory fallback only', 'CacheManager', { key, isEnabled: this.isEnabled, isConnected: this.isConnected, hasClient: !!this.client }); } return; } // Validate input parameters if (!key || typeof key !== 'string' || key.trim().length === 0) { cache_logger_1.cacheLogger.error('Invalid cache key provided', 'CacheManager', { key: String(key) }, new Error('Cache key must be a non-empty string')); return; } if (data === undefined || data === null) { cache_logger_1.cacheLogger.warn('Attempting to cache null or undefined data', 'CacheManager', { key }); // Continue anyway - null/undefined might be valid cache values } if (!options || typeof options.ttl !== 'number' || options.ttl <= 0) { cache_logger_1.cacheLogger.error('Invalid cache options provided', 'CacheManager', { key, options }, new Error('TTL must be a positive number')); return; } if (typeof options.revalidateAfter !== 'number' || options.revalidateAfter <= 0) { cache_logger_1.cacheLogger.error('Invalid revalidateAfter in cache options', 'CacheManager', { key, options }, new Error('revalidateAfter must be a positive number')); return; } try { const now = Date.now(); const cachedData = { data, cachedAt: now, expiresAt: now + (options.ttl * 1000), revalidateAfter: now + (options.revalidateAfter * 1000), }; const jsonData = JSON.stringify(cachedData); if (!jsonData) { cache_logger_1.cacheLogger.error('Failed to stringify cache data', 'CacheManager', { key }, new Error('JSON.stringify returned empty result')); return; } // Encrypt if encryption is enabled const encryptedData = this.encrypt(jsonData); if (!encryptedData) { cache_logger_1.cacheLogger.error('Failed to encrypt cache data', 'CacheManager', { key }, new Error('Encryption returned empty result')); return; } // Set the cache entry with TTL const result = await this.client.setEx(key, options.ttl, encryptedData); // Verify the operation was successful // Redis setEx returns 'OK' on success, but newer clients might return different values if (result !== 'OK' && result !== null && result !== undefined) { // Check if the key was actually set by verifying it exists const exists = await this.client.exists(key); if (!exists) { cache_logger_1.cacheLogger.error('Cache setEx operation may have failed - key does not exist after set', 'CacheManager', { key, setExResult: result }, new Error('Key verification failed after setEx')); return; } } if (process.env.DEBUG === 'true') { cache_logger_1.cacheLogger.debug('Successfully set cache entry', 'CacheManager', { key, ttl: options.ttl }); } } catch (error) { cache_logger_1.cacheLogger.error('Error setting cache', 'CacheManager', { key, ttl: options === null || options === void 0 ? void 0 : options.ttl }, error); // Re-throw to allow caller to handle if needed, but since return type is void, we just log } } /** * Determines if cached data should be revalidated * * @param cachedData - The cached data to check * @param forceRevalidate - Force revalidation regardless of time * @returns true if revalidation is needed */ shouldRevalidate(cachedData, forceRevalidate = false) { if (forceRevalidate) { return true; } const now = Date.now(); return now >= cachedData.revalidateAfter; } /** * Checks if cached data is expired * * @param cachedData - The cached data to check * @returns true if the data is expired */ isExpired(cachedData) { const now = Date.now(); return now >= cachedData.expiresAt; } _getHostnameQueue(hostname) { if (!this._revalidationQueues.has(hostname)) { this._revalidationQueues.set(hostname, []); } return this._revalidationQueues.get(hostname); } _getHostnameExecuting(hostname) { if (!this._revalidationExecuting.has(hostname)) { this._revalidationExecuting.set(hostname, new Set()); } return this._revalidationExecuting.get(hostname); } async _processHostnameQueue(hostname) { if (this._revalidationProcessing.get(hostname)) { return; } const executing = this._getHostnameExecuting(hostname); if (executing.size >= this._revalidationConcurrency) { return; } const queue = this._getHostnameQueue(hostname); if (queue.length === 0) { this._revalidationProcessing.set(hostname, false); return; } this._revalidationProcessing.set(hostname, true); const item = queue.shift(); if (!item) { this._revalidationProcessing.set(hostname, false); return; } if (process.env.DEBUG === 'true') { console.log(`[CacheManager] 🔄 [${hostname}] Processing queue - Remaining: ${queue.length}`); } await new Promise(resolve => setTimeout(resolve, this._revalidationDelayMs)); const promise = item.task() .then(() => { this._revalidationSessionCount++; executing.delete(promise); this._revalidationProcessing.set(hostname, false); setTimeout(() => this._processHostnameQueue(hostname).catch(() => { }), 0); }) .catch(() => { this._revalidationSessionCount++; executing.delete(promise); this._revalidationProcessing.set(hostname, false); setTimeout(() => this._processHostnameQueue(hostname).catch(() => { }), 0); }); executing.add(promise); } _getTotalQueueLength() { let total = 0; for (const queue of this._revalidationQueues.values()) { total += queue.length; } return total; } /** * Cleans up revalidation tracking after completion * * This method is called in the `finally` block of each revalidation task * to ensure the cache key is removed from `_revalidationInProgress`, * regardless of whether the revalidation succeeded or failed. * * Memory Safety: * - Guaranteed cleanup through `finally` block * - Safe to call multiple times (idempotent) * - Handles non-existent keys gracefully * * Debug Logging: * When DEBUG=true, logs the cleanup operation and the current number * of revalidations still in progress. * * @param key - The cache key that completed revalidation * @private * @since 1.x.x (Queue Deduplication Feature) */ _cleanupRevalidation(key) { if (this._revalidationInProgress.has(key)) { this._revalidationInProgress.delete(key); if (process.env.DEBUG === 'true') { console.log(`[CacheManager] 🧹 Cleaned up revalidation for key: ${key.substring(0, 80)}... (${this._revalidationInProgress.size} still in progress)`); } } } /** * Revalidates cache in the background with timeout and rate limiting * Supports differential updates for collection endpoints * * Queue Deduplication: * Prevents multiple concurrent revalidations of the same cache key. * If a revalidation for a given key is already in progress, subsequent * requests will be skipped to avoid duplicate SAP API calls. * * Example scenario: * - 50 users request the same stale artifact data simultaneously * - Without deduplication: 50 SAP API calls * - With deduplication: 1 SAP API call * * @param key - The cache key to revalidate * @param fetchFn - Function that fetches fresh data * @param options - Cache options for the new data * @param enableDifferential - Enable differential updates (only for collections) * @param isCollectionEndpoint - Whether this is a collection endpoint */ async revalidateInBackground(key, fetchFn, options, enableDifferential = false, isCollectionEndpoint = false) { if (!this.isEnabled || !this.isConnected) { return; } // Check if a revalidation for this key is already in progress if (this._revalidationInProgress.has(key)) { if (process.env.DEBUG === 'true') { console.log(`[CacheManager] ⏭️ Skipping duplicate revalidation for key: ${key.substring(0, 80)}...`); } return; // Skip adding to queue } if (this._getTotalQueueLength() === 0 && this._revalidationSessionCount === 0) { this._revalidationSessionCount = 0; this._revalidationSessionStart = Date.now(); console.log(`[CacheManager] 📥 Starting revalidation - First task: ${key.substring(0, 80)}...`); } // Add to queue instead of executing immediately const task = async () => { var _a, _b; let timeoutId = null; try { // Registriere Promise in Map BEVOR fetch startet const revalidationPromise = (async () => { const freshData = await Promise.race([ fetchFn(), new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Revalidation timeout')), cache_config_1.REVALIDATION_TIMEOUT_MS); }), ]); // Clear timeout to prevent memory leak if (timeoutId) { clearTimeout(timeoutId); } // If differential updates are enabled and this is a collection endpoint, perform differential merge if (enableDifferential && isCollectionEndpoint) { try { // Import the helper functions dynamically to avoid circular dependencies const { compareArtifacts, mergeArtifactsDifferentially } = await Promise.resolve().then(() => __importStar(require('../utils/cache-update-helper'))); // Get old cache data const oldCachedData = await this.get(key); if (oldCachedData) { // Extract artifacts arrays from both old and new data const oldArtifacts = this.extractArtifactsArray(oldCachedData.data); const newArtifacts = this.extractArtifactsArray(freshData); if (oldArtifacts && newArtifacts) { // Compare artifacts const comparison = compareArtifacts(oldArtifacts, newArtifacts); if (process.env.DEBUG === 'true') { console.log(`[CacheManager] Differential revalidation for ${key}: ` + `Added: ${comparison.added.length}, ` + `Updated: ${comparison.updated.length}, ` + `Removed: ${comparison.removed.length}, ` + `Unchanged: ${comparison.unchanged.length}`); } // Merge differentially const mergedData = mergeArtifactsDifferentially(oldCachedData, newArtifacts, comparison, { ttl: options.ttl, revalidateAfter: options.revalidateAfter, }); // Save merged data directly to Redis await this.saveCachedData(key, mergedData); if (process.env.DEBUG === 'true') { console.log(`[CacheManager] Differential revalidation successful for key: ${key}`); } return; } } } catch (diffError) { // Log error but fall back to full replacement console.warn(`[CacheManager] Differential update failed, falling back to full replacement:`, diffError); } } // Default behavior: Full replacement await this.set(key, freshData, options); if (process.env.DEBUG === 'true') { console.log(`[CacheManager] Background revalidation successful for key: ${key}`); } })(); // Registriere in Map this._revalidationInProgress.set(key, revalidationPromise); // Warte auf Abschluss await revalidationPromise; } catch (error) { if (timeoutId) { clearTimeout(timeoutId); } if (((_a = error === null || error === void 0 ? void 0 : error.message) === null || _a === void 0 ? void 0 : _a.includes('429')) || (error === null || error === void 0 ? void 0 : error.status) === 429 || ((_b = error === null || error === void 0 ? void 0 : error.response) === null || _b === void 0 ? void 0 : _b.status) === 429) { console.warn(`[CacheManager] ⚠️ Background revalidation rate limited (429) for key: ${key}`); } if (process.env.DEBUG === 'true') { console.log(`[CacheManager] Background revalidation failed for key: ${key}`, error.message); } } finally { this._cleanupRevalidation(key); } }; const keyParts = key.split(':'); const hostname = keyParts.length >= 2 ? keyParts[1] : 'default'; const queue = this._getHostnameQueue(hostname); if (this._queueDropStrategy !== 'warn' && queue.length >= this._maxQueueLengthPerHost) { if (this._queueDropStrategy === 'oldest') { queue.shift(); console.warn(`[CacheManager] ⚠️ [${hostname}] Queue limit reached, dropped oldest task`); } else if (this._queueDropStrategy === 'newest') { console.warn(`[CacheManager] ⚠️ [${hostname}] Queue limit reached, dropping new task`); return; } } queue.push({ task, key }); const totalQueueLength = this._getTotalQueueLength(); if (totalQueueLength % 100 === 0 && totalQueueLength > 0) { const hostStats = Array.from(this._revalidationQueues.entries()) .map(([h, q]) => `${h}:${q.length}`) .join(', '); console.log(`[CacheManager] 📊 Queue status - Total: ${totalQueueLength} | Per host: ${hostStats}`); } if (!this._revalidationProcessing.get(hostname)) { this._processHostnameQueue(hostname).catch(() => { }); } } clearRevalidationQueue() { let totalCleared = 0; for (const [hostname, queue] of this._revalidationQueues.entries()) { totalCleared += queue.length; this._revalidationQueues.set(hostname, []); } if (totalCleared > 0) { console.log(`[CacheManager] 🗑️ Revalidation queues cleared: ${totalCleared} tasks removed`); } return totalCleared; } getQueueStatus() { let totalLength = 0; let totalExecuting = 0; let anyProcessing = false; const perHost = {}; for (const [hostname, queue] of this._revalidationQueues.entries()) { totalLength += queue.length; perHost[hostname] = queue.length; } for (const [, executing] of this._revalidationExecuting.entries()) { totalExecuting += executing.size; } for (const [, processing] of this._revalidationProcessing.entries()) { if (processing) anyProcessing = true; } return { length: totalLength, executing: totalExecuting, processing: anyProcessing, maxLength: this._maxQueueLengthPerHost, perHost, }; } /** * Extracts artifacts array from various cache data formats * Supports: Direct array, OData v2/v4, IntegrationPackages, IntegrationRuntimeArtifacts * * @param data - The cache data to extract from * @returns Array of artifacts or null if format not recognized */ extractArtifactsArray(data) { var _a; if (!data) return null; // Format 1: Direct array if (Array.isArray(data)) { return data; } // Format 2: OData v2 format (d.results) if (((_a = data === null || data === void 0 ? void 0 : data.d) === null || _a === void 0 ? void 0 : _a.results) && Array.isArray(data.d.results)) { return data.d.results; } // Format 3: OData v4 format (value array) if ((data === null || data === void 0 ? void 0 : data.value) && Array.isArray(data.value)) { return data.value; } // Format 4: IntegrationPackages format if ((data === null || data === void 0 ? void 0 : data.IntegrationPackages) && Array.isArray(data.IntegrationPackages)) { return data.IntegrationPackages; } // Format 5: IntegrationRuntimeArtifacts format if ((data === null || data === void 0 ? void 0 : data.IntegrationRuntimeArtifacts) && Array.isArray(data.IntegrationRuntimeArtifacts)) { return data.IntegrationRuntimeArtifacts; } return null; } /** * Saves CachedData directly to Redis (internal helper for differential updates) * * @param key - The cache key * @param cachedData - The complete CachedData object to save */ async saveCachedData(key, cachedData) { if (!this.isEnabled || !this.isConnected || !this.client) { return; } try { // Calculate remaining TTL in seconds const now = Date.now(); const remainingTtl = Math.max(0, Math.ceil((cachedData.expiresAt - now) / 1000)); // Serialize and optionally encrypt const serialized = JSON.stringify(cachedData); const value = this.encrypt(serialized); // Save to Redis with TTL if (remainingTtl > 0) { await this.client.set(key, value, { EX: remainingTtl }); } else { // If TTL is 0 or negative, delete the key await this.client.del(key); } } catch (error) { console.error('[CacheManager] Error saving cached data:', error); throw error; } } /** * Closes the Redis connection and removes all event listeners */ async close() { if (this.client) { // Wait for pending revalidations to complete if (this._revalidationInProgress.size > 0) { console.log(`[CacheManager] ⏳ Waiting for ${this._revalidationInProgress.size} revalidations to complete...`); try { await Promise.race([ Promise.allSettled(Array.from(this._revalidationInProgress.values())), new Promise((resolve) => setTimeout(resolve, 5000)), ]); } catch (error) { console.warn('[CacheManager] Some revalidations did not complete before closing:', error); } this._revalidationInProgress.clear(); } // Remove all event listeners to prevent memory leaks this.client.removeAllListeners('error'); this.client.removeAllListeners('connect'); this.client.removeAllListeners('disconnect'); // Close connection if connected, with timeout if (this.isConnected || this.client.isOpen) { try { const quitPromise = this.client.quit(); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error('Redis quit timeout after 2 seconds')); }, 2000); }); await Promise.race([quitPromise, timeoutPromise]); } catch (error) { // Ignore errors during quit (connection might already be closed) console.warn('[CacheManager] Error during Redis quit (ignored):', error instanceof Error ? error.message : String(error)); } } this.isConnected = false; this.client = null; } } /** * Checks if the cache manager is ready to use */ isReady() { return this.isEnabled && this.isConnected; } /** * Disables the cache manager * Useful when connection fails and cache should be disabled */ disable() { this.isEnabled = false; } /** * Finds cache keys matching a pattern using Redis SCAN * Uses SCAN instead of KEYS for better performance in production * * @param pattern - The pattern to match (supports wildcards like *) * @returns Array of matching cache keys * * @example * const keys = await cacheManager.findKeysByPattern('sap:hostname:GET:/IntegrationRuntimeArtifacts*'); */ async findKeysByPattern(pattern) { if (!this.isEnabled || !this.isConnected || !this.client) { return []; } try { const keys = []; let cursor = 0; do { const result = await this.client.scan(cursor, { MATCH: pattern, COUNT: 100, // Scan in batches of 100 }); cursor = result.cursor; keys.push(...result.keys); } while (cursor !== 0); return keys; } catch (error) { cache_logger_1.cacheLogger.error('Error finding keys by pattern', 'CacheManager', { pattern }, error); return []; } } /** * Updates a partial cache entry by applying an update function * Reads the cache entry, applies the update function, and writes it back * * @param key - The cache key to update * @param updateFn - Function that receives the cached data and returns updated data * @returns true if update was successful, false otherwise * * @example * await cacheManager.updatePartial('my-key', (cachedData) => { * cachedData.data.status = 'updated'; * return cachedData; * }); */ async updatePartial(key, updateFn) { if (!this.isEnabled || !this.isConnected || !this.client) { return false; } try { // Read current cache entry const cachedData = await this.get(key); if (!cachedData) { cache_logger_1.cacheLogger.debug('Cache key not found for update', 'CacheManager', { key }); return false; } // Apply update function const updatedData = updateFn(cachedData); // Calculate remaining TTL const now = Date.now(); // Validate expiresAt is a valid number if (!updatedData.expiresAt || typeof updatedData.expiresAt !== 'number' || isNaN(updatedData.expiresAt)) { cache_logger_1.cacheLogger.error('Invalid expiresAt in cached data', 'CacheManager', { key, expiresAt: updatedData.expiresAt }, new Error('expiresAt must be a valid number')); return false; } // Calculate remaining TTL in seconds // Use Math.ceil instead of Math.floor to be more lenient with timing const remainingTtlMs = updatedData.expiresAt - now; const remainingTtl = Math.max(0, Math.ceil(remainingTtlMs / 1000)); // Allow updates if TTL is still valid (greater than 0) // Add 2 seconds tolerance for validation to handle race conditions and test isolation // This means we allow updates if expired by less than 2 seconds if (remainingTtl <= 0 && remainingTtlMs < -2000) { // Cache entry expired by more than 2 seconds, don't update cache_logger_1.cacheLogger.debug('Cache entry expired, skipping update', 'CacheManager', { key, expiresAt: updatedData.expiresAt, now, remainingTtlMs, remainingTtl }); return false; } // Write updated cache entry back const jsonData = JSON.stringify(updatedData); const encryptedData = this.encrypt(jsonData); await this.client.setEx(key, remainingTtl, encryptedData); cache_logger_1.cacheLogger.debug('Successfully updated cache key', 'CacheManager', { key, remainingTtl }); return true; } catch (error) { cache_logger_1.cacheLogger.error('Error updating partial cache', 'CacheManager', { key }, error); return false; } } /** * Deletes a single cache entry by key * * @param key - The cache key to delete * @returns true if the key was deleted, false if the key doesn't exist or an error occurred * * @example * const deleted = await cacheManager.delete('sap:hostname:GET:/IntegrationRuntimeArtifacts(\'artifactId\')'); */ async delete(key) { if (!this.isEnabled || !this.isConnected || !this.client) { return false; } try { const result = await this.client.del(key); // Redis DEL returns the number of keys deleted (0 or 1) const deleted = result > 0; if (deleted) { cache_logger_1.cacheLogger.debug('Cache key deleted', 'CacheManager', { key }); } return deleted; } catch (error) { cache_logger_1.cacheLogger.error('Error deleting cache key', 'CacheManager', { key }, error); return false; } } /** * Deletes all cache entries matching a pattern * Uses findKeysByPattern internally to find matching keys, then deletes them * * @param pattern - The pattern to match (supports wildcards like *) * @returns The number of keys deleted * * @example * const deletedCount = await cacheManager.deleteByPattern('sap:hostname:GET:/IntegrationRuntimeArtifacts*'); */ async deleteByPattern(pattern) { if (!this.isEnabled || !this.isConnected || !this.client) { return 0; } try { const startTime = Date.now(); // Find all keys matching the pattern const keys = await this.findKeysByPattern(pattern); if (keys.length === 0) { cache_logger_1.cacheLogger.debug('No keys found for pattern', 'CacheManager', { pattern }); return 0; } // Delete all keys in a single operation const result = await this.client.del(keys); const duration = Date.now() - startTime; // Redis DEL returns the number of keys deleted if (result > 0) { cache_logger_1.cacheLogger.info('Cache keys deleted by pattern', 'CacheManager', { pattern, keysFound: keys.length, keysDeleted: result, duration, }); } return result; } catch (error) { cache_logger_1.cacheLogger.error('Error deleting cache keys by pattern', 'CacheManager', { pattern }, error); return 0; } } /** * Updates a single field in a cache entry using a dot-notation path * * @param key - The cache key to update * @param fieldPath - The dot-notation path to the field (e.g. 'data.Status' or 'data.d.results[0].Status') * @param value - The value to set * @returns true if the update was successful, false otherwise * * @example * await cacheManager.updateField('my-key', 'data.Status', 'STARTED'); */ async updateField(key, fieldPath, value) { let updateSuccess = false; const result = await this.updatePartial(key, (cachedData) => { updateSuccess = (0, cache_update_helper_1.