@hotmeshio/hotmesh
Version:
Permanent-Memory Workflows & AI Agents
981 lines (980 loc) • 41.1 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RedisStoreBase = void 0;
const errors_1 = require("../../../../modules/errors");
const key_1 = require("../../../../modules/key");
const serializer_1 = require("../../../serializer");
const utils_1 = require("../../../../modules/utils");
const enums_1 = require("../../../../modules/enums");
const cache_1 = require("../../cache");
const __1 = require("../..");
class RedisStoreBase extends __1.StoreService {
constructor(storeClient) {
super(storeClient);
this.commands = {};
this.storeClient = storeClient;
}
async init(namespace = key_1.HMNS, appId, logger) {
this.namespace = namespace;
this.appId = appId;
this.logger = logger;
const settings = await this.getSettings(true);
this.cache = new cache_1.Cache(appId, settings);
this.serializer = new serializer_1.SerializerService();
await this.getApp(appId);
return this.cache.getApps();
}
isSuccessful(result) {
return result > 0 || result === 'OK' || result === true;
}
async delistSignalKey(key, target) {
await this.storeClient[this.commands.del](`${key}:${target}`);
}
async zAdd(key, score, value, redisMulti) {
//default call signature uses 'ioredis' NPM Package format
return await (redisMulti || this.storeClient)[this.commands.zadd](key, score, value);
}
async zRangeByScore(key, score, value) {
const result = await this.storeClient[this.commands.zrangebyscore](key, score, value);
if (result?.length > 0) {
return result[0];
}
return null;
}
mintKey(type, params) {
if (!this.namespace)
throw new Error('namespace not set');
return key_1.KeyService.mintKey(this.namespace, type, params);
}
invalidateCache() {
this.cache.invalidate();
}
/**
* At any given time only a single engine will
* check for and process work items in the
* time and signal task queues.
*/
async reserveScoutRole(scoutType, delay = enums_1.HMSH_SCOUT_INTERVAL_SECONDS) {
const key = this.mintKey(key_1.KeyType.WORK_ITEMS, {
appId: this.appId,
scoutType,
});
const success = await this.exec('SET', key, `${scoutType}:${(0, utils_1.formatISODate)(new Date())}`, 'NX', 'EX', `${delay - 1}`);
return this.isSuccessful(success);
}
async releaseScoutRole(scoutType) {
const key = this.mintKey(key_1.KeyType.WORK_ITEMS, {
appId: this.appId,
scoutType,
});
const success = await this.exec('DEL', key);
return this.isSuccessful(success);
}
async getSettings(bCreate = false) {
let settings = this.cache?.getSettings();
if (settings) {
return settings;
}
else {
if (bCreate) {
const packageJson = await Promise.resolve().then(() => __importStar(require('../../../../package.json')));
const version = packageJson['version'] || '0.0.0';
settings = { namespace: key_1.HMNS, version };
await this.setSettings(settings);
return settings;
}
}
throw new Error('settings not found');
}
async setSettings(manifest) {
//HotMesh heartbeat. If a connection is made, the version will be set
const params = {};
const key = this.mintKey(key_1.KeyType.HOTMESH, params);
return await this.storeClient[this.commands.hset](key, manifest);
}
async reserveSymbolRange(target, size, type, tryCount = 1) {
const rangeKey = this.mintKey(key_1.KeyType.SYMKEYS, { appId: this.appId });
const symbolKey = this.mintKey(key_1.KeyType.SYMKEYS, {
activityId: target,
appId: this.appId,
});
//reserve the slot in a `pending` state (range will be established in the next step)
const response = await this.storeClient[this.commands.hsetnx](rangeKey, target, '?:?');
if (response) {
//if the key didn't exist, set the inclusive range and seed metadata fields
const upperLimit = await this.storeClient[this.commands.hincrby](rangeKey, ':cursor', size);
const lowerLimit = upperLimit - size;
const inclusiveRange = `${lowerLimit}:${upperLimit - 1}`;
await this.storeClient[this.commands.hset](rangeKey, target, inclusiveRange);
const metadataSeeds = this.seedSymbols(target, type, lowerLimit);
await this.storeClient[this.commands.hset](symbolKey, metadataSeeds);
return [lowerLimit + serializer_1.MDATA_SYMBOLS.SLOTS, upperLimit - 1, {}];
}
else {
//if the key already existed, get the lower limit and add the number of symbols
const range = await this.storeClient[this.commands.hget](rangeKey, target);
const [lowerLimitString] = range.split(':');
if (lowerLimitString === '?') {
await (0, utils_1.sleepFor)(tryCount * 1000);
if (tryCount < 5) {
return this.reserveSymbolRange(target, size, type, tryCount + 1);
}
else {
throw new Error('Symbol range reservation failed due to deployment contention');
}
}
else {
const lowerLimit = parseInt(lowerLimitString, 10);
const symbols = await this.storeClient[this.commands.hgetall](symbolKey);
const symbolCount = Object.keys(symbols).length;
const actualLowerLimit = lowerLimit + serializer_1.MDATA_SYMBOLS.SLOTS + symbolCount;
const upperLimit = Number(lowerLimit + size - 1);
return [actualLowerLimit, upperLimit, symbols];
}
}
}
async getAllSymbols() {
//get hash with all reserved symbol ranges
const rangeKey = this.mintKey(key_1.KeyType.SYMKEYS, { appId: this.appId });
const ranges = await this.storeClient[this.commands.hgetall](rangeKey);
const rangeKeys = Object.keys(ranges).sort();
delete rangeKeys[':cursor'];
const transaction = this.transact();
for (const rangeKey of rangeKeys) {
const symbolKey = this.mintKey(key_1.KeyType.SYMKEYS, {
activityId: rangeKey,
appId: this.appId,
});
transaction[this.commands.hgetall](symbolKey);
}
const results = (await transaction.exec());
const symbolSets = {};
results.forEach((result, index) => {
if (result) {
let vals;
if (Array.isArray(result) && result.length === 2) {
vals = result[1];
}
else {
vals = result;
}
for (const [key, value] of Object.entries(vals)) {
symbolSets[value] = key.startsWith(rangeKeys[index])
? key
: `${rangeKeys[index]}/${key}`;
}
}
});
return symbolSets;
}
async getSymbols(activityId) {
let symbols = this.cache.getSymbols(this.appId, activityId);
if (symbols) {
return symbols;
}
else {
const params = { activityId, appId: this.appId };
const key = this.mintKey(key_1.KeyType.SYMKEYS, params);
symbols = (await this.storeClient[this.commands.hgetall](key));
this.cache.setSymbols(this.appId, activityId, symbols);
return symbols;
}
}
async addSymbols(activityId, symbols) {
if (!symbols || !Object.keys(symbols).length)
return false;
const params = { activityId, appId: this.appId };
const key = this.mintKey(key_1.KeyType.SYMKEYS, params);
const success = await this.storeClient[this.commands.hset](key, symbols);
this.cache.deleteSymbols(this.appId, activityId);
return success > 0;
}
seedSymbols(target, type, startIndex) {
if (type === 'JOB') {
return this.seedJobSymbols(startIndex);
}
return this.seedActivitySymbols(startIndex, target);
}
seedJobSymbols(startIndex) {
const hash = {};
serializer_1.MDATA_SYMBOLS.JOB.KEYS.forEach((key) => {
hash[`metadata/${key}`] = (0, utils_1.getSymKey)(startIndex);
startIndex++;
});
return hash;
}
seedActivitySymbols(startIndex, activityId) {
const hash = {};
serializer_1.MDATA_SYMBOLS.ACTIVITY.KEYS.forEach((key) => {
hash[`${activityId}/output/metadata/${key}`] = (0, utils_1.getSymKey)(startIndex);
startIndex++;
});
return hash;
}
async getSymbolValues() {
let symvals = this.cache.getSymbolValues(this.appId);
if (symvals) {
return symvals;
}
else {
const key = this.mintKey(key_1.KeyType.SYMVALS, { appId: this.appId });
symvals = await this.storeClient[this.commands.hgetall](key);
this.cache.setSymbolValues(this.appId, symvals);
return symvals;
}
}
async addSymbolValues(symvals) {
if (!symvals || !Object.keys(symvals).length)
return false;
const key = this.mintKey(key_1.KeyType.SYMVALS, { appId: this.appId });
const success = await this.storeClient[this.commands.hset](key, symvals);
this.cache.deleteSymbolValues(this.appId);
return this.isSuccessful(success);
}
async getSymbolKeys(symbolNames) {
const symbolLookups = [];
for (const symbolName of symbolNames) {
symbolLookups.push(this.getSymbols(symbolName));
}
const symbolSets = await Promise.all(symbolLookups);
const symKeys = {};
for (const symbolName of symbolNames) {
symKeys[symbolName] = symbolSets.shift();
}
return symKeys;
}
async getApp(id, refresh = false) {
let app = this.cache.getApp(id);
if (refresh || !(app && Object.keys(app).length > 0)) {
const params = { appId: id };
const key = this.mintKey(key_1.KeyType.APP, params);
const sApp = await this.storeClient[this.commands.hgetall](key);
if (!sApp)
return null;
app = {};
for (const field in sApp) {
try {
if (field === 'active') {
app[field] = sApp[field] === 'true';
}
else {
app[field] = sApp[field];
}
}
catch (e) {
app[field] = sApp[field];
}
}
this.cache.setApp(id, app);
}
return app;
}
async setApp(id, version) {
const params = { appId: id };
const key = this.mintKey(key_1.KeyType.APP, params);
const versionId = `versions/${version}`;
const payload = {
id,
version,
[versionId]: `deployed:${(0, utils_1.formatISODate)(new Date())}`,
};
await this.storeClient[this.commands.hset](key, payload);
this.cache.setApp(id, payload);
return payload;
}
async activateAppVersion(id, version) {
const params = { appId: id };
const key = this.mintKey(key_1.KeyType.APP, params);
const versionId = `versions/${version}`;
const app = await this.getApp(id, true);
if (app && app[versionId]) {
const payload = {
id,
version: version.toString(),
[versionId]: `activated:${(0, utils_1.formatISODate)(new Date())}`,
active: true,
};
Object.entries(payload).forEach(([key, value]) => {
payload[key] = value.toString();
});
await this.storeClient[this.commands.hset](key, payload);
return true;
}
throw new Error(`Version ${version} does not exist for app ${id}`);
}
async registerAppVersion(appId, version) {
const params = { appId };
const key = this.mintKey(key_1.KeyType.APP, params);
const payload = {
id: appId,
version: version.toString(),
[`versions/${version}`]: (0, utils_1.formatISODate)(new Date()),
};
return await this.storeClient[this.commands.hset](key, payload);
}
async setStats(jobKey, jobId, dateTime, stats, appVersion, transaction) {
const params = {
appId: appVersion.id,
jobId,
jobKey,
dateTime,
};
const privateMulti = transaction || this.transact();
if (stats.general.length) {
const generalStatsKey = this.mintKey(key_1.KeyType.JOB_STATS_GENERAL, params);
for (const { target, value } of stats.general) {
privateMulti[this.commands.hincrbyfloat](generalStatsKey, target, value);
}
}
for (const { target, value } of stats.index) {
const indexParams = { ...params, facet: target };
const indexStatsKey = this.mintKey(key_1.KeyType.JOB_STATS_INDEX, indexParams);
privateMulti[this.commands.rpush](indexStatsKey, value.toString());
}
for (const { target, value } of stats.median) {
const medianParams = { ...params, facet: target };
const medianStatsKey = this.mintKey(key_1.KeyType.JOB_STATS_MEDIAN, medianParams);
this.zAdd(medianStatsKey, value, target, privateMulti);
}
if (!transaction) {
return await privateMulti.exec();
}
}
hGetAllResult(result) {
//default response signature uses 'redis' NPM Package format
return result;
}
async getJobStats(jobKeys) {
const transaction = this.transact();
for (const jobKey of jobKeys) {
transaction[this.commands.hgetall](jobKey);
}
const results = await transaction.exec();
const output = {};
for (const [index, result] of results.entries()) {
const key = jobKeys[index];
const statsHash = this.hGetAllResult(result);
if (statsHash && Object.keys(statsHash).length > 0) {
const resolvedStatsHash = { ...statsHash };
for (const [key, val] of Object.entries(resolvedStatsHash)) {
resolvedStatsHash[key] = Number(val);
}
output[key] = resolvedStatsHash;
}
else {
output[key] = {};
}
}
return output;
}
async getJobIds(indexKeys, idRange) {
const transaction = this.transact();
for (const idsKey of indexKeys) {
transaction[this.commands.lrange](idsKey, idRange[0], idRange[1]); //0,-1 returns all ids
}
const results = await transaction.exec();
const output = {};
for (const [index, result] of results.entries()) {
const key = indexKeys[index];
//todo: resolve this discrepancy between redis/ioredis
const idsList = result[1] || result;
if (idsList && idsList.length > 0) {
output[key] = idsList;
}
else {
output[key] = [];
}
}
return output;
}
async setStatus(collationKeyStatus, jobId, appId, transaction) {
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId, jobId });
return await (transaction || this.storeClient)[this.commands.hincrbyfloat](jobKey, ':', collationKeyStatus);
}
async getStatus(jobId, appId) {
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId, jobId });
const status = await this.storeClient[this.commands.hget](jobKey, ':');
if (status === null) {
throw new Error(`Job ${jobId} not found`);
}
return Number(status);
}
async setState({ ...state }, status, jobId, symbolNames, dIds, transaction) {
delete state['metadata/js'];
const hashKey = this.mintKey(key_1.KeyType.JOB_STATE, {
appId: this.appId,
jobId,
});
const symKeys = await this.getSymbolKeys(symbolNames);
const symVals = await this.getSymbolValues();
this.serializer.resetSymbols(symKeys, symVals, dIds);
const hashData = this.serializer.package(state, symbolNames);
if (status !== null) {
hashData[':'] = status.toString();
}
else {
delete hashData[':'];
}
await (transaction || this.storeClient)[this.commands.hset](hashKey, hashData);
return jobId;
}
/**
* Returns custom search fields and values.
* NOTE: The `fields` param should NOT prefix items with an underscore.
* NOTE: Literals are allowed if quoted.
*/
async getQueryState(jobId, fields) {
const key = this.mintKey(key_1.KeyType.JOB_STATE, { appId: this.appId, jobId });
const _fields = fields.map((field) => {
if (field.startsWith('"')) {
return field.slice(1, -1);
}
return `_${field}`;
});
const jobDataArray = await this.storeClient[this.commands.hmget](key, _fields);
const jobData = {};
fields.forEach((field, index) => {
if (field.startsWith('"')) {
field = field.slice(1, -1);
}
jobData[field] = jobDataArray[index];
});
return jobData;
}
async getState(jobId, consumes, dIds) {
//get abbreviated field list (the symbols for the paths)
const key = this.mintKey(key_1.KeyType.JOB_STATE, { appId: this.appId, jobId });
const symbolNames = Object.keys(consumes);
const symKeys = await this.getSymbolKeys(symbolNames);
this.serializer.resetSymbols(symKeys, {}, dIds);
const fields = this.serializer.abbreviate(consumes, symbolNames, [':']);
const jobDataArray = await this.storeClient[this.commands.hmget](key, fields);
const jobData = {};
let atLeast1 = false; //if status field (':') isn't present assume 404
fields.forEach((field, index) => {
if (jobDataArray[index]) {
atLeast1 = true;
}
jobData[field] = jobDataArray[index];
});
if (atLeast1) {
const symVals = await this.getSymbolValues();
this.serializer.resetSymbols(symKeys, symVals, dIds);
const state = this.serializer.unpackage(jobData, symbolNames);
let status = 0;
if (state[':']) {
status = Number(state[':']);
state[`metadata/js`] = status;
delete state[':'];
}
return [state, status];
}
else {
throw new errors_1.GetStateError(jobId);
}
}
async getRaw(jobId) {
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, {
appId: this.appId,
jobId,
});
const job = await this.storeClient[this.commands.hgetall](jobKey);
if (!job) {
throw new errors_1.GetStateError(jobId);
}
return job;
}
/**
* collate is a generic method for incrementing a value in a hash
* in order to track their progress during processing.
*/
async collate(jobId, activityId, amount, dIds, transaction) {
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, {
appId: this.appId,
jobId,
});
const collationKey = `${activityId}/output/metadata/as`; //activity state
const symbolNames = [activityId];
const symKeys = await this.getSymbolKeys(symbolNames);
const symVals = await this.getSymbolValues();
this.serializer.resetSymbols(symKeys, symVals, dIds);
const payload = { [collationKey]: amount.toString() };
const hashData = this.serializer.package(payload, symbolNames);
const targetId = Object.keys(hashData)[0];
return await (transaction || this.storeClient)[this.commands.hincrbyfloat](jobKey, targetId, amount);
}
/**
* Synthentic collation affects those activities in the graph
* that represent the synthetic DAG that was materialized during compilation;
* Synthetic collation distinguishes `re-entry due to failure` from
* `purposeful re-entry`.
*/
async collateSynthetic(jobId, guid, amount, transaction) {
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, {
appId: this.appId,
jobId,
});
return await (transaction || this.storeClient)[this.commands.hincrbyfloat](jobKey, guid, amount.toString());
}
async setStateNX(jobId, appId, status, entity) {
const hashKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId, jobId });
const result = await this.storeClient[this.commands.hsetnx](hashKey, ':', status?.toString() ?? '1');
return this.isSuccessful(result);
}
async getSchema(activityId, appVersion) {
const schema = this.cache.getSchema(appVersion.id, appVersion.version, activityId);
if (schema) {
return schema;
}
else {
const schemas = await this.getSchemas(appVersion);
return schemas[activityId];
}
}
async getSchemas(appVersion) {
let schemas = this.cache.getSchemas(appVersion.id, appVersion.version);
if (schemas && Object.keys(schemas).length > 0) {
return schemas;
}
else {
const params = {
appId: appVersion.id,
appVersion: appVersion.version,
};
const key = this.mintKey(key_1.KeyType.SCHEMAS, params);
schemas = {};
const hash = await this.storeClient[this.commands.hgetall](key);
Object.entries(hash).forEach(([key, value]) => {
schemas[key] = JSON.parse(value);
});
this.cache.setSchemas(appVersion.id, appVersion.version, schemas);
return schemas;
}
}
async setSchemas(schemas, appVersion) {
const params = {
appId: appVersion.id,
appVersion: appVersion.version,
};
const key = this.mintKey(key_1.KeyType.SCHEMAS, params);
const _schemas = { ...schemas };
Object.entries(_schemas).forEach(([key, value]) => {
_schemas[key] = JSON.stringify(value);
});
const response = await this.storeClient[this.commands.hset](key, _schemas);
this.cache.setSchemas(appVersion.id, appVersion.version, schemas);
return response;
}
async setSubscriptions(subscriptions, appVersion) {
const params = {
appId: appVersion.id,
appVersion: appVersion.version,
};
const key = this.mintKey(key_1.KeyType.SUBSCRIPTIONS, params);
const _subscriptions = { ...subscriptions };
Object.entries(_subscriptions).forEach(([key, value]) => {
_subscriptions[key] = JSON.stringify(value);
});
const status = await this.storeClient[this.commands.hset](key, _subscriptions);
this.cache.setSubscriptions(appVersion.id, appVersion.version, subscriptions);
return this.isSuccessful(status);
}
async getSubscriptions(appVersion) {
let subscriptions = this.cache.getSubscriptions(appVersion.id, appVersion.version);
if (subscriptions && Object.keys(subscriptions).length > 0) {
return subscriptions;
}
else {
const params = {
appId: appVersion.id,
appVersion: appVersion.version,
};
const key = this.mintKey(key_1.KeyType.SUBSCRIPTIONS, params);
subscriptions =
await this.storeClient[this.commands.hgetall](key) || {};
Object.entries(subscriptions).forEach(([key, value]) => {
subscriptions[key] = JSON.parse(value);
});
this.cache.setSubscriptions(appVersion.id, appVersion.version, subscriptions);
return subscriptions;
}
}
async getSubscription(topic, appVersion) {
const subscriptions = await this.getSubscriptions(appVersion);
return subscriptions[topic];
}
async setTransitions(transitions, appVersion) {
const params = {
appId: appVersion.id,
appVersion: appVersion.version,
};
const key = this.mintKey(key_1.KeyType.SUBSCRIPTION_PATTERNS, params);
const _subscriptions = { ...transitions };
Object.entries(_subscriptions).forEach(([key, value]) => {
_subscriptions[key] = JSON.stringify(value);
});
if (Object.keys(_subscriptions).length !== 0) {
const response = await this.storeClient[this.commands.hset](key, _subscriptions);
this.cache.setTransitions(appVersion.id, appVersion.version, transitions);
return response;
}
}
async getTransitions(appVersion) {
let transitions = this.cache.getTransitions(appVersion.id, appVersion.version);
if (transitions && Object.keys(transitions).length > 0) {
return transitions;
}
else {
const params = {
appId: appVersion.id,
appVersion: appVersion.version,
};
const key = this.mintKey(key_1.KeyType.SUBSCRIPTION_PATTERNS, params);
transitions = {};
const hash = await this.storeClient[this.commands.hgetall](key);
Object.entries(hash).forEach(([key, value]) => {
transitions[key] = JSON.parse(value);
});
this.cache.setTransitions(appVersion.id, appVersion.version, transitions);
return transitions;
}
}
async setHookRules(hookRules) {
const key = this.mintKey(key_1.KeyType.HOOKS, { appId: this.appId });
const _hooks = {};
Object.entries(hookRules).forEach(([key, value]) => {
_hooks[key.toString()] = JSON.stringify(value);
});
if (Object.keys(_hooks).length !== 0) {
const response = await this.storeClient[this.commands.hset](key, _hooks);
this.cache.setHookRules(this.appId, hookRules);
return response;
}
}
async getHookRules() {
let patterns = this.cache.getHookRules(this.appId);
if (patterns && Object.keys(patterns).length > 0) {
return patterns;
}
else {
const key = this.mintKey(key_1.KeyType.HOOKS, { appId: this.appId });
const _hooks = await this.storeClient[this.commands.hgetall](key);
patterns = {};
Object.entries(_hooks).forEach(([key, value]) => {
patterns[key] = JSON.parse(value);
});
this.cache.setHookRules(this.appId, patterns);
return patterns;
}
}
async setHookSignal(hook, transaction) {
const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
const { topic, resolved, jobId } = hook;
const signalKey = `${topic}:${resolved}`;
await this.setnxex(`${key}:${signalKey}`, jobId, Math.max(hook.expire, enums_1.HMSH_SIGNAL_EXPIRE));
}
async getHookSignal(topic, resolved) {
const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
const response = await this.storeClient[this.commands.get](`${key}:${topic}:${resolved}`);
return response ? response.toString() : undefined;
}
async deleteHookSignal(topic, resolved) {
const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
const response = await this.storeClient[this.commands.del](`${key}:${topic}:${resolved}`);
return response ? Number(response) : undefined;
}
async addTaskQueues(keys) {
const transaction = this.transact();
const zsetKey = this.mintKey(key_1.KeyType.WORK_ITEMS, { appId: this.appId });
for (const key of keys) {
transaction[this.commands.zadd](zsetKey, { score: Date.now().toString(), value: key }, { NX: true });
}
await transaction.exec();
}
async getActiveTaskQueue() {
let workItemKey = this.cache.getActiveTaskQueue(this.appId) || null;
if (!workItemKey) {
const zsetKey = this.mintKey(key_1.KeyType.WORK_ITEMS, { appId: this.appId });
const result = await this.storeClient[this.commands.zrange](zsetKey, 0, 0);
workItemKey = result.length > 0 ? result[0] : null;
if (workItemKey) {
this.cache.setWorkItem(this.appId, workItemKey);
}
}
return workItemKey;
}
async deleteProcessedTaskQueue(workItemKey, key, processedKey, scrub = false) {
const zsetKey = this.mintKey(key_1.KeyType.WORK_ITEMS, { appId: this.appId });
const didRemove = await this.storeClient[this.commands.zrem](zsetKey, workItemKey);
if (didRemove) {
if (scrub) {
//indexes can be designed to be self-cleaning; `engine.hookAll` exposes this option
this.storeClient[this.commands.expire](processedKey, 0);
this.storeClient[this.commands.expire](key.split(':').slice(0, 5).join(':'), 0);
}
else {
await this.storeClient[this.commands.rename](processedKey, key);
}
}
this.cache.removeWorkItem(this.appId);
}
async processTaskQueue(sourceKey, destinationKey) {
return await this.storeClient[this.commands.lmove](sourceKey, destinationKey, 'LEFT', 'RIGHT');
}
async expireJob(jobId, inSeconds, redisMulti) {
if (!isNaN(inSeconds) && inSeconds > 0) {
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, {
appId: this.appId,
jobId,
});
await (redisMulti || this.storeClient)[this.commands.expire](jobKey, inSeconds);
}
}
async getDependencies(jobId) {
const depParams = { appId: this.appId, jobId };
const depKey = this.mintKey(key_1.KeyType.JOB_DEPENDENTS, depParams);
return this.storeClient[this.commands.lrange](depKey, 0, -1);
}
/**
* registers a hook activity to be awakened (uses ZSET to
* store the 'sleep group' and LIST to store the events
* for the given sleep group. Sleep groups are
* organized into 'n'-second blocks (LISTS))
*/
async registerTimeHook(jobId, gId, activityId, type, deletionTime, dad, transaction) {
const listKey = this.mintKey(key_1.KeyType.TIME_RANGE, {
appId: this.appId,
timeValue: deletionTime,
});
//construct the composite key (the key has enough info to signal the hook)
const timeEvent = [type, activityId, gId, dad, jobId].join(key_1.VALSEP);
const len = await (transaction || this.storeClient)[this.commands.rpush](listKey, timeEvent);
if (transaction || len === 1) {
const zsetKey = this.mintKey(key_1.KeyType.TIME_RANGE, { appId: this.appId });
await this.zAdd(zsetKey, deletionTime.toString(), listKey, transaction);
}
}
async getNextTask(listKey) {
const zsetKey = this.mintKey(key_1.KeyType.TIME_RANGE, { appId: this.appId });
listKey = listKey || await this.zRangeByScore(zsetKey, 0, Date.now());
if (listKey) {
let [pType, pKey] = this.resolveTaskKeyContext(listKey);
const timeEvent = await this.storeClient[this.commands.lpop](pKey);
if (timeEvent) {
//deconstruct composite key
let [type, activityId, gId, _pd, ...jobId] = timeEvent.split(key_1.VALSEP);
const jid = jobId.join(key_1.VALSEP);
if (type === 'delist') {
pType = 'delist';
}
else if (type === 'child') {
pType = 'child';
}
else if (type === 'expire-child') {
type = 'expire';
}
return [listKey, jid, gId, activityId, pType];
}
await this.storeClient[this.commands.zrem](zsetKey, listKey);
return true;
}
return false;
}
/**
* when processing time jobs, the target LIST ID returned
* from the ZSET query can be prefixed to denote what to
* do with the work list. (not everything is known in advance,
* so the ZSET key defines HOW to approach the work in the
* generic LIST (lists typically contain target job ids)
* @param {string} listKey - composite key
*/
resolveTaskKeyContext(listKey) {
if (listKey.startsWith(`${key_1.TYPSEP}INTERRUPT`)) {
return ['interrupt', listKey.split(key_1.TYPSEP)[2]];
}
else if (listKey.startsWith(`${key_1.TYPSEP}EXPIRE`)) {
return ['expire', listKey.split(key_1.TYPSEP)[2]];
}
else {
return ['sleep', listKey];
}
}
/**
* Interrupts a job and sets sets a job error (410), if 'throw'!=false.
* This method is called by the engine and not by an activity and is
* followed by a call to execute job completion/cleanup tasks
* associated with a job completion event.
*
* Todo: move most of this logic to the engine (too much logic for the store)
*/
async interrupt(topic, jobId, options = {}) {
try {
//verify job exists
const status = await this.getStatus(jobId, this.appId);
if (status <= 0) {
//verify still active; job already completed
throw new Error(`Job ${jobId} already completed`);
}
//decrement job status (:) by 1bil
const amount = -1000000000;
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, {
appId: this.appId,
jobId,
});
const result = await this.storeClient[this.commands.hincrbyfloat](jobKey, ':', amount);
if (result <= amount) {
//verify active state; job already interrupted
throw new Error(`Job ${jobId} already completed`);
}
//persist the error unless specifically told not to
if (options.throw !== false) {
const errKey = `metadata/err`; //job errors are stored at the path `metadata/err`
const symbolNames = [`$${topic}`]; //the symbol for `metadata/err`
const symKeys = await this.getSymbolKeys(symbolNames);
const symVals = await this.getSymbolValues();
this.serializer.resetSymbols(symKeys, symVals, {});
//persists the standard 410 error (job is `gone`)
const err = JSON.stringify({
code: options.code ?? enums_1.HMSH_CODE_INTERRUPT,
message: options.reason ?? `job [${jobId}] interrupted`,
stack: options.stack ?? '',
job_id: jobId,
});
const payload = { [errKey]: amount.toString() };
const hashData = this.serializer.package(payload, symbolNames);
const errSymbol = Object.keys(hashData)[0];
await this.storeClient[this.commands.hset](jobKey, errSymbol, err);
}
}
catch (e) {
if (!options.suppress) {
throw e;
}
else {
this.logger.debug('suppressed-interrupt', { message: e.message });
}
}
}
async scrub(jobId) {
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, {
appId: this.appId,
jobId,
});
await this.storeClient[this.commands.del](jobKey);
}
async findJobs(queryString = '*', limit = 1000, batchSize = 1000, cursor = '0') {
const matchKey = this.mintKey(key_1.KeyType.JOB_STATE, {
appId: this.appId,
jobId: queryString,
});
let keys;
const matchingKeys = [];
do {
const output = (await this.exec('SCAN', cursor, 'MATCH', matchKey, 'COUNT', batchSize.toString()));
if (Array.isArray(output)) {
[cursor, keys] = output;
for (const key of [...keys]) {
matchingKeys.push(key);
}
if (matchingKeys.length >= limit) {
break;
}
}
else {
break;
}
} while (cursor !== '0');
return [cursor, matchingKeys];
}
async findJobFields(jobId, fieldMatchPattern = '*', limit = 1000, batchSize = 1000, cursor = '0') {
let fields = [];
const matchingFields = {};
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, {
appId: this.appId,
jobId,
});
let len = 0;
do {
const output = (await this.exec('HSCAN', jobKey, cursor, 'MATCH', fieldMatchPattern, 'COUNT', batchSize.toString()));
if (Array.isArray(output)) {
[cursor, fields] = output;
for (let i = 0; i < fields.length; i += 2) {
len++;
matchingFields[fields[i]] = fields[i + 1];
}
}
else {
break;
}
} while (cursor !== '0' && len < limit);
return [cursor, matchingFields];
}
async setThrottleRate(options) {
const key = this.mintKey(key_1.KeyType.THROTTLE_RATE, { appId: this.appId });
//engine guids are session specific. no need to persist
if (options.guid) {
return;
}
//if a topic, update one
const rate = options.throttle.toString();
if (options.topic) {
await this.storeClient[this.commands.hset](key, {
[options.topic]: rate,
});
}
else {
//if no topic, update all
const transaction = this.transact();
transaction[this.commands.del](key);
transaction[this.commands.hset](key, { ':': rate });
await transaction.exec();
}
}
async getThrottleRates() {
const key = this.mintKey(key_1.KeyType.THROTTLE_RATE, { appId: this.appId });
const response = await this.storeClient[this.commands.hgetall](key);
return response ?? {};
}
async getThrottleRate(topic) {
//always return a valid number range
const resolveRate = (response, topic) => {
const rate = topic in response ? Number(response[topic]) : 0;
if (isNaN(rate))
return 0;
if (rate == -1)
return enums_1.MAX_DELAY;
return Math.max(Math.min(rate, enums_1.MAX_DELAY), 0);
};
const response = await this.getThrottleRates();
const globalRate = resolveRate(response, ':');
if (topic === ':' || !(topic in response)) {
//use global rate unless worker specifies rate
return globalRate;
}
return resolveRate(response, topic);
}
}
exports.RedisStoreBase = RedisStoreBase;