@mojaloop/inter-scheme-proxy-cache-lib
Version:
Common component, that provides scheme proxy caching mapping (ISPC)
582 lines (569 loc) • 21 kB
JavaScript
"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