UNPKG

@hotmeshio/hotmesh

Version:

Permanent-Memory Workflows & AI Agents

981 lines (980 loc) 41.1 kB
"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;