@contiva/sap-integration-suite-client
Version:
SAP Cloud Platform Integration API Client
1,116 lines • 123 kB
JavaScript
"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.