UNPKG

@mojaloop/inter-scheme-proxy-cache-lib

Version:

Common component, that provides scheme proxy caching mapping (ISPC)

582 lines (569 loc) 21 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/lib/storages/index.ts var storages_exports = {}; __export(storages_exports, { RedisProxyCache: () => RedisProxyCache }); module.exports = __toCommonJS(storages_exports); // src/lib/storages/RedisProxyCache.ts var import_ioredis = __toESM(require("ioredis")); // src/validation.ts var import_ajv = __toESM(require("ajv")); // src/utils/index.ts var import_contextLogger = require("@mojaloop/central-services-logger/src/contextLogger"); // src/config.ts var import_convict = __toESM(require("convict")); // src/types/utils.ts var logLevelsMap = { error: "error", warn: "warn", info: "info", verbose: "verbose", debug: "debug", silly: "silly", audit: "audit", trace: "trace", perf: "perf" }; var logLevelValues = Object.values(logLevelsMap); // src/config.ts var config = (0, import_convict.default)({ logLevel: { doc: "Log level for the library.", format: logLevelValues, default: logLevelsMap.warn, env: "PROXY_CACHE_LOG_LEVEL" }, defaultTtlSec: { doc: "Default cache TTL for sendToProxiesList keys.", format: Number, default: 20, env: "PROXY_CACHE_DEFAULT_TTL_SEC" } }); config.validate({ allowed: "strict" }); var config_default = config; // src/utils/index.ts var createLogger = (context) => { const log = (0, import_contextLogger.loggerFactory)(context); log.setLevel(config_default.get("logLevel")); return log; }; var logger = createLogger("ISPCL"); // src/constants.ts var STORAGE_TYPES = { redis: "redis", redisCluster: "redis-cluster", mysql: "mysql" }; var storageTypeValues = Object.values(STORAGE_TYPES); var ERROR_MESSAGES = { unsupportedProxyCacheType: `Unsupported proxyCache type [possible values: ${storageTypeValues.join(", ")}]`, invalidFormat: "Invalid format" // add all needed error messages }; // src/lib/errors.ts var ProxyCacheError = class _ProxyCacheError extends Error { constructor(message) { super(message); Error.captureStackTrace(this, this.constructor); this.name = this.constructor.name; } static unsupportedProxyCacheType() { return new _ProxyCacheError(ERROR_MESSAGES.unsupportedProxyCacheType); } }; var ValidationError = class _ValidationError extends ProxyCacheError { static invalidFormat(details) { const erMessage = `${ERROR_MESSAGES.invalidFormat}${details ? ` - ${details}` : ""}`; return new _ValidationError(erMessage); } }; // src/validation.ts var ajv = new import_ajv.default(); var BasicConnectionSchema = { type: "object", properties: { host: { type: "string", minLength: 1 }, port: { type: "integer" } }, required: ["host", "port"], additionalProperties: false }; var RedisOptionsSchema = { type: "object", properties: { username: { type: "string", nullable: true }, password: { type: "string", nullable: true }, lazyConnect: { type: "boolean", nullable: true }, db: { type: "number", nullable: true } }, additionalProperties: true }; var RedisProxyCacheConfigSchema = { type: "object", properties: { ...BasicConnectionSchema.properties, ...RedisOptionsSchema.properties }, required: ["host", "port"], additionalProperties: true }; var redisProxyCacheConfigValidatingFn = ajv.compile(RedisProxyCacheConfigSchema); var RedisClusterProxyCacheConfigSchema = { type: "object", properties: { cluster: { type: "array", items: BasicConnectionSchema, minItems: 1 }, ...RedisOptionsSchema.properties }, required: ["cluster"], additionalProperties: true }; var redisClusterProxyCacheConfigValidatingFn = ajv.compile( RedisClusterProxyCacheConfigSchema ); var AlsRequestSchema = { type: "object", properties: { sourceId: { type: "string" }, type: { type: "string" }, partyId: { type: "string" } }, required: ["sourceId", "type", "partyId"], additionalProperties: false }; var alsRequestValidatingFn = ajv.compile(AlsRequestSchema); var validateAlsRequestDetails = (data) => { const isValid = alsRequestValidatingFn(data); if (!isValid) { const errDetails = `alsRequest: ${alsRequestValidatingFn.errors[0].message}`; throw ValidationError.invalidFormat(errDetails); } return true; }; var DfspIdSchema = { type: "string", minLength: 1, maxLength: 32 }; var dfspIdValidatingFn = ajv.compile(DfspIdSchema); var validateDfspId = (data) => { const isValid = dfspIdValidatingFn(data); if (!isValid) { const errDetails = `dfspId: ${dfspIdValidatingFn.errors[0].message}`; throw ValidationError.invalidFormat(errDetails); } return true; }; // src/lib/storages/constants.ts var REDIS_KEYS_PREFIXES = { als: "als", dfsp: "dfsp", getParties: "getParties" }; var REDIS_SUCCESS = "OK"; var REDIS_IS_CONNECTED_STATUSES = ["connect", "ready"]; // src/lib/storages/RedisProxyCache.ts var PROCESS_NODE_STREAM_TIMEOUT_MS = 2 * 60 * 1e3; var isClusterConfig = (config2, isCluster) => isCluster; var RedisProxyCache = class _RedisProxyCache { constructor(proxyConfig, isCluster = false) { this.proxyConfig = proxyConfig; isClusterConfig(proxyConfig, isCluster); this.isCluster = isCluster; this.log = logger.child({ component: this.constructor.name }); this.redisClient = this.createRedisClient(); } redisClient; log; defaultTtlSec = config_default.get("defaultTtlSec"); isCluster = false; get redisNodes() { return this.isCluster ? this.redisClient.nodes("master") : [this.redisClient]; } async addDfspIdToProxyMapping(dfspId, proxyId) { const key = _RedisProxyCache.formatDfspCacheKey(dfspId); const response = await this.redisClient.set(key, proxyId); const isAdded = response === REDIS_SUCCESS; this.log.debug("proxyMapping is added", { key, proxyId, isAdded }); return isAdded; } async lookupProxyByDfspId(dfspId) { const key = _RedisProxyCache.formatDfspCacheKey(dfspId); const proxyId = await this.redisClient.get(key); this.log.debug("lookupProxyByDfspId is done", { key, proxyId }); return proxyId; } async removeDfspIdFromProxyMapping(dfspId) { const key = _RedisProxyCache.formatDfspCacheKey(dfspId); const result = await this.redisClient.del(key); const isRemoved = result === 1; this.log.debug("proxyMapping is removed", { key, isRemoved, result }); return isRemoved; } async removeProxyGetPartiesTimeout(alsReq, proxyId) { const key = _RedisProxyCache.formatProxyGetPartiesExpiryKey(alsReq, proxyId); const result = await this.redisClient.del(key); this.log.debug("removeProxyGetPartiesTimeout is done", { result, key }); return result > 0; } async setProxyGetPartiesTimeout(alsReq, proxyId, ttlSec = this.defaultTtlSec) { const key = _RedisProxyCache.formatProxyGetPartiesExpiryKey(alsReq, proxyId); const expiryTime = this.calculateExpiryTimestampInMs(ttlSec); const result = await this.redisClient.set(key, expiryTime); this.log.verbose("setProxyGetPartiesTimeout is done", { result, key, expiryTime }); return result === REDIS_SUCCESS; } async setSendToProxiesList(alsReq, proxyIds, ttlSec) { const key = _RedisProxyCache.formatAlsCacheKey(alsReq); const expiryKey = _RedisProxyCache.formatAlsCacheExpiryKey(alsReq); const isExists = await this.redisClient.exists(key); if (isExists) { this.log.warn("sendToProxiesList already exists", { key }); return false; } const uniqueProxyIds = [...new Set(proxyIds)]; const expiryTime = this.calculateExpiryTimestampInMs(ttlSec); let isOk; if (this.isCluster) { const [addedCount, expirySetResult] = await Promise.all([ this.redisClient.sadd(key, uniqueProxyIds), this.redisClient.set(expiryKey, expiryTime) ]); isOk = addedCount === uniqueProxyIds.length && expirySetResult === REDIS_SUCCESS; } else { const [addedCount] = await this.executePipeline([ ["sadd", key, ...uniqueProxyIds], ["set", expiryKey, expiryTime] ]); isOk = addedCount === uniqueProxyIds.length; } this.log.verbose("setSendToProxiesList is done", { isOk, key, uniqueProxyIds, expiryTime }); return isOk; } async receivedSuccessResponse(alsReq, proxyId) { const isPending = await this.isPendingCallback(alsReq, proxyId); if (!isPending) { this.log.verbose("receivedSuccessResponse is skipped (not ISDf)", { isPending, alsReq, proxyId }); return false; } const isSet = await this.storeSuccessAlsResponse(alsReq, proxyId); const { isLast } = await this.isLastCallback(alsReq, proxyId); this.log.info("receivedSuccessResponse is done", { isLast, isSet, alsReq, proxyId }); return isSet; } async receivedErrorResponse(alsReq, proxyId) { const { isLast, card, hadSuccess } = await this.isLastCallback(alsReq, proxyId); const isLastWithoutSuccess = isLast && !hadSuccess; this.log.info("receivedErrorResponse is done", { isLast, isLastWithoutSuccess, alsReq, card, proxyId }); return isLastWithoutSuccess; } async isPendingCallback(alsReq, proxyId = "") { if (!proxyId) return false; const key = _RedisProxyCache.formatAlsCacheKey(alsReq); const isMember = await this.redisClient.sismember(key, proxyId); this.log.verbose("isPendingCallback for alsReq is done", { key, isMember, proxyId }); return isMember === 1; } // todo: refactor to use processAllNodesStream async processExpiredAlsKeys(callbackFn, batchSize) { const pattern = _RedisProxyCache.formatAlsCacheExpiryKey({ sourceId: "*", type: "*", partyId: "*" }); return Promise.all( this.redisNodes.map(async (node) => { return new Promise((resolve, reject) => { this.processNode(node, { pattern, batchSize, callbackFn, resolve, reject }); }); }) ); } // prettier-ignore async processExpiredProxyGetPartiesKeys(customFn, batchSize) { const pattern = _RedisProxyCache.formatProxyGetPartiesExpiryKey({ sourceId: "*", type: "*", partyId: "*" }, "*"); return this.processAllNodesStream({ pattern, batchSize }, async (key) => { const result = await Promise.all([ customFn(key.replace(":expiresAt", "")).catch((err) => { this.log.warn(`error processing expired proxyGetParties key ${key} - `, err); return err; }), this.redisClient.del(key) ]); this.log.verbose("processExpiredProxyGetPartiesKeys is done", { result }); return result; }); } async connect() { if (this.isConnected) { const { status: status2 } = this.redisClient; this.log.warn("proxyCache is already connected", { status: status2 }); return status2; } await this.redisClient.connect(); const { status } = this.redisClient; this.log.info("proxyCache is connected", { status }); return status; } async disconnect() { const response = await this.redisClient.quit(); const isDisconnected = response === REDIS_SUCCESS; this.redisClient.removeAllListeners(); this.log.info("proxyCache is disconnected", { isDisconnected, response }); return isDisconnected; } async healthCheck() { try { const response = await this.redisClient.ping(); const isHealthy = response === "PONG"; this.log.debug("healthCheck ping response", { isHealthy, response }); return isHealthy; } catch (err) { this.log.warn("healthCheck error", err); return false; } } get isConnected() { const isConnected = REDIS_IS_CONNECTED_STATUSES.includes(this.redisClient.status); this.log.debug("isConnected", { isConnected }); return isConnected; } createRedisClient() { this.proxyConfig.lazyConnect ??= true; const redisClient = isClusterConfig(this.proxyConfig, this.isCluster) ? new import_ioredis.Cluster(this.proxyConfig.cluster, this.proxyConfig) : new import_ioredis.default(this.proxyConfig); this.addEventListeners(redisClient); return redisClient; } addEventListeners(redisClient) { const { log } = this; redisClient.on("error", (err) => { log.error("redis connection error", err); }).on("close", () => { log.info("redis connection closed"); }).on("end", () => { log.warn("redis connection ended"); }).on("reconnecting", (ms) => { log.info("redis connection reconnecting", { ms }); }).on("connect", () => { log.verbose("redis connection is established"); }).on("ready", () => { log.verbose("redis connection is ready"); }); } async executePipeline(commands) { const pipeline = this.redisClient.pipeline(); commands.forEach(([command, ...args]) => { if (typeof pipeline[command] === "function") pipeline[command](...args); else this.log.warn("unknown redis command", { command, args }); }); try { const results = await pipeline.exec(); if (!results) { throw new Error("no results from pipeline.exec()"); } return results.map((result, index) => { if (result[0]) { const errMessage = `error in command ${index + 1}: ${result[0].message}`; this.log.warn(errMessage, result[0]); throw new Error(errMessage); } return result[1]; }); } catch (error) { this.log.error("pipeline execution failed", error); return []; } } /** @deprecated Use processAllNodesStream */ async processNode(node, options) { const { pattern: match, batchSize: count, callbackFn, resolve, reject } = options; const stream = node.scanStream({ match, count }); stream.on("data", async (keys) => { stream.pause(); try { await Promise.all(keys.map((key) => this.processExpiryKey(key, callbackFn))); } catch (err) { stream.destroy(err); reject(err); } stream.resume(); }); stream.on("end", resolve); } async processAllNodesStream(options, callbackFn) { const result = await Promise.all( this.redisNodes.map((node) => this.processSingleNodeStream(node, options, callbackFn)) ); this.log.info("processAllNodesStream is done", { result }); return result; } async processSingleNodeStream(node, options, processKeyFn) { const { pattern: match, batchSize: count } = options; return new Promise((resolve, reject) => { const stream = node.scanStream({ match, count }); let result; let timer = setTimeout(() => { const err = new Error(`Timeout during processNodeStream [${PROCESS_NODE_STREAM_TIMEOUT_MS} ms]`); stream.destroy(err); reject(err); }, PROCESS_NODE_STREAM_TIMEOUT_MS); const clearTimer = () => { if (timer) { clearTimeout(timer); timer = null; } }; stream.on("data", async (keys) => { stream.pause(); try { result = await Promise.all(keys.map(processKeyFn)); } catch (err) { this.log.warn("error in processNodeStream data: ", err); clearTimer(); stream.destroy(err); reject(err); } stream.resume(); }); stream.on("end", () => { this.log.info("processNodeStream is done", { result }); clearTimer(); resolve(result); }); stream.on("error", (err) => { this.log.warn("processNodeStream stream error: ", err); clearTimer(); reject(err); }); }); } // todo: add tests for successKey case async processExpiryKey(expiryKey, callbackFn) { const expiresAt = await this.redisClient.get(expiryKey); if (Number(expiresAt) >= Date.now()) return; const actualKey = expiryKey.replace(":expiresAt", ""); const deleteKeys = this.isCluster ? () => Promise.all([this.redisClient.del(actualKey), this.redisClient.del(expiryKey)]) : () => this.executePipeline([["del", actualKey], ["del", expiryKey]]); const alsReq = _RedisProxyCache.extractAlsRequestDetails(expiryKey); const successKey = _RedisProxyCache.formatAlsCacheSuccessKey(alsReq); const proxyId = await this.redisClient.get(successKey); const jobs = [ deleteKeys().catch((err) => { this.log.error(`processExpiryKey key deletion error ${expiryKey}: `, err); return err; }) ]; if (!proxyId) { jobs.push( callbackFn(actualKey).catch((err) => { this.log.warn(`processExpiryKey callbackFn error ${expiryKey}: `, err); return err; }) ); } else { this.log.info("expired ALS request has success callback", { alsReq, expiryKey, proxyId }); jobs.push( this.redisClient.del(successKey).catch((err) => { this.log.warn(`processExpiryKey delete successKey error: `, err); return err; }) ); } return Promise.all(jobs); } async storeSuccessAlsResponse(alsReq, proxyId) { const key = _RedisProxyCache.formatAlsCacheSuccessKey(alsReq); const response = await this.redisClient.set(key, proxyId); const isSet = response === REDIS_SUCCESS; this.log.info("storeSuccessAlsResponse is done", { key, proxyId, isSet }); return isSet; } // prettier-ignore async isLastCallback(alsReq, proxyId) { const key = _RedisProxyCache.formatAlsCacheKey(alsReq); const [delCount, card] = await this.executePipeline([ ["srem", key, proxyId], ["scard", key] ]); const isLast = delCount === 1 && card === 0; let hadSuccess = false; if (isLast) { const [delKeyCount, delExpiryCount, delSuccessCount] = await Promise.all([ this.redisClient.del(key), this.redisClient.del(_RedisProxyCache.formatAlsCacheExpiryKey(alsReq)), this.redisClient.del(_RedisProxyCache.formatAlsCacheSuccessKey(alsReq)) ]); this.log.verbose("received last callback, keys were deleted", { delKeyCount, delExpiryCount, delSuccessCount }); hadSuccess = delSuccessCount === 1; } const result = { isLast, hadSuccess, key, card }; this.log.info("isLastCallback is done:", result); return result; } calculateExpiryTimestampInMs(ttlSec = this.defaultTtlSec) { if (typeof ttlSec === "number" && ttlSec > 0) { return Date.now() + ttlSec * 1e3; } throw new Error(`Invalid TTL value: ${ttlSec}. Expected a positive number`); } static formatAlsCacheKey(alsReq) { validateAlsRequestDetails(alsReq); return `${REDIS_KEYS_PREFIXES.als}:${alsReq.sourceId}:${alsReq.type}:${alsReq.partyId}`; } static formatAlsCacheExpiryKey(alsReq) { return `${_RedisProxyCache.formatAlsCacheKey(alsReq)}:expiresAt`; } static formatAlsCacheSuccessKey(alsReq) { return `${_RedisProxyCache.formatAlsCacheKey(alsReq)}:success`; } static formatProxyGetPartiesExpiryKey(alsReq, proxyId) { return `${REDIS_KEYS_PREFIXES.getParties}:${proxyId}:${alsReq.sourceId}:${alsReq.type}:${alsReq.partyId}:expiresAt`; } static formatDfspCacheKey(dfspId) { validateDfspId(dfspId); return `${REDIS_KEYS_PREFIXES.dfsp}:${dfspId}`; } static extractAlsRequestDetails(key) { const [prefix, sourceId, type, partyId] = key.split(":"); if (!prefix || !(prefix in REDIS_KEYS_PREFIXES)) { throw new Error(`Invalid key prefix: ${prefix}`); } if (!sourceId || !type || !partyId) { throw new Error(`No required ALS request details extracted from cache key ${key}!`); } return { sourceId, type, partyId }; } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { RedisProxyCache }); //# sourceMappingURL=index.js.map