UNPKG

@contiva/sap-integration-suite-client

Version:
682 lines (681 loc) 26 kB
/** * Redis-based cache manager for SAP API responses * * @module cache-manager */ import { CachedData, CacheOptions, CacheAdminStats, CacheKeyInfo } from '../types/cache'; /** * Manages caching operations with Redis */ export declare class CacheManager { private client; private isConnected; private isEnabled; private connectionString; private connectPromise; private encryptionKey; private encryptionEnabled; private connectedAt; private _memoryFallbackCache; private _memoryFallbackMaxSize; private _memoryFallbackEnabled; private maxReconnectAttempts; private reconnectDelay; private maxReconnectDelay; private currentReconnectAttempt; private isReconnecting; private _revalidationQueues; private _revalidationExecuting; private _revalidationProcessing; private _revalidationConcurrency; private _revalidationDelayMs; private _maxQueueLengthPerHost; private _queueDropStrategy; private _revalidationSessionCount; private _revalidationSessionStart; /** * 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) */ private _revalidationInProgress; /** * 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: string, enabled?: boolean, encryptionSecret?: string, maxQueueLength?: number, queueDropStrategy?: 'oldest' | 'newest' | 'warn'); /** * 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 */ private initializeEncryption; /** * Encrypts data using AES-256-GCM * * @param data - The data to encrypt * @returns Encrypted data with IV prepended */ private encrypt; /** * Decrypts data using AES-256-GCM * * @param encryptedData - The encrypted data with IV prepended * @returns Decrypted data */ private decrypt; /** * Initializes and connects to Redis * Must be called before using the cache manager */ connect(): Promise<void>; /** * Initializes the Redis client connection */ private initialize; /** * 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 */ get(key: string): Promise<CachedData | 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 */ private _setMemoryFallback; /** * 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) */ set(key: string, data: any, options: CacheOptions): Promise<void>; /** * 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: CachedData, forceRevalidate?: boolean): boolean; /** * Checks if cached data is expired * * @param cachedData - The cached data to check * @returns true if the data is expired */ isExpired(cachedData: CachedData): boolean; private _getHostnameQueue; private _getHostnameExecuting; private _processHostnameQueue; private _getTotalQueueLength; /** * 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) */ private _cleanupRevalidation; /** * 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 */ revalidateInBackground(key: string, fetchFn: () => Promise<any>, options: CacheOptions, enableDifferential?: boolean, isCollectionEndpoint?: boolean): Promise<void>; clearRevalidationQueue(): number; getQueueStatus(): { length: number; executing: number; processing: boolean; maxLength: number; perHost: Record<string, number>; }; /** * 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 */ private extractArtifactsArray; /** * Saves CachedData directly to Redis (internal helper for differential updates) * * @param key - The cache key * @param cachedData - The complete CachedData object to save */ private saveCachedData; /** * Closes the Redis connection and removes all event listeners */ close(): Promise<void>; /** * Checks if the cache manager is ready to use */ isReady(): boolean; /** * Disables the cache manager * Useful when connection fails and cache should be disabled */ disable(): void; /** * 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*'); */ findKeysByPattern(pattern: string): Promise<string[]>; /** * 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; * }); */ updatePartial(key: string, updateFn: (cachedData: CachedData) => CachedData): Promise<boolean>; /** * 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\')'); */ delete(key: string): Promise<boolean>; /** * 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*'); */ deleteByPattern(pattern: string): Promise<number>; /** * 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'); */ updateField(key: string, fieldPath: string, value: any): Promise<boolean>; /** * Updates multiple fields in a cache entry using dot-notation paths * * @param key - The cache key to update * @param updates - Object mapping field paths to values * @returns true if the update was successful, false otherwise * * @example * await cacheManager.updateFields('my-key', { * 'data.Status': 'STARTED', * 'data.DeployedBy': 'user@example.com', * 'data.DeployedOn': '2024-01-01T00:00:00Z' * }); */ updateFields(key: string, updates: Record<string, any>): Promise<boolean>; /** * Updates an item in an array within a cache entry * Finds the item by ID and updates its fields * * @param key - The cache key to update * @param arrayPath - The dot-notation path to the array (e.g. 'data.d.results' or 'data.IntegrationPackages') * @param itemId - The ID of the item to update (matches 'Id' or 'id' property) * @param updates - Object mapping field names to values to update * @returns true if the update was successful, false otherwise * * @example * await cacheManager.updateInArray('my-key', 'data.d.results', 'artifact1', { * Status: 'STARTED', * DeployedBy: 'user@example.com' * }); */ updateInArray(key: string, arrayPath: string, itemId: string, updates: Record<string, any>): Promise<boolean>; /** * Performs multiple cache updates in a single batch using Redis Pipeline * * **CRITICAL**: Uses Redis Pipeline for performance (50-100 updates = 1 roundtrip instead of 50-100) * * @param updates - Array of update operations, each containing a key and updates object * @returns Object with success and failed counts * * @example * const result = await cacheManager.batchUpdate([ * { key: 'key1', updates: { 'data.Status': 'STARTED' } }, * { key: 'key2', updates: { 'data.Status': 'STOPPED' } } * ]); * // result: { success: 2, failed: 0 } */ batchUpdate(updates: Array<{ key: string; updates: Record<string, any>; }>): Promise<{ success: number; failed: number; }>; /** * Deletes multiple cache keys in a single batch using Redis Pipeline * * **CRITICAL**: Uses Redis Pipeline for performance (50-100 deletes = 1 roundtrip instead of 50-100) * * @param keys - Array of cache keys to delete * @returns The number of keys successfully deleted * * @example * const deleted = await cacheManager.batchDelete(['key1', 'key2', 'key3']); */ batchDelete(keys: string[]): Promise<number>; /** * Gets cache statistics for admin/debug purposes * * **WARNING**: This API is expensive (uses MEMORY USAGE + SCAN) and should NOT be used in hot-path code. * Primarily intended for admin/debug/monitoring purposes. * * @returns Cache statistics including total keys, size, TTL information, etc. * * @example * const stats = await cacheManager.getStats(); * console.log(`Total keys: ${stats.totalKeys}, Total size: ${stats.totalSize} bytes`); */ getStats(): Promise<CacheAdminStats>; /** * Gets cache statistics for keys matching a specific pattern * * **WARNING**: This API is expensive (uses MEMORY USAGE + SCAN) and should not be used in hot-path code. * Primarily intended for admin/debug/monitoring purposes. * * @param pattern - The pattern to match cache keys (e.g., 'sap:*:GET:/IntegrationPackages*') * @returns Cache statistics for matching keys * * @example * const stats = await cacheManager.getStatsByPattern('sap:*:GET:/IntegrationRuntimeArtifacts*'); * console.log(`Matching keys: ${stats.totalKeys}, Total size: ${stats.totalSize} bytes`); */ getStatsByPattern(pattern: string): Promise<CacheAdminStats>; /** * Gets the health status of the cache * * @returns Health status including connection status, total keys, and uptime * * @example * const health = await cacheManager.getCacheHealth(); * console.log(`Cache is ${health.isHealthy ? 'healthy' : 'unhealthy'}, Uptime: ${health.uptime}s`); */ getCacheHealth(): Promise<{ isHealthy: boolean; connectionStatus: 'connected' | 'disconnected' | 'error'; totalKeys: number; errorRate?: number; lastError?: string; uptime: number; }>; /** * Gets cache statistics grouped by endpoint * * **WARNING**: This API is expensive (uses MEMORY USAGE + SCAN) and should not be used in hot-path code. * Primarily intended for admin/debug/monitoring purposes. * * @param endpoint - The endpoint path (e.g., '/IntegrationPackages' or '/IntegrationRuntimeArtifacts') * @returns Aggregated statistics for the endpoint * * @example * const stats = await cacheManager.getStatsByEndpoint('/IntegrationPackages'); * console.log(`Endpoint: ${stats.endpoint}, Keys: ${stats.keys}, Size: ${stats.totalSize} bytes`); */ getStatsByEndpoint(endpoint: string): Promise<{ endpoint: string; keys: number; totalSize: number; averageTtl?: number; }>; /** * Gets information about a specific cache key * * **WARNING**: This API uses MEMORY USAGE which is expensive and should NOT be used in hot-path code. * Primarily intended for admin/debug purposes. * * @param key - The cache key to get information about * @returns Information about the cache key, or null if the key doesn't exist * * @example * const info = await cacheManager.getKeyInfo('sap:hostname:GET:/IntegrationRuntimeArtifacts'); * if (info) { * console.log(`Key exists: ${info.exists}, TTL: ${info.ttl}s, Size: ${info.size} bytes`); * } */ getKeyInfo(key: string): Promise<CacheKeyInfo | null>; /** * Adds an artifact to a cache entry * * @param key - The cache key to update * @param artifact - The artifact to add * @param options - Optional configuration * @param options.arrayPath - Custom dot-notation path to the array (e.g. 'data.custom.path') * @param options.preventDuplicates - If true, prevents adding duplicate artifacts * @returns true if the artifact was added successfully, false otherwise * * @example * const success = await cacheManager.addToCache('my-key', newArtifact, { * preventDuplicates: true * }); */ addToCache(key: string, artifact: any, options?: { arrayPath?: string; preventDuplicates?: boolean; }): Promise<boolean>; /** * Removes an artifact from a cache entry * * @param key - The cache key to update * @param artifactId - The ID of the artifact to remove * @param options - Optional configuration * @param options.arrayPath - Custom dot-notation path to the array (e.g. 'data.custom.path') * @returns true if the artifact was removed successfully, false otherwise * * @example * const success = await cacheManager.removeFromCache('my-key', 'MyArtifactId'); */ removeFromCache(key: string, artifactId: string, options?: { arrayPath?: string; }): Promise<boolean>; /** * Adds multiple artifacts to cache entries in a single batch using Redis Pipeline * * **CRITICAL**: Uses Redis Pipeline for performance (50-100 updates = 1 roundtrip instead of 50-100) * * @param updates - Array of artifact addition operations, each containing a key, artifact, and optional options * @returns Object with success and failed counts * * @example * const result = await cacheManager.batchAddToCache([ * { key: 'key1', artifact: { Id: 'Artifact1' }, options: { preventDuplicates: true } }, * { key: 'key2', artifact: { Id: 'Artifact2' } } * ]); * // result: { success: 2, failed: 0 } */ batchAddToCache(updates: Array<{ key: string; artifact: any; options?: { arrayPath?: string; preventDuplicates?: boolean; }; }>): Promise<{ success: number; failed: number; }>; /** * Updates the TTL (Time-To-Live) of a cache entry * * @param key - The cache key to update * @param newTTL - The new TTL in seconds * @returns true if the TTL was updated successfully, false otherwise * * @example * const success = await cacheManager.updateTTL('my-key', 3600); // Set TTL to 1 hour */ updateTTL(key: string, newTTL: number): Promise<boolean>; /** * Extends the TTL (Time-To-Live) of a cache entry by adding additional seconds * * @param key - The cache key to update * @param additionalSeconds - The number of seconds to add to the current TTL * @returns true if the TTL was extended successfully, false otherwise * * @example * const success = await cacheManager.extendTTL('my-key', 1800); // Add 30 minutes */ extendTTL(key: string, additionalSeconds: number): Promise<boolean>; /** * Updates TTL for multiple cache keys in a single batch using Redis Pipeline * * **CRITICAL**: Uses Redis Pipeline for performance (50-100 updates = 1 roundtrip instead of 50-100) * * @param updates - Array of TTL update operations, each containing a key and new TTL * @returns Object with success and failed counts * * @example * const result = await cacheManager.batchUpdateTTL([ * { key: 'key1', ttl: 3600 }, * { key: 'key2', ttl: 7200 } * ]); * // result: { success: 2, failed: 0 } */ batchUpdateTTL(updates: Array<{ key: string; ttl: number; }>): Promise<{ success: number; failed: number; }>; /** * Warms the cache by pre-loading multiple endpoints * * @param endpoints - Array of endpoints to warm, each containing cache key, fetch function, and cache options * @param config - Optional configuration for warming behavior * @param config.timeout - Timeout per endpoint in milliseconds (default: 30000) * @param config.parallel - Whether to execute endpoints in parallel (default: true) * @returns Statistics about the warming operation (success count, failed count, duration, errors) * * @example * const result = await cacheManager.warmCache([ * { * key: 'sap:hostname:GET:/IntegrationPackages', * fetchFn: () => api.getIntegrationPackages(), * options: { ttl: 3600, revalidateAfter: 1800 } * } * ]); */ warmCache(endpoints: Array<{ key: string; fetchFn: () => Promise<any>; options?: CacheOptions; }>, config?: { timeout?: number; parallel?: boolean; }): Promise<{ success: number; failed: number; duration: number; errors?: Array<{ key: string; error: string; }>; }>; /** * Validates a single cache entry for consistency and validity * * @param key - The cache key to validate * @param options - Optional validation options * @param options.compareWithSap - Whether to compare cache data with SAP API (requires fetchFn) * @param options.fetchFn - Function to fetch fresh data from SAP API for comparison * @param options.autoRepair - Whether to automatically repair invalid entries * @returns Validation result with validity status, issues, and optional repair status * * @example * const result = await cacheManager.validateCacheEntry('my-key', { * compareWithSap: true, * fetchFn: () => api.getData(), * autoRepair: true * }); */ validateCacheEntry(key: string, options?: { compareWithSap?: boolean; fetchFn?: () => Promise<any>; autoRepair?: boolean; }): Promise<{ isValid: boolean; issues: string[]; repaired?: boolean; comparisonResult?: { matches: boolean; differences?: string[]; }; }>; /** * Validates all cache entries matching a pattern * * @param pattern - The pattern to match cache keys (supports wildcards like *) * @param options - Optional validation options * @param options.autoRepair - Whether to automatically repair invalid entries * @param options.compareWithSap - Whether to compare cache data with SAP API (requires fetchFn) * @param options.fetchFn - Function to fetch fresh data from SAP API for comparison (receives key as parameter) * @returns Validation statistics and issues per key * * @example * const result = await cacheManager.validateCacheByPattern('sap:hostname:GET:/IntegrationPackages*', { * autoRepair: true * }); */ validateCacheByPattern(pattern: string, options?: { autoRepair?: boolean; compareWithSap?: boolean; fetchFn?: (key: string) => Promise<any>; }): Promise<{ total: number; valid: number; invalid: number; repaired: number; issues: Array<{ key: string; issues: string[]; }>; }>; /** * Gets data from cache or fetches it using the provided function * Implements Stale-While-Revalidate pattern: * - If data is in cache and fresh: return immediately * - If data is in cache but stale: return immediately, revalidate in background * - If data is expired or not in cache: fetch fresh data * * @param key - The cache key * @param fetchFn - Function to fetch fresh data * @param options - Cache options (TTL and revalidation time) * @param forceRefresh - Force fetching fresh data regardless of cache state * @returns Object containing data and cache info * * @example * const result = await cacheManager.getOrFetch( * 'sap:hostname:GET:/IntegrationPackages', * async () => await api.getPackages(), * { ttl: 2592000, revalidateAfter: 300 }, * false * ); * console.log(result.data); // The actual data * console.log(result.cacheInfo); // { hit: true, age: 120, status: 'HIT' } */ getOrFetch<T>(key: string, fetchFn: () => Promise<T>, options: CacheOptions, forceRefresh?: boolean): Promise<{ data: T; cacheInfo: { hit: boolean; age: number | null; status: 'HIT' | 'HIT-STALE' | 'MISS' | 'DISABLED'; }; }>; /** * Returns health status of the cache manager and Redis connection * Useful for monitoring and failover detection * * @returns Health status object */ getHealthStatus(): { enabled: boolean; connected: boolean; reconnecting: boolean; reconnectAttempts: number; maxReconnectAttempts: number; uptime: number; connectedAt: string | null; encryptionEnabled: boolean; queueStatus: { length: number; executing: number; processing: boolean; maxLength: number; perHost: Record<string, number>; }; status: "reconnecting" | "healthy" | "degraded" | "offline"; }; /** * Gets a human-readable connection status * * @private * @returns Connection status string */ private _getConnectionStatus; }