UNPKG

@jonaskahn/maestro

Version:

Job orchestration made simple for Node.js message workflows

539 lines (477 loc) 16.7 kB
/** * @license * Copyleft (c) 2025 Jonas Kahn. All rights are not reserved. * * This source code is licensed under the MIT License found in the * LICENSE file in the root directory of this source tree. * * Abstract Cache Layer Base Class for cache implementation providers * * Provides unified interface for key-value operations across Redis, Memcached, in-memory cache * with job orchestration features including processing state management and message tracking. * This class must be extended by specific cache implementations. */ const logger = require("../services/logger-service"); const TtlConfig = require("../config/ttl-config"); const TopicConfig = TtlConfig.getTopicConfig(); const KEY_SUFFIXES = { PROCESSING: "_PROCESSING:", SUPPRESSION: "_SUPPRESSION:", }; const CACHE_OPERATIONS = { SET: "Set key", GET: "Get key", DELETE: "Delete key", SET_IF_NOT_EXISTS: "Set key if not exists", SET_EXPIRY: "Set expiry", SET_PROCESSING: "Set processing key", REMOVE_PROCESSING: "Removed processing key", SET_SUPPRESSION: "Set suppression key", }; const CONNECTION_STATES = { CONNECTED: true, DISCONNECTED: false, }; const ENV_KEYS = { CACHE_KEY_PREFIX: ["MO_CACHE_KEY_PREFIX"], PROCESSING_SUFFIX: ["MO_CACHE_KEY_SUFFIXES_PROCESSING"], SUPPRESSION_SUFFIX: ["MO_CACHE_KEY_SUFFIXES_SUPPRESSION"], }; /** * Abstract Cache Layer Base Class * * Provides unified interface for key-value operations across different cache providers * with job orchestration features including processing state management and message tracking. */ class AbstractCache { _isConnected; /** * Creates a cache instance with configuration validation and initialization * * @param {Object} config - Cache configuration object * @param {string} config.keyPrefix - Required prefix for all cache keys * @param {number} [config.processingTtl] - TTL for processing state keys (defaults to CacheConfig value) * @param {number} [config.suppressionTtl] - TTL for suppression keys (defaults to 3x processing TTL) * @param {Object} [config.connectionOptions] - Cache provider-specific connection options * @param {Object} [config.retryOptions] - Options for connection retry behavior * @param {string} [config.implementation] - Cache implementation type identifier */ constructor(config) { if (this.constructor === AbstractCache) { throw new Error("AbstractCache cannot be instantiated directly"); } if (!config || typeof config !== "object") { throw new Error("Configuration must be an object"); } if (!this.isNonEmptyString(config.keyPrefix)) { throw new Error("keyPrefix is required and must be a non-empty string"); } this.implementation = config.implementation; const processingKeySuffix = this.getEnvironmentValue(ENV_KEYS.PROCESSING_SUFFIX) || KEY_SUFFIXES.PROCESSING; const freezingKeySuffix = this.getEnvironmentValue(ENV_KEYS.SUPPRESSION_SUFFIX) || KEY_SUFFIXES.SUPPRESSION; this.config = { processingPrefix: `${config.keyPrefix}${processingKeySuffix}`, suppressionPrefix: `${config.keyPrefix}${freezingKeySuffix}`, processingTtl: config.processingTtl || TopicConfig.processingTtl, suppressionTtl: config.suppressionTtl || config.processingTtl * 3 || TopicConfig.suppressionTtl, connectionOptions: config.connectionOptions || {}, retryOptions: config.retryOptions || {}, }; logger.logDebug("Cache configuration", { implementation: this.implementation, processingTtl: this.config.processingTtl, suppressionTtl: this.config.suppressionTtl, }); this._isConnected = CONNECTION_STATES.DISCONNECTED; } isNonEmptyString(value) { return typeof value === "string" && value.trim().length > 0; } getEnvironmentValue(keys) { for (const key of keys) { const value = process.env[key]; if (value !== undefined) { return value; } } return null; } /** * Establishes connection to the cache implementation * @returns {Promise<void>} */ async connect() { if (await this._checkExistingConnection()) { this._isConnected = CONNECTION_STATES.CONNECTED; logger.logDebug(`Using existing connection to ${this.implementation} cache`); return; } try { await this._connectTo(); this._isConnected = CONNECTION_STATES.CONNECTED; } catch (error) { this._isConnected = CONNECTION_STATES.DISCONNECTED; logger.logError(`Failed to connect to ${this.implementation} cache`, error); throw error; } } /** * Checks if a connection already exists * @returns {Promise<boolean>} */ async _checkExistingConnection() { throw new Error("_checkExistingConnection method must be implemented by subclass"); } /** * Establishes connection to the specific cache implementation * @returns {Promise<void>} */ async _connectTo() { throw new Error("_connectTo method must be implemented by subclass"); } /** * Disconnects from the cache implementation * @returns {Promise<void>} */ async disconnect() { if (this.isConnected()) { try { await this._disconnectFrom(); this._isConnected = CONNECTION_STATES.DISCONNECTED; } catch (error) { logger.logWarning(`Error disconnecting from ${this.implementation} cache`, error); this._isConnected = CONNECTION_STATES.DISCONNECTED; } } } /** * Checks if the cache is currently connected * @returns {boolean} */ isConnected() { return this._isConnected === CONNECTION_STATES.CONNECTED; } /** * Checks if the cache is currently disconnected * @returns {boolean} */ isDisconnected() { return this._isConnected === CONNECTION_STATES.DISCONNECTED; } /** * Disconnects from the specific cache implementation * @returns {Promise<void>} */ async _disconnectFrom() { throw new Error("_disconnectFrom method must be implemented by subclass"); } /** * Validates key and value for storage operations * @param {string} key - Key to validate * @param {any} value - Value to validate * @throws {Error} When validation fails */ validateKeyValue(key, value) { if (!this.isNonEmptyString(key)) { throw new Error("Key must be a non-empty string"); } if (value === undefined || value === null) { throw new Error("Value cannot be undefined or null"); } } /** * Ensures the cache is connected before operations * @throws {Error} When cache is not connected */ ensureConnected() { if (this.isDisconnected()) { throw new Error("Cache is not connected"); } } /** * Logs key operations for debugging * @param {string} operation - Operation being performed * @param {string} key - Key being operated on * @param {number} ttl - Time-to-live value in milliseconds */ logKeyOperation(operation, key, ttl = null) { const logData = { key, operation }; if (ttl) { logData.ttl = ttl; } logger.logDebug(`${operation}: ${key}`, logData); } /** * Sets a value in the cache * @param {string} key - Key to set * @param {string|Object} value - Value to store * @param {number} ttlMs - TTL in milliseconds * @returns {Promise<boolean>} Success indicator */ async set(key, value, ttlMs) { this.validateKeyValue(key, value); this.ensureConnected(); this.logKeyOperation(CACHE_OPERATIONS.SET, key, ttlMs); return await this._setKeyValue(key, value, ttlMs); } /** * Implementation-specific method to set a value * @param {string} key - Key to set * @param {string|Object} value - Value to store * @param {number} ttlMs - TTL in milliseconds * @returns {Promise<boolean>} Success indicator */ async _setKeyValue(key, value, ttlMs) { throw new Error("_setKeyValue method must be implemented by subclass"); } /** * Gets a value from the cache * @param {string} key - Key to retrieve * @returns {Promise<any>} Retrieved value or null */ async get(key) { if (!this.isNonEmptyString(key)) { throw new Error("Key must be a non-empty string"); } this.ensureConnected(); this.logKeyOperation(CACHE_OPERATIONS.GET, key); return await this._getKeyValue(key); } /** * Implementation-specific method to get a value * @param {string} _key - Key to retrieve * @returns {Promise<any>} Retrieved value or null */ async _getKeyValue(_key) { throw new Error("_getKeyValue method must be implemented by subclass"); } /** * Deletes a key from the cache * @param {string} key - Key to delete * @returns {Promise<boolean>} Success indicator */ async del(key) { if (!this.isNonEmptyString(key)) { throw new Error("Key must be a non-empty string"); } this.ensureConnected(); this.logKeyOperation(CACHE_OPERATIONS.DELETE, key); return await this._deleteKey(key); } /** * Implementation-specific method to delete a key * @param {string} _key - Key to delete * @returns {Promise<boolean>} Success indicator */ async _deleteKey(_key) { throw new Error("_deleteKey method must be implemented by subclass"); } /** * Sets a value only if the key doesn't exist * @param {string} key - Key to set * @param {string|Object} value - Value to store * @param {number} ttlMs - TTL in milliseconds * @returns {Promise<boolean>} True if set, false if key exists */ async setIfNotExists(key, value, ttlMs) { this.validateKeyValue(key, value); this.ensureConnected(); this.logKeyOperation(CACHE_OPERATIONS.SET_IF_NOT_EXISTS, key, ttlMs); return await this._setKeyIfNotExists(key, value, ttlMs); } /** * Implementation-specific method to set a value if key doesn't exist * @param {string} _key - Key to set * @param {string|Object} _value - Value to store * @param {number} _ttlMs - TTL in milliseconds * @returns {Promise<boolean>} True if set, false if key exists */ async _setKeyIfNotExists(_key, _value, _ttlMs) { throw new Error("_setKeyIfNotExists method must be implemented by subclass"); } /** * Checks if a key exists in the cache * @param {string} key - Key to check * @returns {Promise<boolean>} True if exists */ async exists(key) { if (!this.isNonEmptyString(key)) { throw new Error("Key must be a non-empty string"); } this.ensureConnected(); return await this._checkKeyExists(key); } /** * Implementation-specific method to check if a key exists * @param {string} _key - Key to check * @returns {Promise<boolean>} True if exists */ async _checkKeyExists(_key) { throw new Error("_checkKeyExists method must be implemented by subclass"); } /** * Sets or updates the expiration for a key * @param {string} key - Key to update * @param {number} ttlMs - New TTL in milliseconds * @returns {Promise<boolean>} Success indicator */ async expire(key, ttlMs) { if (!this.isNonEmptyString(key)) { throw new Error("Key must be a non-empty string"); } if (typeof ttlMs !== "number" || ttlMs <= 0) { throw new Error("TTL must be a positive number"); } this.ensureConnected(); this.logKeyOperation(CACHE_OPERATIONS.SET_EXPIRY, key, ttlMs); return await this._setKeyExpiry(key, ttlMs); } /** * Implementation-specific method to set key expiry * @param {string} _key - Key to update * @param {number} _ttlMs - TTL in milliseconds * @returns {Promise<boolean>} Success indicator */ async _setKeyExpiry(_key, _ttlMs) { throw new Error("_setKeyExpiry method must be implemented by subclass"); } /** * Finds keys matching a pattern * @param {string} pattern - Pattern to match * @returns {Promise<string[]>} Matching keys */ async keys(pattern) { if (!this.isNonEmptyString(pattern)) { throw new Error("Pattern must be a non-empty string"); } this.ensureConnected(); const keys = await this._findKeysByPattern(pattern); logger.logDebug(`Found ${keys.length} keys matching pattern: ${pattern}`); return keys; } /** * Implementation-specific method to find keys by pattern * @param {string} _pattern - Pattern to match * @returns {Promise<string[]>} Matching keys */ async _findKeysByPattern(_pattern) { throw new Error("_findKeysByPattern method must be implemented by subclass"); } /** * Marks an item as being processed * @param {string} itemId - Item identifier * @returns {Promise<boolean>} Success indicator */ async markAsProcessing(itemId) { if (!this.isNonEmptyString(itemId)) { throw new Error("Item ID must be a non-empty string"); } this.ensureConnected(); const key = `${this.config.processingPrefix}${itemId}`; return await this.setIfNotExists(key, itemId, this.config.processingTtl); } /** * Marks an item as completed processing * @param {string} itemId - Item identifier * @returns {Promise<boolean>} Success indicator */ async clearProcessingState(itemId) { if (!this.isNonEmptyString(itemId)) { throw new Error("Item ID must be a non-empty string"); } this.ensureConnected(); const key = `${this.config.processingPrefix}${itemId}`; this.logKeyOperation(CACHE_OPERATIONS.DELETE, key); return await this.del(key); } /** * Marks an item as suppressed to prevent duplicate processing * @param {string} itemId - Item identifier * @returns {Promise<boolean>} Success indicator */ async markAsSuppressed(itemId) { if (!this.isNonEmptyString(itemId)) { throw new Error("Item ID must be a non-empty string"); } this.ensureConnected(); const key = `${this.config.suppressionPrefix}${itemId}`; return await this.setIfNotExists(key, itemId, this.config.suppressionTtl); } async clearSuppressionState(itemId) { if (!this.isNonEmptyString(itemId)) { throw new Error("Item ID must be a non-empty string"); } this.ensureConnected(); const key = `${this.config.suppressionPrefix}${itemId}`; this.logKeyOperation(CACHE_OPERATIONS.DELETE, key); return await this.del(key); } /** * Checks if an item was processed recently * @param {string} itemId - Item identifier * @returns {Promise<boolean>} True if suppressed recently */ async isSuppressedRecently(itemId) { if (!this.isNonEmptyString(itemId)) { throw new Error("Item ID must be a non-empty string"); } this.ensureConnected(); const suppressedKey = `${this.config.suppressionPrefix}${itemId}`; return await this.exists(suppressedKey); } /** * Gets IDs of all items currently being processed * @returns {Promise<string[]>} Processing item IDs */ async getProcessingIds() { return await this.getIdsByPrefix("processing", this.config.processingPrefix); } /** * Gets IDs by key prefix * @param {string} typeName - Type name for logging * @param {string} keyPrefix - Key prefix to search * @returns {Promise<string[]>} Matching IDs */ async getIdsByPrefix(typeName, keyPrefix) { try { const pattern = `${keyPrefix}*`; const keys = await this.keys(pattern); const ids = keys.map(key => key.substring(keyPrefix.length)); logger.logDebug(`Found ${ids.length} ${typeName} IDs`); return ids; } catch (error) { logger.logWarning(`Failed to retrieve ${typeName} IDs`, error); return []; } } /** * Gets IDs of all suppressed items * @returns {Promise<string[]>} Suppressed item IDs */ async getSuppressedIds() { return await this.getIdsByPrefix("suppressed", this.config.suppressionPrefix); } /** * Extends TTL for a processing item * @param {string} itemId - Item identifier * @param {number} ttlMs - New TTL in milliseconds * @returns {Promise<boolean>} Success indicator */ async extendProcessingTtl(itemId, ttlMs) { if (!this.isNonEmptyString(itemId)) { throw new Error("Item ID must be a non-empty string"); } if (typeof ttlMs !== "number" || ttlMs <= 0) { throw new Error("TTL must be a positive number"); } this.ensureConnected(); const key = `${this.config.processingPrefix}${itemId}`; if (!(await this.exists(key))) { logger.logWarning(`Cannot extend TTL for non-existent processing key: ${key}`); return false; } logger.logDebug(`Extending TTL for processing key: ${key} to ${ttlMs}ms`); return await this.expire(key, ttlMs); } } module.exports = AbstractCache;