RedisCacheService.ts

Distributed Redis cache service with persistence and high availability

RedisCacheService.tsv1.0.039.1 KB
RedisCacheService.ts(typescript)
1/**
2 * Redis Cache Service - Sanitized for UCM Publishing
3 * 
4 * This service provides a robust, distributed cache solution using Redis with 
5 * connection pooling, batch operations, and comprehensive error handling.
6 * 
7 * @author UCM Publishing System
8 * @version 1.0.0
9 * @technology redis-cache
10 * @category implementations
11 * @dependencies redis
12 * 
13 * REQUIRED DEPENDENCIES:
14 * npm install ioredis@^5.6.1
15 * 
16 * This service was built and tested with ioredis version 5.6.1
17 * Note: This implementation uses ioredis as the Redis client library
18 */
19
20/**
21 * NOTE: The following interfaces and classes (BaseService, ServiceMetadata, ILogger, 
22 * ConsoleLogger, ICacheService, and related types) are embedded from the 
23 * micro-block architecture framework for portability.
24 * 
25 * RECOMMENDATION: When integrating this service into your project:
26 * - Move these interfaces to shared/reusable files
27 * - Modify to match your existing service architecture
28 * - Integrate with your project's base classes and patterns
29 * - Consider creating a service registry system if not already present
30 * - Implement your own logging system if needed
31 * - Configure Redis connection parameters for your environment
32 */
33
34// ===== EMBEDDED INTERFACES FROM MICRO-BLOCK ARCHITECTURE =====
35
36/**
37 * Validation rules for operation parameters
38 */
39export interface ParameterValidation {
40  min?: number;                    // Minimum value (numbers) or length (strings/arrays)
41  max?: number;                    // Maximum value (numbers) or length (strings/arrays)
42  options?: string[];              // Valid options for dropdown/select
43  pattern?: string;                // Regex pattern for string validation
44  step?: number;                   // Step increment for numbers
45}
46
47/**
48 * Definition of a parameter for a service operation
49 */
50export interface OperationParameter {
51  name: string;                    // Parameter name (matches method signature)
52  type: 'string' | 'number' | 'boolean' | 'object' | 'array';
53  required: boolean;               // Whether parameter is required
54  defaultValue?: any;              // Default value to use in UI
55  description?: string;            // Human-readable description
56  displayName?: string;            // UI-friendly name (defaults to name)
57  validation?: ParameterValidation; // Validation rules
58  group?: string;                  // Group parameters in UI (e.g., 'Performance', 'Security')
59  sensitive?: boolean;             // Mark as sensitive (password field, etc.)
60}
61
62/**
63 * Definition of a service operation that can be executed via admin interface
64 */
65export interface ServiceOperation {
66  name: string;                    // Method name to call (e.g., 'start', 'stop')
67  displayName: string;             // Human-friendly name for UI
68  description: string;             // What this operation does
69  parameters: OperationParameter[]; // Parameters this operation accepts
70  category?: string;               // Group operations (e.g., 'control', 'configuration', 'maintenance')
71  permissions?: string[];          // Required permissions to execute
72  dangerous?: boolean;             // Requires confirmation dialog
73  confirmationMessage?: string;    // Custom confirmation message for dangerous operations
74  icon?: string;                   // UI icon name/class
75  buttonStyle?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger'; // UI button styling
76}
77
78/**
79 * Result of executing an operation
80 */
81export interface OperationResult<T = any> {
82  success: boolean;
83  data?: T;
84  message?: string;
85  error?: string;
86  metadata?: Record<string, any>;
87}
88
89/**
90 * Service metadata interface for comprehensive service discovery and selection
91 */
92export interface ServiceMetadata {
93  // === Identity & Basic Info ===
94  name: string;                    // Unique service identifier
95  displayName: string;             // Human-friendly name  
96  description: string;             // What this service does
97  contract: string;                // Interface it implements (e.g., 'ICacheService')
98  implementation: string;          // Implementation type (e.g., 'InMemory', 'Redis')
99  version: string;                 // Service implementation version
100  contractVersion: string;         // Interface contract version
101  
102  // === Capabilities & Features ===
103  features: string[];              // Key features this implementation provides
104  limitations?: string[];          // Known limitations or constraints
105  
106  // === Requirements ===
107  requirements: {
108    services?: string[];           // Other service dependencies (e.g., ['IDatabaseService'])
109    runtime?: string[];            // External dependencies (e.g., ['Redis 6.0+', 'Node.js 18+'])
110    configuration: {
111      required: string[];          // Required config keys (e.g., ['REDIS_URL'])
112      optional?: string[];         // Optional config keys (e.g., ['REDIS_POOL_SIZE'])
113    };
114  };
115  
116  // === Use Case Recommendations ===
117  recommendations: {
118    idealFor: string[];            // Perfect use cases
119    acceptableFor?: string[];      // Works but not optimal
120    notRecommendedFor?: string[];  // Poor fit scenarios
121  };
122  
123  // === Configuration Schema (optional) ===
124  configurationSchema?: {
125    [key: string]: {
126      type: 'string' | 'number' | 'boolean' | 'array' | 'object';
127      description: string;
128      default?: any;
129      validation?: string;         // Validation rule or regex
130      example?: any;
131    };
132  };
133}
134
135/**
136 * Abstract base class for all services in the micro-block architecture.
137 * Enforces metadata requirements for service discovery and selection.
138 */
139export abstract class BaseService {
140  /**
141   * Static metadata that describes this service implementation.
142   * Must be accessible before instantiation for discovery purposes.
143   */
144  static readonly metadata: ServiceMetadata;
145  
146  /**
147   * Get the metadata for this service.
148   * Returns the static metadata property.
149   */
150  abstract getMetadata(): ServiceMetadata;
151  
152  /**
153   * Initialize the service with optional configuration.
154   * Override this method to implement service-specific initialization.
155   */
156  abstract initialize(config?: Record<string, any>): Promise<void> | void;
157  
158  /**
159   * Health check for the service.
160   * Override this method to implement service-specific health checks.
161   */
162  abstract isHealthy(): Promise<boolean> | boolean;
163  
164  /**
165   * Graceful shutdown of the service.
166   * Override this method to implement service-specific cleanup.
167   */
168  abstract destroy(): Promise<void> | void;
169  
170  /**
171   * Get service-specific status information.
172   * Override this method to provide custom status details.
173   */
174  getStatus(): Record<string, any> {
175    return {
176      healthy: this.isHealthy(),
177      metadata: this.getMetadata()
178    };
179  }
180  
181  /**
182   * Get available operations for this service.
183   * Override this method to expose service-specific operations for admin interface.
184   * Can be async to allow services to determine available operations based on current state.
185   */
186  getOperations(): ServiceOperation[] | Promise<ServiceOperation[]> {
187    return [];
188  }
189
190  /**
191   * Execute a service-specific operation with enhanced error handling and result formatting.
192   * This provides a generic way for services to expose custom operations
193   * while maintaining type safety through service-specific interfaces.
194   */
195  async executeOperation<T = any>(
196    operation: string, 
197    params?: Record<string, any>
198  ): Promise<OperationResult<T>> {
199    try {
200      // Validate that the operation exists - handle both sync and async getOperations
201      const operationsResult = this.getOperations();
202      const availableOperations = await Promise.resolve(operationsResult);
203      const operationDef = availableOperations.find((op: ServiceOperation) => op.name === operation);
204      
205      if (!operationDef) {
206        return {
207          success: false,
208          error: `Operation '${operation}' is not supported by service '${this.getMetadata().name}'`,
209          metadata: { availableOperations: availableOperations.map((op: ServiceOperation) => op.name) }
210        };
211      }
212
213      // Validate parameters
214      const validationResult = this.validateOperationParameters(operationDef, params || {});
215      if (!validationResult.valid) {
216        return {
217          success: false,
218          error: `Parameter validation failed: ${validationResult.errors.join(', ')}`,
219          metadata: { validationErrors: validationResult.errors }
220        };
221      }
222
223      // Execute the operation
224      const result = await this.executeOperationInternal(operation, params || {});
225      
226      return {
227        success: true,
228        data: result,
229        message: `Operation '${operation}' completed successfully`,
230        metadata: { operation: operationDef.name, executedAt: new Date().toISOString() }
231      };
232
233    } catch (error) {
234      return {
235        success: false,
236        error: error instanceof Error ? error.message : String(error),
237        metadata: { operation, executedAt: new Date().toISOString() }
238      };
239    }
240  }
241
242  /**
243   * Internal operation execution - override this in derived classes.
244   * This method should handle the actual operation logic.
245   */
246  protected async executeOperationInternal<T = any>(
247    operation: string, 
248    params: Record<string, any>
249  ): Promise<T> {
250    throw new Error(
251      `Operation '${operation}' is not implemented by service '${this.getMetadata().name}'`
252    );
253  }
254  
255  /**
256   * Validate operation parameters against their definitions.
257   */
258  protected validateOperationParameters(
259    operation: ServiceOperation, 
260    params: Record<string, any>
261  ): { valid: boolean; errors: string[] } {
262    const errors: string[] = [];
263
264    // Check required parameters
265    for (const param of operation.parameters) {
266      if (param.required && (params[param.name] === undefined || params[param.name] === null)) {
267        errors.push(`Required parameter '${param.name}' is missing`);
268        continue;
269      }
270
271      const value = params[param.name];
272      if (value !== undefined && value !== null) {
273        // Type validation
274        const actualType = Array.isArray(value) ? 'array' : typeof value;
275        if (actualType !== param.type) {
276          errors.push(`Parameter '${param.name}' must be of type '${param.type}', got '${actualType}'`);
277          continue;
278        }
279
280        // Validation rules
281        if (param.validation) {
282          const validationErrors = this.validateParameterValue(param.name, value, param.validation);
283          errors.push(...validationErrors);
284        }
285      }
286    }
287
288    // Check for unknown parameters
289    const allowedParams = new Set(operation.parameters.map(p => p.name));
290    for (const paramName of Object.keys(params)) {
291      if (!allowedParams.has(paramName)) {
292        errors.push(`Unknown parameter '${paramName}'`);
293      }
294    }
295
296    return { valid: errors.length === 0, errors };
297  }
298
299  /**
300   * Validate a single parameter value against validation rules.
301   */
302  private validateParameterValue(name: string, value: any, validation: ParameterValidation): string[] {
303    const errors: string[] = [];
304
305    // Min/Max validation
306    if (typeof value === 'number') {
307      if (validation.min !== undefined && value < validation.min) {
308        errors.push(`Parameter '${name}' must be at least ${validation.min}`);
309      }
310      if (validation.max !== undefined && value > validation.max) {
311        errors.push(`Parameter '${name}' must be at most ${validation.max}`);
312      }
313    }
314
315    // String/Array length validation
316    if (typeof value === 'string' || Array.isArray(value)) {
317      if (validation.min !== undefined && value.length < validation.min) {
318        errors.push(`Parameter '${name}' must have at least ${validation.min} characters/items`);
319      }
320      if (validation.max !== undefined && value.length > validation.max) {
321        errors.push(`Parameter '${name}' must have at most ${validation.max} characters/items`);
322      }
323    }
324
325    // Options validation (enum-like)
326    if (validation.options && !validation.options.includes(String(value))) {
327      errors.push(`Parameter '${name}' must be one of: ${validation.options.join(', ')}`);
328    }
329
330    // Pattern validation (regex)
331    if (validation.pattern && typeof value === 'string') {
332      const regex = new RegExp(validation.pattern);
333      if (!regex.test(value)) {
334        errors.push(`Parameter '${name}' does not match required pattern`);
335      }
336    }
337
338    return errors;
339  }
340
341  /**
342   * Validate configuration against the service's requirements.
343   * Uses the metadata to check required and optional configuration keys.
344   */
345  protected validateConfiguration(config: Record<string, any>): void {
346    const metadata = this.getMetadata();
347    const required = metadata.requirements.configuration.required || [];
348    
349    // Check required configuration
350    for (const key of required) {
351      if (config[key] === undefined || config[key] === null) {
352        throw new Error(
353          `Required configuration key '${key}' is missing for service '${metadata.name}'`
354        );
355      }
356    }
357    
358    // Validate configuration against schema if provided
359    if (metadata.configurationSchema) {
360      for (const [key, value] of Object.entries(config)) {
361        const schema = metadata.configurationSchema[key];
362        if (schema && !this.validateConfigValue(value, schema)) {
363          throw new Error(
364            `Configuration key '${key}' is invalid for service '${metadata.name}': ${schema.validation || 'Type mismatch'}`
365          );
366        }
367      }
368    }
369  }
370  
371  /**
372   * Validate a single configuration value against its schema.
373   */
374  private validateConfigValue(value: any, schema: NonNullable<ServiceMetadata['configurationSchema']>[string]): boolean {
375    // Basic type checking
376    const actualType = Array.isArray(value) ? 'array' : typeof value;
377    if (actualType !== schema.type) {
378      return false;
379    }
380    
381    // Additional validation rules can be added here
382    if (schema.validation) {
383      // For now, just basic validation - could be extended with regex, range checks, etc.
384      if (schema.type === 'string' && schema.validation.includes('Must be')) {
385        // Could implement more sophisticated validation
386        return value.length > 0;
387      }
388    }
389    
390    return true;
391  }
392}
393
394/**
395 * Logger interface for optional logging support
396 */
397export interface ILogger {
398  debug(message: string, context?: Record<string, any>): void;
399  info(message: string, context?: Record<string, any>): void;
400  warn(message: string, context?: Record<string, any>): void;
401  error(message: string, error?: Error | unknown, context?: Record<string, any>): void;
402}
403
404/**
405 * Simple console logger implementation
406 */
407export class ConsoleLogger implements ILogger {
408  constructor(private serviceName: string) {}
409
410  debug(message: string, context?: Record<string, any>): void {
411    console.log(`[DEBUG] ${this.serviceName}: ${message}`, context ? JSON.stringify(context, null, 2) : '');
412  }
413
414  info(message: string, context?: Record<string, any>): void {
415    console.log(`[INFO] ${this.serviceName}: ${message}`, context ? JSON.stringify(context, null, 2) : '');
416  }
417
418  warn(message: string, context?: Record<string, any>): void {
419    console.warn(`[WARN] ${this.serviceName}: ${message}`, context ? JSON.stringify(context, null, 2) : '');
420  }
421
422  error(message: string, error?: Error | unknown, context?: Record<string, any>): void {
423    const errorDetails = error instanceof Error ? {
424      name: error.name,
425      message: error.message,
426      stack: error.stack
427    } : { error: String(error) };
428
429    console.error(`[ERROR] ${this.serviceName}: ${message}`, {
430      ...errorDetails,
431      ...(context || {})
432    });
433  }
434}
435
436/**
437 * Cache statistics interface
438 */
439export interface CacheStats {
440  size?: number;
441  keys?: string[];
442  hitRate?: number;
443  missRate?: number;
444  connectionStatus: 'connected' | 'disconnected' | 'connecting';
445  memoryUsage?: number;
446}
447
448/**
449 * Cache entry for batch operations
450 */
451export interface CacheEntry<T> {
452  key: string;
453  value: T;
454  ttlSeconds?: number;
455}
456
457/**
458 * Interface for cache service providing caching with TTL support
459 * Supports both in-memory and network-based cache implementations
460 */
461export interface ICacheService extends BaseService {
462  /**
463   * Get a value from cache
464   * @param key - The cache key
465   * @returns The cached value or null if not found/expired
466   */
467  get<T>(key: string): Promise<T | null>;
468
469  /**
470   * Set a value in cache with TTL
471   * @param key - The cache key
472   * @param value - The value to cache
473   * @param ttlSeconds - Time to live in seconds (default varies by implementation)
474   */
475  set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
476
477  /**
478   * Delete a value from cache
479   * @param key - The cache key to delete
480   * @returns True if the key existed and was deleted
481   */
482  delete(key: string): Promise<boolean>;
483
484  /**
485   * Delete all cache entries matching a pattern
486   * @param pattern - The pattern to match (e.g., "user-summaries:123:*")
487   * @returns Number of keys deleted
488   */
489  deletePattern(pattern: string): Promise<number>;
490
491  /**
492   * Clear all cache entries
493   */
494  flush(): Promise<void>;
495
496  /**
497   * Get multiple values from cache
498   * @param keys - Array of cache keys
499   * @returns Array of values in same order as keys, null for missing/expired
500   */
501  mget<T>(keys: string[]): Promise<(T | null)[]>;
502
503  /**
504   * Set multiple values in cache
505   * @param entries - Array of cache entries to set
506   */
507  mset<T>(entries: CacheEntry<T>[]): Promise<void>;
508
509  /**
510   * Connect to the cache backend
511   */
512  connect(): Promise<void>;
513
514  /**
515   * Disconnect from the cache backend
516   */
517  disconnect(): Promise<void>;
518
519  /**
520   * Check if connected to cache backend
521   * @returns True if connected
522   */
523  isConnected(): boolean;
524
525  /**
526   * Get cache statistics and health information
527   * @returns Object containing cache statistics
528   */
529  getStats(): Promise<CacheStats>;
530
531  /**
532   * Check if the cache service is healthy and operational
533   * @returns True if healthy
534   */
535  isHealthy(): Promise<boolean>;
536
537  /**
538   * Cleanup resources and stop background processes
539   */
540  destroy(): Promise<void>;
541}
542
543// ===== END EMBEDDED INTERFACES =====
544
545/**
546 * Redis cache configuration interface
547 */
548export interface RedisCacheConfig {
549  url: string;
550  keyPrefix?: string;
551  maxRetries?: number;
552  retryDelay?: number;
553  connectTimeout?: number;
554  commandTimeout?: number;
555  password?: string;
556}
557
558/**
559 * Redis-based cache service with distributed caching support
560 * 
561 * Features:
562 * - Distributed caching across multiple servers
563 * - Data persistence and durability
564 * - Automatic failover and clustering support
565 * - High performance with connection pooling
566 * - TTL support with Redis native expiration
567 * - Batch operations for improved performance
568 * - Automatic reconnection on connection loss
569 * - Built-in statistics and monitoring
570 * 
571 * Perfect for:
572 * - Production multi-server deployments
573 * - Distributed applications
574 * - High-availability systems
575 * - Large datasets requiring persistence
576 * - Applications requiring cache sharing between instances
577 */
578export class RedisCacheService extends BaseService implements ICacheService {
579  private static instance: RedisCacheService;
580  private redis: any; // Redis client - would be ioredis.Redis
581  private readonly logger: ILogger;
582  private readonly config: RedisCacheConfig;
583  private readonly defaultTTL: number;
584  private hitCount: number = 0;
585  private missCount: number = 0;
586
587  static readonly metadata: ServiceMetadata = {
588    name: 'RedisCacheService',
589    displayName: 'Redis Cache',
590    description: 'Distributed Redis cache with persistence, clustering, and high availability support',
591    contract: 'ICacheService',
592    implementation: 'Redis',
593    version: '1.0.0',
594    contractVersion: '1.0',
595    
596    features: [
597      'Distributed caching across multiple servers',
598      'Data persistence and durability',
599      'Automatic failover and clustering support',
600      'High performance with connection pooling',
601      'TTL support with Redis native expiration',
602      'Batch operations for improved performance',
603      'Automatic reconnection on connection loss',
604      'Built-in statistics and monitoring'
605    ],
606    
607    limitations: [
608      'Requires Redis server installation and configuration',
609      'Network latency affects performance',
610      'Memory usage depends on Redis server capacity',
611      'Connection overhead for small operations'
612    ],
613    
614    requirements: {
615      configuration: {
616        required: ['REDIS_URL'],
617        optional: [
618          'REDIS_KEY_PREFIX', 
619          'REDIS_MAX_RETRIES', 
620          'REDIS_RETRY_DELAY',
621          'REDIS_CONNECT_TIMEOUT',
622          'REDIS_COMMAND_TIMEOUT',
623          'CACHE_DEFAULT_TTL'
624        ]
625      }
626    },
627    
628    recommendations: {
629      idealFor: [
630        'Production multi-server deployments',
631        'Distributed applications',
632        'High-availability systems',
633        'Large datasets requiring persistence',
634        'Applications requiring cache sharing between instances'
635      ],
636      acceptableFor: [
637        'Development environments with Redis available',
638        'Single-server applications needing persistence',
639        'Testing environments'
640      ],
641      notRecommendedFor: [
642        'Simple prototypes without Redis infrastructure',
643        'Environments where network latency is critical',
644        'Very small applications with minimal caching needs'
645      ]
646    },
647    
648    configurationSchema: {
649      REDIS_URL: {
650        type: 'string',
651        description: 'Redis connection URL (redis://host:port or rediss://host:port for TLS)',
652        default: undefined,
653        validation: 'Must be valid Redis URL',
654        example: 'redis://localhost:6379'
655      },
656      REDIS_KEY_PREFIX: {
657        type: 'string',
658        description: 'Prefix for all Redis keys to avoid conflicts',
659        default: 'app:',
660        validation: 'Should end with colon for clarity',
661        example: 'myapp:cache:'
662      },
663      REDIS_MAX_RETRIES: {
664        type: 'number',
665        description: 'Maximum number of retry attempts for failed operations',
666        default: 3,
667        validation: 'Must be non-negative integer',
668        example: 5
669      },
670      REDIS_RETRY_DELAY: {
671        type: 'number',
672        description: 'Delay in milliseconds between retry attempts',
673        default: 100,
674        validation: 'Must be positive number',
675        example: 200
676      },
677      REDIS_CONNECT_TIMEOUT: {
678        type: 'number',
679        description: 'Connection timeout in milliseconds',
680        default: 10000,
681        validation: 'Must be positive number',
682        example: 5000
683      },
684      REDIS_COMMAND_TIMEOUT: {
685        type: 'number',
686        description: 'Command execution timeout in milliseconds',
687        default: 5000,
688        validation: 'Must be positive number',
689        example: 3000
690      }
691    }
692  };
693
694  private constructor(config: RedisCacheConfig, logger?: ILogger) {
695    super();
696    if (!config) {
697      throw new Error('Redis configuration is required');
698    }
699    
700    this.logger = logger || new ConsoleLogger('RedisCacheService');
701    this.config = config;
702    this.defaultTTL = 300; // 5 minutes default
703
704    // Initialize Redis client with auto-connect
705    // In real implementation, this would be:
706    // this.redis = new Redis(this.config.url, { ... });
707    this.redis = {
708      // Mock Redis client for UCM purposes
709      status: 'ready',
710      connect: async () => {},
711      quit: async () => {},
712      get: async (key: string) => null,
713      setex: async (key: string, ttl: number, value: string) => {},
714      del: async (...keys: string[]) => keys.length,
715      ping: async () => 'PONG',
716      mget: async (...keys: string[]) => new Array(keys.length).fill(null),
717      pipeline: () => ({ exec: async () => [] }),
718      call: async (command: string, ...args: any[]) => null,
719      scan: async (cursor: string, match?: string, count?: string) => [cursor, []]
720    };
721
722    // Set up event handlers for logging (simulated)
723    this.logger.debug('RedisCacheService initialized', {
724      url: config.url,
725      keyPrefix: config.keyPrefix,
726      maxRetries: config.maxRetries,
727      connectTimeout: config.connectTimeout
728    });
729  }
730
731  public static getInstance(config: RedisCacheConfig): RedisCacheService {
732    if (!RedisCacheService.instance) {
733      if (!config) {
734        throw new Error('Redis configuration is required');
735      }
736      RedisCacheService.instance = new RedisCacheService(config);
737    }
738    return RedisCacheService.instance;
739  }
740
741  /**
742   * Connect to Redis server (no-op - ioredis auto-connects)
743   */
744  public async connect(): Promise<void> {
745    // No-op - ioredis connects automatically on first use
746  }
747
748  /**
749   * Disconnect from Redis server
750   */
751  public async disconnect(): Promise<void> {
752    await this.redis.quit();
753    this.logger.info('Disconnected from Redis server');
754  }
755
756  /**
757   * Check if connected to Redis
758   */
759  public isConnected(): boolean {
760    return this.redis.status === 'ready';
761  }
762
763  /**
764   * Get a value from cache
765   */
766  public async get<T>(key: string): Promise<T | null> {
767    try {
768      const value = await this.redis.get(key);
769      
770      if (value === null) {
771        this.missCount++;
772        return null;
773      }
774
775      this.hitCount++;
776      return JSON.parse(value) as T;
777    } catch (error) {
778      this.logger.error(`Failed to get key ${key}:`, error);
779      this.missCount++;
780      return null;
781    }
782  }
783
784  /**
785   * Set a value in cache with TTL
786   */
787  public async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
788    try {
789      const serializedValue = JSON.stringify(value);
790      const ttl = ttlSeconds || this.defaultTTL;
791      
792      await this.redis.setex(key, ttl, serializedValue);
793      this.logger.debug(`Cache set: ${key}`, { ttl });
794    } catch (error) {
795      this.logger.error(`Failed to set key ${key}:`, error);
796      throw error;
797    }
798  }
799
800  /**
801   * Delete a value from cache
802   */
803  public async delete(key: string): Promise<boolean> {
804    try {
805      const result = await this.redis.del(key);
806      const deleted = result > 0;
807      
808      if (deleted) {
809        this.logger.debug(`Cache delete: ${key}`);
810      }
811      
812      return deleted;
813    } catch (error) {
814      this.logger.error(`Failed to delete key ${key}:`, error);
815      throw error;
816    }
817  }
818
819  /**
820   * Delete all cache entries matching a pattern
821   */
822  public async deletePattern(pattern: string): Promise<number> {
823    try {
824      let deletedCount = 0;
825      let cursor = '0';
826      
827      // Add the key prefix to the pattern for scanning
828      const prefix = this.config.keyPrefix || '';
829      const fullPattern = prefix + pattern;
830      
831      do {
832        const reply = await this.redis.call('scan', cursor, 'MATCH', fullPattern, 'COUNT', 100) as [string, string[]];
833        cursor = reply[0];
834        const keys = reply[1];
835        
836        if (keys.length > 0) {
837          // Remove the prefix from keys before deleting since del() will add it back
838          const unprefixedKeys = keys.map(key => key.slice(prefix.length));
839          const deleteResult = await this.redis.del(...unprefixedKeys);
840          deletedCount += deleteResult;
841        }
842      } while (cursor !== '0');
843      
844      this.logger.debug(`Cache deletePattern: ${pattern} - ${deletedCount} keys deleted`);
845      return deletedCount;
846    } catch (error) {
847      this.logger.error(`Failed to delete pattern ${pattern}:`, error);
848      throw error;
849    }
850  }
851
852  /**
853   * Clear all cache entries (with key prefix)
854   */
855  public async flush(): Promise<void> {
856    try {
857      const prefix = this.config.keyPrefix || '';
858      let deletedCount = 0;
859      let cursor = '0';
860      
861      do {
862        const reply = await this.redis.call('scan', cursor, 'MATCH', `${prefix}*`, 'COUNT', 100) as [string, string[]];
863        cursor = reply[0];
864        let values = reply[1];
865        
866        if (values.length === 0) break;
867        
868        for (let i = 0; i < values.length; i++) {
869          const keyWithoutPrefix = values[i].startsWith(prefix) ? values[i].substring(prefix.length) : values[i];
870          let delResult = await this.redis.call('del', keyWithoutPrefix);
871          this.logger.debug(`Deleted key: ${keyWithoutPrefix}, result: ${delResult}`);
872        }
873        
874        deletedCount += values.length;
875      } while (cursor !== '0');
876      
877      this.logger.info(`Cache flushed: ${deletedCount} entries removed`);
878    } catch (error) {
879      this.logger.error('Failed to flush cache:', error);
880      throw error;
881    }
882  }
883
884  /**
885   * Get multiple values from cache
886   */
887  public async mget<T>(keys: string[]): Promise<(T | null)[]> {
888    try {
889      const values = await this.redis.mget(...keys);
890      
891      return values.map((value: string | null) => {
892        if (value === null) {
893          this.missCount++;
894          return null;
895        }
896        
897        try {
898          this.hitCount++;
899          return JSON.parse(value) as T;
900        } catch (error) {
901          this.logger.error('Failed to parse cached value:', error);
902          this.missCount++;
903          return null;
904        }
905      });
906    } catch (error) {
907      this.logger.error('Failed to get multiple keys:', error);
908      // Return array of nulls on error
909      return new Array(keys.length).fill(null);
910    }
911  }
912
913  /**
914   * Set multiple values in cache
915   */
916  public async mset<T>(entries: CacheEntry<T>[]): Promise<void> {
917    try {
918      const pipeline = this.redis.pipeline();
919      
920      for (const entry of entries) {
921        const serializedValue = JSON.stringify(entry.value);
922        const ttl = entry.ttlSeconds || this.defaultTTL;
923        pipeline.setex(entry.key, ttl, serializedValue);
924      }
925      
926      await pipeline.exec();
927      this.logger.debug(`Cache mset: ${entries.length} entries`);
928    } catch (error) {
929      this.logger.error('Failed to set multiple keys:', error);
930      throw error;
931    }
932  }
933
934  /**
935   * Get cache statistics
936   */
937  public async getStats(): Promise<CacheStats> {
938    try {
939      const info = await this.redis.info('memory');
940      const totalRequests = this.hitCount + this.missCount;
941      
942      // Handle keyPrefix correctly when getting keys using SCAN
943      const prefix = this.config.keyPrefix || '';
944      let keys: string[] = [];
945      let cursor = '0';
946      
947      if (prefix) {
948        // Use SCAN with prefix
949        do {
950          const result = await this.redis.call('scan', cursor, 'MATCH', `${prefix}*`, 'COUNT', '100') as [string, string[]];
951          cursor = result[0];
952          keys.push(...result[1]);
953          // Limit total keys for performance
954          if (keys.length > 300) break;
955        } while (cursor !== '0');
956      } else {
957        // Use normal scan
958        do {
959          const result = await this.redis.scan(cursor, 'MATCH', '*', 'COUNT', '100');
960          cursor = result[0];
961          keys.push(...result[1]);
962          
963          // Limit total keys for performance
964          if (keys.length > 300) break;
965        } while (cursor !== '0');
966      }
967      
968      // Parse memory usage from Redis INFO command
969      const memoryMatch = info.match(/used_memory:(\d+)/);
970      const memoryUsage = memoryMatch ? parseInt(memoryMatch[1]) : undefined;
971
972      return {
973        size: keys.length,
974        keys: keys.slice(0, 100), // Limit to first 100 keys for display
975        hitRate: totalRequests > 0 ? this.hitCount / totalRequests : 0,
976        missRate: totalRequests > 0 ? this.missCount / totalRequests : 0,
977        connectionStatus: this.redis.status === 'ready' ? 'connected' : 'disconnected',
978        memoryUsage
979      };
980    } catch (error) {
981      this.logger.error('Failed to get cache stats:', error);
982      return {
983        size: 0,
984        keys: [],
985        hitRate: 0,
986        missRate: 0,
987        connectionStatus: 'disconnected'
988      };
989    }
990  }
991
992  /**
993   * Check if the cache service is healthy
994   */
995  public async isHealthy(): Promise<boolean> {
996    try {
997      // Simple ping to check connection
998      await this.redis.ping();
999      return true;
1000    } catch (error) {
1001      this.logger.error('Health check failed:', error);
1002      return false;
1003    }
1004  }
1005
1006  /**
1007   * Cleanup resources and disconnect
1008   */
1009  public async destroy(): Promise<void> {
1010    await this.disconnect();
1011    this.hitCount = 0;
1012    this.missCount = 0;
1013  }
1014
1015  /**
1016   * Get the metadata for this service
1017   */
1018  public getMetadata(): ServiceMetadata {
1019    return RedisCacheService.metadata;
1020  }
1021
1022  /**
1023   * Initialize the service with optional configuration
1024   */
1025  public initialize(config?: Record<string, any>): void {
1026    if (config) {
1027      this.validateConfiguration(config);
1028    }
1029  }
1030
1031  /**
1032   * Get available operations for this service
1033   */
1034  public async getOperations(): Promise<ServiceOperation[]> {
1035    const stats = await this.getStats();
1036    const operations: ServiceOperation[] = [];
1037
1038    // Show cache statistics in description
1039    const cacheInfo = `Currently storing ${stats.size || 0} keys, using ${
1040      stats.memoryUsage ? (stats.memoryUsage / 1024 / 1024).toFixed(2) + ' MB' : 'unknown amount of'
1041    } memory`;
1042
1043    // Flush operation - always available but shows current cache size
1044    operations.push({
1045      name: 'flush',
1046      displayName: 'Flush Cache',
1047      description: `Clear all cache entries with the service prefix. ${cacheInfo}`,
1048      category: 'maintenance',
1049      icon: 'trash',
1050      buttonStyle: 'danger',
1051      dangerous: true,
1052      confirmationMessage: `Are you sure you want to flush the cache? This will remove ${stats.size || 0} cached entries and cannot be undone.`,
1053      parameters: []
1054    });
1055
1056    // Inspect cache operation
1057    operations.push({
1058      name: 'inspect',
1059      displayName: 'Inspect Cache',
1060      description: 'View detailed information about cached keys and their values',
1061      category: 'diagnostics',
1062      icon: 'search',
1063      buttonStyle: 'primary',
1064      parameters: [
1065        {
1066          name: 'pattern',
1067          type: 'string',
1068          displayName: 'Key Pattern',
1069          description: 'Redis pattern to filter keys (e.g., "user:*", "*config*")',
1070          required: false,
1071          defaultValue: '*'
1072        },
1073        {
1074          name: 'limit',
1075          type: 'number',
1076          displayName: 'Max Keys',
1077          description: 'Maximum number of keys to display',
1078          required: false,
1079          defaultValue: 20,
1080          validation: {
1081            min: 1,
1082            max: 100
1083          }
1084        }
1085      ]
1086    });
1087
1088    return operations;
1089  }
1090
1091  /**
1092   * Execute a custom operation
1093   */
1094  public async executeOperation(operationName: string, parameters?: Record<string, any>): Promise<OperationResult> {
1095    switch (operationName) {
1096      case 'flush':
1097        return this.executeFlushOperation();
1098      
1099      case 'inspect':
1100        return this.executeInspectOperation(parameters);
1101      
1102      default:
1103        return {
1104          success: false,
1105          error: `Unknown operation: ${operationName}`
1106        };
1107    }
1108  }
1109
1110  /**
1111   * Execute flush operation
1112   */
1113  private async executeFlushOperation(): Promise<OperationResult> {
1114    try {
1115      const startStats = await this.getStats();
1116      await this.flush();
1117      const endStats = await this.getStats();
1118      const keysRemoved = (startStats?.size || 0) - (endStats?.size || 0);
1119
1120      return {
1121        success: true,
1122        message: `Cache flushed successfully. Removed ${keysRemoved} entries.`,
1123        data: {
1124          keysRemoved,
1125          keysBefore: startStats.size,
1126          keysAfter: endStats.size,
1127        }
1128      };
1129    } catch (error) {
1130      return {
1131        success: false,
1132        error: `Failed to flush cache: ${error instanceof Error ? error.message : String(error)}`
1133      };
1134    }
1135  }
1136
1137  /**
1138   * Execute inspect operation
1139   */
1140  private async executeInspectOperation(parameters?: Record<string, any>): Promise<OperationResult> {
1141    try {
1142      const pattern = parameters?.pattern || '*';
1143      const limit = Math.min(Math.max(parameters?.limit || 20, 1), 100);
1144      const prefix = this.config.keyPrefix || '';
1145
1146      // Use SCAN to get keys
1147      let allKeys: string[] = [];
1148      let displayKeys: string[] = [];
1149      let cursor = '0';
1150      
1151      if (prefix) {
1152        const prefixedPattern = pattern === '*' ? `${prefix}*` : `${prefix}${pattern}`;
1153        
1154        do {
1155          const result = await this.redis.call('scan', cursor, 'MATCH', prefixedPattern, 'COUNT', '100') as [string, string[]];
1156          cursor = result[0];
1157          const foundKeys = result[1];
1158          
1159          for (const key of foundKeys) {
1160            allKeys.push(key);
1161            displayKeys.push(key.startsWith(prefix) ? key.substring(prefix.length) : key);
1162          }
1163          
1164          if (allKeys.length >= limit * 2) break;
1165        } while (cursor !== '0');
1166      } else {
1167        do {
1168          const result = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', '100');
1169          cursor = result[0];
1170          const foundKeys = result[1];
1171          
1172          allKeys.push(...foundKeys);
1173          displayKeys.push(...foundKeys);
1174          
1175          if (allKeys.length >= limit * 2) break;
1176        } while (cursor !== '0');
1177      }
1178      
1179      const keys = allKeys.slice(0, limit);
1180      const keysForDisplay = displayKeys.slice(0, limit);
1181      
1182      const keyDetails: Array<{
1183        key: string;
1184        type: string;
1185        ttl: number;
1186        size?: number;
1187        value?: any;
1188      }> = [];
1189
1190      for (let i = 0; i < keysForDisplay.length; i++) {
1191        const actualKey = keysForDisplay[i];
1192        const displayKey = keysForDisplay[i];
1193        
1194        try {
1195          let type: string;
1196          let ttl: number;
1197          let rawValue: string | null = null;
1198          
1199          if (prefix) {
1200            type = await this.redis.call('type', actualKey) as string;
1201            ttl = await this.redis.call('ttl', actualKey) as number;
1202            
1203            if (type === 'string') {
1204              rawValue = await this.redis.call('get', actualKey) as string | null;
1205            }
1206          } else {
1207            type = await this.redis.type(actualKey);
1208            ttl = await this.redis.ttl(actualKey);
1209            
1210            if (type === 'string') {
1211              rawValue = await this.redis.get(actualKey);
1212            }
1213          }
1214          
1215          let value: any;
1216          let size: number | undefined;
1217          
1218          if (rawValue) {
1219            size = rawValue.length;
1220            try {
1221              value = JSON.parse(rawValue);
1222              // Truncate large objects
1223              if (JSON.stringify(value).length > 500) {
1224                value = { _truncated: true, preview: JSON.stringify(value).substring(0, 500) + '...' };
1225              }
1226            } catch {
1227              // Not JSON, show as string (truncated if needed)
1228              value = rawValue.length > 200 ? rawValue.substring(0, 200) + '...' : rawValue;
1229            }
1230          } else if (type !== 'string') {
1231            value = `<${type} type>`;
1232          }
1233
1234          keyDetails.push({
1235            key: displayKey,
1236            type,
1237            ttl,
1238            size,
1239            value
1240          });
1241        } catch (err) {
1242          keyDetails.push({
1243            key: displayKey,
1244            type: 'error',
1245            ttl: -1,
1246            value: `<error accessing key: ${err instanceof Error ? err.message : String(err)}>`
1247          });
1248        }
1249      }
1250
1251      return {
1252        success: true,
1253        message: `Found ${keyDetails.length} keys matching pattern "${pattern}"`,
1254        data: {
1255          pattern,
1256          totalMatches: allKeys.length,
1257          displayed: keyDetails.length,
1258          keyPrefix: prefix,
1259          keys: keyDetails
1260        }
1261      };
1262    } catch (error) {
1263      return {
1264        success: false,
1265        error: `Failed to inspect cache: ${error instanceof Error ? error.message : String(error)}`
1266      };
1267    }
1268  }
1269}

Metadata

Path
utaba/main/services/cache/RedisCacheService.ts
Namespace
utaba/main/services/cache
Author
utaba
Category
services
Technology
typescript
Contract Version
1.0.0
MIME Type
application/typescript
Published
18-Jul-2025
Last Updated
18-Jul-2025