UNPKG

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

Version:

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

517 lines (512 loc) 18.7 kB
import { logger } from "./chunk-5AKRF73H.mjs"; import { REDIS_IS_CONNECTED_STATUSES, REDIS_KEYS_PREFIXES, REDIS_SUCCESS } from "./chunk-G4NMIJDV.mjs"; import { config_default } from "./chunk-SXXBY7AF.mjs"; import { ProxyCacheError, ValidationError } from "./chunk-CNBILPR3.mjs"; import { STORAGE_TYPES } from "./chunk-7FWCQSX5.mjs"; // src/validation.ts import Ajv from "ajv"; // src/lib/storages/RedisProxyCache.ts import Redis, { Cluster } from "ioredis"; var PROCESS_NODE_STREAM_TIMEOUT_MS = 2 * 60 * 1e3; var isClusterConfig = (config, 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 Cluster(this.proxyConfig.cluster, this.proxyConfig) : new Redis(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 }; } }; // 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; } } }; // src/validation.ts var ajv = new Ajv(); 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; }; export { validateRedisProxyCacheConfig, validateRedisClusterProxyCacheConfig, validateAlsRequestDetails, validateDfspId, RedisProxyCache, createProxyCache }; //# sourceMappingURL=chunk-SXHHTQYP.mjs.map