UNPKG

idempotency-redis

Version:
243 lines (242 loc) 13.6 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.IdempotentExecutor = void 0; const redlock_1 = __importDefault(require("redlock")); const serialization_1 = require("./serialization"); const cache_1 = require("./cache"); const executor_errors_1 = require("./executor.errors"); /** * Wraps an error that is being replayed. */ class ReplayedErrorWrapper extends Error { constructor(origin) { super('Replayed error'); this.origin = origin; this.name = 'ReplayedErrorWrapper'; } } /** * Manages idempotency in asynchronous operations by leveraging Redis for storage and distributed locks. */ class IdempotentExecutor { /** * Initializes a new instance of the IdempotentExecutor class. * * @param {Client} redis - The Redis client to be used for managing state and locks. */ constructor(redis) { this.redlock = new redlock_1.default([redis]); this.cache = new cache_1.RedisCache(redis); } /** * Executes the provided action with idempotency, ensuring that it runs exactly once for a given idempotency key. * * @param {string} idempotencyKey - A unique key identifying the operation to ensure idempotency. * @param {() => Promise<T>} action - An asynchronous function representing the operation to execute idempotently. * @param {Partial<RunOptions<T>>} options - Optional. Configuration options for the execution. * @property {number} options.timeout - Optional. The maximum duration, in milliseconds, that the concurrent operations will wait for the in-progress one to complete after which they will be terminated. Defaults to 60 seconds. * @property {Serializer<T>} options.valueSerializer - Optional. Responsible for serializing the successful result of the action. Defaults to JSON serialization. * @property {Serializer<Error>} options.errorSerializer - Optional. Used for serializing errors that may occur during the action's execution. Defaults to a error serializer that uses serialize-error-cjs. * @property {(idempotencyKey: string, value: T) => T} options.onActionSuccess - Optional. A callback that is invoked when the action is executed successfully. It receives the idempotency key and the result of the action, and should return the result to be returned by the executor. * @property {(idempotencyKey: string, error: Error) => Error} options.onActionError - Optional. A callback that is invoked when the action fails during execution. It receives the idempotency key and the error that occurred, and should return the error to be thrown by the executor. * @property {(idempotencyKey: string, value: T) => T} options.onSuccessReplay - Optional. A callback that is invoked when a successful action is replayed. It receives the idempotency key and the result of the action, and should return the result to be returned by the executor. * @property {(idempotencyKey: string, error: Error) => Error} options.onErrorReplay - Optional. A callback that is invoked when a failed action is replayed. It receives the idempotency key and the error that occurred, and should return the error to be thrown by the executor. * @property {(error: Error) => boolean} options.shouldIgnoreError - Optional. A callback that is invoked when an error is encountered. If it returns `true`, the error will not be cached and will not be replayed. * @returns {Promise<T>} The result of the executed action. * @throws {IdempotentExecutorCriticalError} If saving the result to cache fails, potentially leading to non-idempotent executions. * @throws {IdempotentExecutorCacheError} If retrieving the cached result fails. * @throws {IdempotentExecutorSerializationError} If serializing or deserializing the cached result fails. * @throws {IdempotentExecutorCallbackError} If executing a callback fails. * @throws {IdempotentExecutorNonErrorWrapperError} If the action throws a non-error object. * @throws {IdempotentExecutorUnknownError} If an unknown error occurs during the idempotent execution. * @throws {Error} If the action throws an error. */ run(idempotencyKey, action, options) { return __awaiter(this, void 0, void 0, function* () { var _a, _b, _c, _d; const timeout = (_a = options === null || options === void 0 ? void 0 : options.timeout) !== null && _a !== void 0 ? _a : 60000; const valueSerializer = (_b = options === null || options === void 0 ? void 0 : options.valueSerializer) !== null && _b !== void 0 ? _b : new serialization_1.JSONSerializer(); const errorSerializer = (_c = options === null || options === void 0 ? void 0 : options.errorSerializer) !== null && _c !== void 0 ? _c : new serialization_1.DefaultErrorSerializer(); const cacheKey = `idempotent-executor-result:${idempotencyKey}`; const shouldIgnoreError = (_d = options === null || options === void 0 ? void 0 : options.shouldIgnoreError) !== null && _d !== void 0 ? _d : (() => false); try { return yield this.redlock.using([idempotencyKey], timeout, { retryCount: timeout / 200, retryDelay: 200, automaticExtensionThreshold: timeout / 2, }, () => __awaiter(this, void 0, void 0, function* () { // Retrieve the cached result of the action. const cachedResult = yield this.getCachedResult(idempotencyKey, cacheKey); // If the action has already been executed, replay the result. if (cachedResult) { if (cachedResult.type === 'error') { this.replayCachedError(idempotencyKey, errorSerializer, cachedResult.error, options === null || options === void 0 ? void 0 : options.onErrorReplay); } if (cachedResult.value === undefined) { // If `undefined` does not satisfy the type T, // this means the action also broke the type contract. // So, we're simply replaying this here too. return undefined; } return this.replayCachedValue(idempotencyKey, valueSerializer, cachedResult.value, options === null || options === void 0 ? void 0 : options.onSuccessReplay); } // Execute the action. let actionResult; try { actionResult = yield action(); } catch (error) { actionResult = error instanceof Error ? error : new executor_errors_1.IdempotentExecutorNonErrorWrapperError('Non-error thrown', idempotencyKey, error); } // Cache the result of the action. yield this.cacheResult(idempotencyKey, cacheKey, actionResult, valueSerializer, errorSerializer, shouldIgnoreError); // If the action resulted in an error, throw it. if (actionResult instanceof Error) { this.throwError(idempotencyKey, actionResult, options === null || options === void 0 ? void 0 : options.onActionError); } // Return the result of the action. return this.returnResult(idempotencyKey, actionResult, options === null || options === void 0 ? void 0 : options.onActionSuccess); })); } catch (error) { if (error instanceof executor_errors_1.IdempotentExecutorErrorBase) { throw error; } if (error instanceof ReplayedErrorWrapper) { throw error.origin; } throw new executor_errors_1.IdempotentExecutorUnknownError('Failed to execute action idempotently', idempotencyKey, error); } }); } /** * Retrieves the cached result of an idempotent operation. */ getCachedResult(idempotencyKey, cacheKey) { return __awaiter(this, void 0, void 0, function* () { try { return yield this.cache.get(cacheKey); } catch (error) { throw new executor_errors_1.IdempotentExecutorCacheError('Failed to get cached result', idempotencyKey, error); } }); } /** * Replays a cached error, potentially transforming it using a callback. */ replayCachedError(idempotencyKey, errorSerializer, serializedError, onErrorReplay) { let error; try { error = errorSerializer.deserialize(serializedError); } catch (error) { throw new executor_errors_1.IdempotentExecutorSerializationError('Failed to parse cached error', idempotencyKey, error); } if (onErrorReplay) { try { error = onErrorReplay(idempotencyKey, error); } catch (error) { throw new executor_errors_1.IdempotentExecutorCallbackError('Failed to execute onErrorReplay callback', idempotencyKey, 'onErrorReplay', error); } } throw new ReplayedErrorWrapper(error); } /** * Replays a cached value, potentially transforming it using a callback. */ replayCachedValue(idempotencyKey, valueSerializer, serializedValue, onSuccessReplay) { let value; try { value = valueSerializer.deserialize(serializedValue); } catch (error) { throw new executor_errors_1.IdempotentExecutorSerializationError('Failed to parse cached value', idempotencyKey, error); } if (onSuccessReplay) { try { value = onSuccessReplay(idempotencyKey, value); } catch (error) { throw new executor_errors_1.IdempotentExecutorCallbackError('Failed to execute onSuccessReplay callback', idempotencyKey, 'onSuccessReplay', error); } } return value; } /** * Caches the result of an idempotent operation. */ cacheResult(idempotencyKey, cacheKey, value, valueSerializer, errorSerializer, shouldIgnoreError) { return __awaiter(this, void 0, void 0, function* () { try { if (value instanceof Error) { const ignoreError = shouldIgnoreError(value); if (ignoreError) { return; } yield this.cache.set(cacheKey, { type: 'error', error: errorSerializer.serialize(value), }); } else { yield this.cache.set(cacheKey, { type: 'value', value: valueSerializer.serialize(value), }); } } catch (error) { // If caching the result fails, throw a critical error as it might lead to non-idempotent executions. throw new executor_errors_1.IdempotentExecutorCriticalError('Failed to set cached result', idempotencyKey, error); } }); } /** * Throws an error that occurred during the execution of an idempotent operation, * potentially transforming it using a callback. */ throwError(idempotencyKey, error, onActionError) { if (onActionError) { try { error = onActionError(idempotencyKey, error); } catch (error) { throw new executor_errors_1.IdempotentExecutorCallbackError('Failed to execute onActionError callback', idempotencyKey, 'onActionError', error); } } throw new ReplayedErrorWrapper(error); } /** * Returns the result of an idempotent operation, * potentially transforming it using a callback. */ returnResult(idempotencyKey, value, onActionSuccess) { if (onActionSuccess) { try { value = onActionSuccess(idempotencyKey, value); } catch (error) { throw new executor_errors_1.IdempotentExecutorCallbackError('Failed to execute onActionSuccess callback', idempotencyKey, 'onActionSuccess', error); } } return value; } } exports.IdempotentExecutor = IdempotentExecutor;