@cap-js-community/event-queue
Version:
An event queue that enables secure transactional processing of asynchronous and periodic events, featuring instant event processing with Redis Pub/Sub and load distribution across all application instances.
279 lines (252 loc) • 8.02 kB
JavaScript
;
const redis = require("./redis");
const config = require("../config");
const cdsHelper = require("./cdsHelper");
const existingLocks = {};
const REDIS_COMMAND_OK = "OK";
const COMPONENT_NAME = "/eventQueue/distributedLock";
const acquireLock = async (
context,
key,
{ tenantScoped = true, expiryTime = config.globalTxTimeout, keepTrackOfLock = false, skipNamespace = false } = {}
) => {
const fullKey = _generateKey(context, tenantScoped, key, skipNamespace);
if (config.redisEnabled) {
return await _acquireLockRedis(context, fullKey, expiryTime, { keepTrackOfLock });
} else {
return await _acquireLockDB(context, fullKey, expiryTime, { keepTrackOfLock });
}
};
const renewLock = async (context, key, { tenantScoped = true, expiryTime = config.globalTxTimeout } = {}) => {
const fullKey = _generateKey(context, tenantScoped, key);
if (config.redisEnabled) {
return await _renewLockRedis(context, fullKey, expiryTime);
} else {
return await _acquireLockDB(context, fullKey, expiryTime, { overrideValue: true });
}
};
const setValueWithExpire = async (
context,
key,
value,
{ tenantScoped = true, expiryTime = config.globalTxTimeout, overrideValue = false, keepTrackOfLock = false } = {}
) => {
const fullKey = _generateKey(context, tenantScoped, key);
if (config.redisEnabled) {
return await _acquireLockRedis(context, fullKey, expiryTime, {
value,
overrideValue,
keepTrackOfLock,
});
} else {
return await _acquireLockDB(context, fullKey, expiryTime, {
value,
overrideValue,
});
}
};
const releaseLock = async (context, key, { tenantScoped = true, skipNamespace = false } = {}) => {
const fullKey = _generateKey(context, tenantScoped, key, skipNamespace);
if (config.redisEnabled) {
return await _releaseLockRedis(context, fullKey);
} else {
return await _releaseLockDb(context, fullKey);
}
};
const checkLockExists = async (context, key, { tenantScoped = true } = {}) => {
const fullKey = _generateKey(context, tenantScoped, key);
if (config.redisEnabled) {
return !!(await _getLockValueRedis(context, fullKey));
} else {
return !!(await _getLockValueDb(context, fullKey));
}
};
const getValue = async (context, key, { tenantScoped = true } = {}) => {
const fullKey = _generateKey(context, tenantScoped, key);
if (config.redisEnabled) {
return await _getLockValueRedis(context, fullKey);
} else {
return await _getLockValueDb(context, fullKey);
}
};
const _acquireLockRedis = async (
context,
fullKey,
expiryTime,
{ value = Date.now(), overrideValue = false, keepTrackOfLock } = {}
) => {
const client = await redis.createMainClientAndConnect();
const result = await client.set(fullKey, value, {
PX: Math.round(expiryTime),
...(overrideValue ? null : { NX: true }),
});
const isOk = result === REDIS_COMMAND_OK;
if (isOk && keepTrackOfLock) {
existingLocks[fullKey] = context.tenant;
}
return isOk;
};
const _renewLockRedis = async (context, fullKey, expiryTime, { value = "true" } = {}) => {
const client = await redis.createMainClientAndConnect();
let result = await client.set(fullKey, value, {
PX: Math.round(expiryTime),
XX: true,
});
if (result !== REDIS_COMMAND_OK) {
const readResult = await client.get(fullKey);
if (!readResult) {
result = await client.set(fullKey, value, {
PX: Math.round(expiryTime),
});
}
}
return result === REDIS_COMMAND_OK;
};
const _getLockValueRedis = async (context, fullKey) => {
const client = await redis.createMainClientAndConnect();
return await client.get(fullKey);
};
const _getLockValueDb = async (context, fullKey) => {
let result;
await cdsHelper.executeInNewTransaction(context, "distributedLock-checkExists", async (tx) => {
result = await tx.run(SELECT.one.from(config.tableNameEventLock).where("code =", fullKey));
});
return result?.value;
};
const _releaseLockRedis = async (context, fullKey) => {
const client = await redis.createMainClientAndConnect();
const result = await client.del(fullKey);
delete existingLocks[fullKey];
return result === 1;
};
const _releaseLockDb = async (context, fullKey) => {
await cdsHelper.executeInNewTransaction(context, "distributedLock-release", async (tx) => {
await tx.run(DELETE.from(config.tableNameEventLock).where("code =", fullKey));
});
delete existingLocks[fullKey];
return true;
};
const _acquireLockDB = async (
context,
fullKey,
expiryTime,
{ value = "true", overrideValue = false, keepTrackOfLock } = {}
) => {
let result;
await cdsHelper.executeInNewTransaction(context, "distributedLock-acquire", async (tx) => {
try {
await tx.run(
INSERT.into(config.tableNameEventLock).entries({
code: fullKey,
value,
})
);
result = true;
} catch (err) {
let currentEntry;
if (!overrideValue) {
currentEntry = await tx.run(
SELECT.one
.from(config.tableNameEventLock)
.forUpdate({ wait: config.forUpdateTimeout })
.where("code =", fullKey)
);
}
if (
overrideValue ||
(currentEntry && new Date(currentEntry.createdAt).getTime() + Math.round(expiryTime) <= Date.now())
) {
await tx.run(
UPDATE.entity(config.tableNameEventLock)
.set({
createdAt: new Date().toISOString(),
value,
})
.where("code =", fullKey)
);
result = true;
} else {
result = false;
}
}
});
if (result && keepTrackOfLock) {
existingLocks[fullKey] = context.tenant;
}
return result;
};
const _generateKey = (context, tenantScoped, key, skipNamespace) => {
const keyParts = [config.redisNamespace(!skipNamespace)];
tenantScoped && keyParts.push(context.tenant);
keyParts.push(key);
return `${keyParts.join("##")}`;
};
const getAllLocksRedis = async () => {
const clientOrCluster = await redis.createMainClientAndConnect();
const output = [];
const results = [];
let clients;
if (redis.isClusterMode()) {
clients = clientOrCluster.masters.map((master) => master.client);
} else {
clients = [clientOrCluster];
}
// NOTE: use SCAN because KEYS is not supported for cluster clients
for (const client of clients) {
for await (const key of client.scanIterator({ MATCH: "EVENT*", COUNT: 1000 })) {
const [, namespace, tenant, guidOrType, subType] = key.split("##");
if (!subType) {
continue;
}
const pipeline = client.multi();
output.push({
namespace,
tenant,
type: guidOrType,
subType: subType,
});
pipeline.ttl(key).get(key);
const replies = await pipeline.exec();
results.push(...replies);
}
}
let counter = 0;
for (const row of output) {
const ttl = results[counter];
const createdAt = results[counter + 1];
Object.assign(row, { ttl, createdAt });
counter = counter + 2;
}
return output;
};
const shutdownHandler = async () => {
const logger = cds.log(COMPONENT_NAME);
logger.info("received shutdown event, trying to release all locks", {
numberOfLocks: Object.keys(existingLocks).length,
});
const result = await Promise.allSettled(
Object.entries(existingLocks).map(async ([key, tenant]) => {
if (config.redisEnabled) {
await _releaseLockRedis({ tenant }, key);
} else {
await _releaseLockDb({ tenant }, key);
}
logger.info("lock released", { key });
})
);
const errors = result.filter((promise) => promise.reason);
logger.info("releasing locks finished ", {
numberOfErrors: errors.length,
...(errors.length && { firstError: errors[0] }),
});
};
module.exports = {
acquireLock,
releaseLock,
checkLockExists,
getValue,
setValueWithExpire,
shutdownHandler,
renewLock,
getAllLocksRedis,
};