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