UNPKG

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

Version:

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

575 lines (561 loc) 20.3 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/createProxyCache.ts var createProxyCache_exports = {}; __export(createProxyCache_exports, { createProxyCache: () => createProxyCache }); module.exports = __toCommonJS(createProxyCache_exports); // src/validation.ts var import_ajv = __toESM(require("ajv")); // 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 validateRedisProxyCacheConfig = (cacheConfig) => { const isValid = redisProxyCacheConfigValidatingFn(cacheConfig); if (!isValid) { const errDetails = `redisProxyCacheConfig error: ${redisProxyCacheConfigValidatingFn.errors[0].message}`; throw ValidationError.invalidFormat(errDetails); } return cacheConfig; }; var RedisClusterProxyCacheConfigSchema = { type: "object", properties: { cluster: { type: "array", items: BasicConnectionSchema, minItems: 1 }, ...RedisOptionsSchema.properties }, required: ["cluster"], additionalProperties: true }; var redisClusterProxyCacheConfigValidatingFn = ajv.compile( RedisClusterProxyCacheConfigSchema ); var validateRedisClusterProxyCacheConfig = (cacheConfig) => { const isValid = redisClusterProxyCacheConfigValidatingFn(cacheConfig); if (!isValid) { const errDetails = `redisClusterProxyCacheConfig error: ${redisClusterProxyCacheConfigValidatingFn.errors[0].message}`; throw ValidationError.invalidFormat(errDetails); } return cacheConfig; }; 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/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: 30, 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/lib/storages/RedisProxyCache.ts var import_ioredis = __toESM(require("ioredis")); // 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 = Date.now() + ttlSec * 1e3; const result = await this.redisClient.set(key, expiryTime); this.log.debug("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 ttl = ttlSec ?? this.defaultTtlSec; const expiryTime = Date.now() + ttl * 1e3; 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, ttl }); return isOk; } async receivedSuccessResponse(alsReq) { const key = _RedisProxyCache.formatAlsCacheKey(alsReq); const expiryKey = _RedisProxyCache.formatAlsCacheExpiryKey(alsReq); let isDeleted; let logMeta; if (this.isCluster) { const [delResult, delExpiryResult] = await Promise.all([ this.redisClient.del(key), this.redisClient.del(expiryKey) ]); isDeleted = delResult === 1 && delExpiryResult === 1; logMeta = { isDeleted, delResult, delExpiryResult }; } else { const delResult = await this.executePipeline([ ["del", key], ["del", expiryKey] ]); isDeleted = delResult[0] === 1 && delResult[1] === 1; logMeta = { isDeleted, delResult }; } this.log.debug("sendToProxiesList is deleted", logMeta); return isDeleted; } async receivedErrorResponse(alsReq, proxyId) { const key = _RedisProxyCache.formatAlsCacheKey(alsReq); const expiryKey = _RedisProxyCache.formatAlsCacheExpiryKey(alsReq); const [delCount, card] = await this.executePipeline([ ["srem", key, proxyId], ["scard", key] ]); const isLast = delCount === 1 && card === 0; if (isLast) { const [delKeyCount, delExpiryCount] = await Promise.all([ this.redisClient.del(key), this.redisClient.del(expiryKey) ]); this.log.info("receivedErrorResponse: last response received, keys were deleted", { isLast, delKeyCount, delExpiryCount }); } this.log.info("receivedErrorResponse is done", { isLast, alsReq, delCount, card }); return isLast; } async isPendingCallback(alsReq) { const key = _RedisProxyCache.formatAlsCacheKey(alsReq); const card = await this.redisClient.scard(key); this.log.debug("isPendingCallbacks for alsReq is done", { key, card }); return card > 0; } // 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 pipeline results"); } 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.debug("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.debug("processNodeStream is done", { result }); clearTimer(); resolve(result); }); stream.on("error", (err) => { this.log.warn("processNodeStream stream error: ", err); clearTimer(); reject(err); }); }); } async processExpiryKey(expiryKey, callbackFn) { const actualKey = expiryKey.replace(":expiresAt", ""); const expiresAt = await this.redisClient.get(expiryKey); if (Number(expiresAt) >= Date.now()) return; const deleteKeys = this.isCluster ? () => Promise.all([this.redisClient.del(actualKey), this.redisClient.del(expiryKey)]) : () => this.executePipeline([["del", actualKey], ["del", expiryKey]]); return Promise.all([ callbackFn(actualKey).catch((err) => { this.log.warn(`processExpiryKey callback error ${expiryKey}`, err); return Promise.resolve(); }), deleteKeys().catch((err) => { this.log.error(`processExpiryKey key deletion error ${expiryKey}`, err); return Promise.reject(err); }) ]); } 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 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}`; } }; // src/lib/createProxyCache.ts var createProxyCache = (type, proxyConfig) => { switch (type) { case STORAGE_TYPES.redis: { return new RedisProxyCache(validateRedisProxyCacheConfig(proxyConfig)); } case STORAGE_TYPES.redisCluster: { return new RedisProxyCache(validateRedisClusterProxyCacheConfig(proxyConfig), true); } case STORAGE_TYPES.mysql: throw new Error("Mysql storage is not implemented yet"); default: { const error = ProxyCacheError.unsupportedProxyCacheType(); logger.warn(error.message, proxyConfig); throw error; } } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { createProxyCache }); //# sourceMappingURL=createProxyCache.js.map