UNPKG

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

Version:

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

475 lines (470 loc) 16.7 kB
import { logger } from "./chunk-ZRQ6O7AE.mjs"; import { REDIS_IS_CONNECTED_STATUSES, REDIS_KEYS_PREFIXES, REDIS_SUCCESS } from "./chunk-G4NMIJDV.mjs"; import { config_default } from "./chunk-FHZ7FVE3.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 = 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 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 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; } } }; // 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-YQ5HWBKF.mjs.map