@hotmeshio/hotmesh 0.0.47 → 0.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # HotMesh
2
2
  ![alpha release](https://img.shields.io/badge/release-alpha-yellow)
3
3
 
4
- HotMesh elevates Redis from an in-memory data cache to a distributed orchestration engine.
4
+ HotMesh transforms Redis into a distributed orchestration engine.
5
5
 
6
6
  *Write functions in your own preferred style, and let Redis govern their execution, reliably and durably.*
7
7
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
4
4
  "description": "Unbreakable Workflows",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -187,13 +187,18 @@ class MeshOSService {
187
187
  }
188
188
  else {
189
189
  //limit which hash fields to return
190
- if (options.return?.length) {
191
- args.push('RETURN');
192
- args.push(options.return.length.toString());
193
- options.return.forEach(returnField => {
190
+ args.push('RETURN');
191
+ args.push(((options.return?.length ?? 0) + 1).toString());
192
+ args.push('$');
193
+ options.return?.forEach(returnField => {
194
+ if (returnField.startsWith('"')) {
195
+ //allow literal values to be requested
196
+ args.push(returnField.slice(1, -1));
197
+ }
198
+ else {
194
199
  args.push(`_${returnField}`);
195
- });
196
- }
200
+ }
201
+ });
197
202
  //paginate
198
203
  if (options.limit) {
199
204
  args.push('LIMIT', options.limit.start.toString(), options.limit.size.toString());
@@ -30,10 +30,29 @@ export declare class Search {
30
30
  * calling any method that produces side effects (changes the value)
31
31
  */
32
32
  getSearchSessionGuid(): string;
33
- set(...args: string[]): Promise<void>;
33
+ /**
34
+ * Sets the fields listed in args. Returns the
35
+ * count of new fields that were set (does not
36
+ * count fields that were updated)
37
+ */
38
+ set(...args: string[]): Promise<number>;
34
39
  get(key: string): Promise<string>;
35
40
  mget(...args: string[]): Promise<string[]>;
41
+ /**
42
+ * Deletes the fields listed in args. Returns the
43
+ * count of fields that were deleted.
44
+ */
36
45
  del(...args: string[]): Promise<number | void>;
46
+ /**
47
+ * Increments the value of a field by the given amount. Returns the
48
+ * new value of the field after the increment. Can be
49
+ * used to decrement the value of a field by specifying a negative.
50
+ */
37
51
  incr(key: string, val: number): Promise<number>;
52
+ /**
53
+ * Multiplies the value of a field by the given amount. Returns the
54
+ * new value of the field after the multiplication. NOTE:
55
+ * this is exponential multiplication.
56
+ */
38
57
  mult(key: string, val: number): Promise<number>;
39
58
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Search = void 0;
4
4
  const key_1 = require("../../modules/key");
5
+ const storage_1 = require("../../modules/storage");
5
6
  class Search {
6
7
  constructor(workflowId, hotMeshClient, searchSessionId) {
7
8
  this.searchSessionIndex = 0;
@@ -57,9 +58,15 @@ class Search {
57
58
  * @returns {Promise<string[]>} - the list of search indexes
58
59
  */
59
60
  static async listSearchIndexes(hotMeshClient) {
60
- const store = hotMeshClient.engine.store;
61
- const searchIndexes = await store.exec('FT._LIST');
62
- return searchIndexes;
61
+ try {
62
+ const store = hotMeshClient.engine.store;
63
+ const searchIndexes = await store.exec('FT._LIST');
64
+ return searchIndexes;
65
+ }
66
+ catch (err) {
67
+ hotMeshClient.engine.logger.info('durable-client-search-list-err', { err });
68
+ return [];
69
+ }
63
70
  }
64
71
  /**
65
72
  * increments the index to return a unique search session guid when
@@ -69,18 +76,28 @@ class Search {
69
76
  //return the search session as it would exist in the search session index
70
77
  return `${this.searchSessionId}-${this.searchSessionIndex++}-`;
71
78
  }
79
+ /**
80
+ * Sets the fields listed in args. Returns the
81
+ * count of new fields that were set (does not
82
+ * count fields that were updated)
83
+ */
72
84
  async set(...args) {
73
85
  const ssGuid = this.getSearchSessionGuid();
74
- const ssGuidValue = Number(await this.store.exec('HINCRBYFLOAT', this.jobId, ssGuid, '1'));
75
- if (ssGuidValue === 1) {
76
- const safeArgs = [];
77
- for (let i = 0; i < args.length; i += 2) {
78
- const key = this.safeKey(args[i]);
79
- const value = args[i + 1].toString();
80
- safeArgs.push(key, value);
81
- }
82
- await this.store.exec('HSET', this.jobId, ...safeArgs);
86
+ const store = storage_1.asyncLocalStorage.getStore();
87
+ const replay = store?.get('replay') ?? {};
88
+ if (ssGuid in replay) {
89
+ return Number(replay[ssGuid]);
83
90
  }
91
+ const safeArgs = [];
92
+ for (let i = 0; i < args.length; i += 2) {
93
+ const key = this.safeKey(args[i]);
94
+ const value = args[i + 1].toString();
95
+ safeArgs.push(key, value);
96
+ }
97
+ const fieldCount = await this.store.exec('HSET', this.jobId, ...safeArgs);
98
+ //no need to wait; set this interim value in the replay
99
+ this.store.exec('HSET', this.jobId, ssGuid, fieldCount.toString());
100
+ return Number(fieldCount);
84
101
  }
85
102
  async get(key) {
86
103
  try {
@@ -104,32 +121,63 @@ class Search {
104
121
  return [];
105
122
  }
106
123
  }
124
+ /**
125
+ * Deletes the fields listed in args. Returns the
126
+ * count of fields that were deleted.
127
+ */
107
128
  async del(...args) {
108
129
  const ssGuid = this.getSearchSessionGuid();
109
- const ssGuidValue = Number(await this.store.exec('HINCRBYFLOAT', this.jobId, ssGuid, '1'));
110
- if (ssGuidValue === 1) {
111
- const safeArgs = [];
112
- for (let i = 0; i < args.length; i++) {
113
- safeArgs.push(this.safeKey(args[i]));
114
- }
115
- const response = await this.store.exec('HDEL', this.jobId, ...safeArgs);
116
- return isNaN(response) ? undefined : Number(response);
130
+ const store = storage_1.asyncLocalStorage.getStore();
131
+ const replay = store?.get('replay') ?? {};
132
+ if (ssGuid in replay) {
133
+ return Number(replay[ssGuid]);
134
+ }
135
+ const safeArgs = [];
136
+ for (let i = 0; i < args.length; i++) {
137
+ safeArgs.push(this.safeKey(args[i]));
117
138
  }
139
+ const response = await this.store.exec('HDEL', this.jobId, ...safeArgs);
140
+ const formattedResponse = isNaN(response) ? 0 : Number(response);
141
+ //no need to wait; set this interim value in the replay
142
+ this.store.exec('HSET', this.jobId, ssGuid, formattedResponse.toString());
143
+ return formattedResponse;
118
144
  }
145
+ /**
146
+ * Increments the value of a field by the given amount. Returns the
147
+ * new value of the field after the increment. Can be
148
+ * used to decrement the value of a field by specifying a negative.
149
+ */
119
150
  async incr(key, val) {
120
151
  const ssGuid = this.getSearchSessionGuid();
121
- const ssGuidValue = Number(await this.store.exec('HINCRBYFLOAT', this.jobId, ssGuid, '1'));
122
- if (ssGuidValue === 1) {
123
- return Number(await this.store.exec('HINCRBYFLOAT', this.jobId, this.safeKey(key), val.toString()));
152
+ const store = storage_1.asyncLocalStorage.getStore();
153
+ const replay = store?.get('replay') ?? {};
154
+ if (ssGuid in replay) {
155
+ return Number(replay[ssGuid]);
124
156
  }
157
+ const num = await this.store.exec('HINCRBYFLOAT', this.jobId, this.safeKey(key), val.toString());
158
+ //no need to wait; set this interim value in the replay
159
+ this.store.exec('HSET', this.jobId, ssGuid, num.toString());
160
+ return Number(num);
125
161
  }
162
+ /**
163
+ * Multiplies the value of a field by the given amount. Returns the
164
+ * new value of the field after the multiplication. NOTE:
165
+ * this is exponential multiplication.
166
+ */
126
167
  async mult(key, val) {
127
168
  const ssGuid = this.getSearchSessionGuid();
169
+ const store = storage_1.asyncLocalStorage.getStore();
170
+ const replay = store?.get('replay') ?? {};
171
+ if (ssGuid in replay) {
172
+ return Math.exp(Number(replay[ssGuid]));
173
+ }
128
174
  const ssGuidValue = Number(await this.store.exec('HINCRBYFLOAT', this.jobId, ssGuid, '1'));
129
175
  if (ssGuidValue === 1) {
130
176
  const log = Math.log(val);
131
- const logTotal = Number(await this.store.exec('HINCRBYFLOAT', this.jobId, this.safeKey(key), log.toString()));
132
- return Math.exp(logTotal);
177
+ const logTotal = await this.store.exec('HINCRBYFLOAT', this.jobId, this.safeKey(key), log.toString());
178
+ //no need to wait; set this interim value in the replay
179
+ this.store.exec('HSET', this.jobId, ssGuid, logTotal.toString());
180
+ return Math.exp(Number(logTotal));
133
181
  }
134
182
  }
135
183
  }
@@ -164,16 +164,26 @@ class WorkerService {
164
164
  // garbage collect (expire) this job when originJobId is expired
165
165
  context.set('originJobId', workflowInput.originJobId);
166
166
  }
167
+ let replayQuery = '';
167
168
  if (workflowInput.workflowDimension) {
168
169
  //every hook function runs in an isolated dimension controlled
169
170
  //by the index assigned when the signal was received; even if the
170
171
  //hook function re-runs, its scope will always remain constant
171
172
  context.set('workflowDimension', workflowInput.workflowDimension);
173
+ replayQuery = `-*${workflowInput.workflowDimension}-*`;
174
+ }
175
+ else {
176
+ //last letter of words like 'hook', 'sleep', 'wait', 'signal', 'search', 'start'
177
+ replayQuery = '-*[ehklpt]-*';
172
178
  }
173
179
  context.set('workflowTopic', workflowTopic);
174
180
  context.set('workflowName', workflowTopic.split('-').pop());
175
181
  context.set('workflowTrace', data.metadata.trc);
176
182
  context.set('workflowSpan', data.metadata.spn);
183
+ const store = this.workflowRunner.engine.store;
184
+ const [cursor, replay] = await store.findJobFields(workflowInput.workflowId, replayQuery, 50000, 5000);
185
+ context.set('replay', replay);
186
+ context.set('cursor', cursor); // if != 0, more remain
177
187
  const workflowResponse = await storage_1.asyncLocalStorage.run(context, async () => {
178
188
  return await workflowFunction.apply(this, workflowInput.arguments);
179
189
  });
@@ -87,6 +87,7 @@ export declare class WorkflowService {
87
87
  workflowTopic: any;
88
88
  workflowDimension: any;
89
89
  counter: any;
90
+ replay: any;
90
91
  };
91
92
  /**
92
93
  * Executes a function once and caches the result. If the function is called
@@ -76,6 +76,10 @@ class WorkflowService {
76
76
  const COUNTER = store.get('counter');
77
77
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
78
78
  const sessionId = `-start${workflowDimension}-${execIndex}-`;
79
+ const replay = store.get('replay');
80
+ if (sessionId in replay) {
81
+ return replay[sessionId];
82
+ }
79
83
  //NOTE: this is the hash prefix; necessary for the search index to locate the entity
80
84
  const entityOrEmptyString = options.entity ?? '';
81
85
  //If the workflowId is not provided, it is generated from the entity and the workflow name
@@ -172,6 +176,8 @@ class WorkflowService {
172
176
  static getContext() {
173
177
  const store = storage_1.asyncLocalStorage.getStore();
174
178
  const workflowId = store.get('workflowId');
179
+ const replay = store.get('replay');
180
+ const cursor = store.get('cursor');
175
181
  const workflowDimension = store.get('workflowDimension') ?? '';
176
182
  const workflowTopic = store.get('workflowTopic');
177
183
  const namespace = store.get('namespace');
@@ -180,7 +186,9 @@ class WorkflowService {
180
186
  const COUNTER = store.get('counter');
181
187
  return {
182
188
  counter: COUNTER.counter,
189
+ cursor,
183
190
  namespace,
191
+ replay,
184
192
  workflowId,
185
193
  workflowDimension,
186
194
  workflowTopic,
@@ -201,6 +209,10 @@ class WorkflowService {
201
209
  const COUNTER = store.get('counter');
202
210
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
203
211
  const sessionId = `-${prefix}${workflowDimension}-${execIndex}-`;
212
+ const replay = store.get('replay');
213
+ if (sessionId in replay) {
214
+ return false;
215
+ }
204
216
  const keyParams = {
205
217
  appId: hotMeshClient.appId,
206
218
  jobId: workflowId
@@ -274,6 +286,7 @@ class WorkflowService {
274
286
  workflowTopic: store.get('workflowTopic'),
275
287
  workflowDimension: store.get('workflowDimension') ?? '',
276
288
  counter: store.get('counter'),
289
+ replay: store.get('replay'),
277
290
  };
278
291
  }
279
292
  /**
@@ -284,9 +297,12 @@ class WorkflowService {
284
297
  * @template T - the result type
285
298
  */
286
299
  static async once(fn, ...args) {
287
- const { workflowId, namespace, workflowTopic, workflowDimension, counter: COUNTER, } = WorkflowService.getLocalState();
300
+ const { workflowId, namespace, workflowTopic, workflowDimension, counter: COUNTER, replay, } = WorkflowService.getLocalState();
288
301
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
289
302
  const sessionId = `-once${workflowDimension}-${execIndex}-`;
303
+ if (sessionId in replay) {
304
+ return JSON.parse(replay[sessionId]);
305
+ }
290
306
  const hotMeshClient = await worker_1.WorkerService.getHotMesh(workflowTopic, { namespace });
291
307
  const keyParams = {
292
308
  appId: hotMeshClient.appId,
@@ -41,6 +41,7 @@ declare class EngineService {
41
41
  jobCallbacks: Record<string, JobMessageCallback>;
42
42
  reporting: boolean;
43
43
  jobId: number;
44
+ inited: string;
44
45
  static init(namespace: string, appId: string, guid: string, config: HotMeshConfig, logger: ILogger): Promise<EngineService>;
45
46
  verifyEngineFields(config: HotMeshConfig): void;
46
47
  initStoreChannel(store: RedisClient): Promise<void>;
@@ -44,6 +44,7 @@ class EngineService {
44
44
  instance.router.consumeMessages(instance.stream.mintKey(key_1.KeyType.STREAMS, { appId: instance.appId }), 'ENGINE', instance.guid, instance.processStreamMessage.bind(instance));
45
45
  instance.taskService = new task_1.TaskService(instance.store, logger);
46
46
  instance.exporter = new exporter_1.ExporterService(instance.appId, instance.store, logger);
47
+ instance.inited = (0, utils_1.formatISODate)(new Date());
47
48
  return instance;
48
49
  }
49
50
  }
@@ -107,6 +107,10 @@ class QuorumService {
107
107
  stream,
108
108
  counts: this.engine.router.counts,
109
109
  timestamp: (0, utils_1.formatISODate)(new Date()),
110
+ inited: this.engine.inited,
111
+ throttle: this.engine.router.throttle,
112
+ reclaimDelay: this.engine.router.reclaimDelay,
113
+ reclaimCount: this.engine.router.reclaimCount,
110
114
  system: await (0, utils_1.getSystemHealth)(),
111
115
  };
112
116
  }
@@ -22,9 +22,24 @@ declare class Router {
22
22
  };
23
23
  currentTimerId: NodeJS.Timeout | null;
24
24
  shouldConsume: boolean;
25
+ sleepPromiseResolve: (() => void) | null;
26
+ innerPromiseResolve: (() => void) | null;
27
+ isSleeping: boolean;
28
+ sleepTimout: NodeJS.Timeout | null;
25
29
  constructor(config: StreamConfig, stream: StreamService<RedisClient, RedisMulti>, store: StoreService<RedisClient, RedisMulti>, logger: ILogger);
30
+ private resetThrottleState;
26
31
  createGroup(stream: string, group: string): Promise<void>;
27
32
  publishMessage(topic: string, streamData: StreamData | StreamDataResponse, multi?: RedisMulti): Promise<string | RedisMulti>;
33
+ /**
34
+ * An adjustable throttle that will interrupt a sleeping
35
+ * router if the throttle is reduced and the sleep time
36
+ * has elapsed. If the throttle is increased, or if
37
+ * the sleep time has not elapsed, the router will continue
38
+ * to sleep until the new termination point. This
39
+ * allows for dynamic, elastic throttling with smooth
40
+ * acceleration and deceleration.
41
+ */
42
+ customSleep(): Promise<void>;
28
43
  consumeMessages(stream: string, group: string, consumer: string, callback: (streamData: StreamData) => Promise<StreamDataResponse | void>): Promise<void>;
29
44
  isStreamMessage(result: any): boolean;
30
45
  consumeOne(stream: string, group: string, id: string, message: string[], callback: (streamData: StreamData) => Promise<StreamDataResponse | void>): Promise<void>;
@@ -12,6 +12,10 @@ class Router {
12
12
  this.errorCount = 0;
13
13
  this.counts = {};
14
14
  this.currentTimerId = null;
15
+ this.sleepPromiseResolve = null;
16
+ this.innerPromiseResolve = null;
17
+ this.isSleeping = false;
18
+ this.sleepTimout = null;
15
19
  this.appId = config.appId;
16
20
  this.guid = config.guid;
17
21
  this.role = config.role;
@@ -21,6 +25,13 @@ class Router {
21
25
  this.reclaimDelay = config.reclaimDelay || enums_1.HMSH_XCLAIM_DELAY_MS;
22
26
  this.reclaimCount = config.reclaimCount || enums_1.HMSH_XCLAIM_COUNT;
23
27
  this.logger = logger;
28
+ this.resetThrottleState();
29
+ }
30
+ resetThrottleState() {
31
+ this.sleepPromiseResolve = null;
32
+ this.innerPromiseResolve = null;
33
+ this.isSleeping = false;
34
+ this.sleepTimout = null;
24
35
  }
25
36
  async createGroup(stream, group) {
26
37
  try {
@@ -36,6 +47,36 @@ class Router {
36
47
  const stream = this.store.mintKey(key_1.KeyType.STREAMS, { appId: this.store.appId, topic });
37
48
  return await this.store.xadd(stream, '*', 'message', JSON.stringify(streamData), multi);
38
49
  }
50
+ /**
51
+ * An adjustable throttle that will interrupt a sleeping
52
+ * router if the throttle is reduced and the sleep time
53
+ * has elapsed. If the throttle is increased, or if
54
+ * the sleep time has not elapsed, the router will continue
55
+ * to sleep until the new termination point. This
56
+ * allows for dynamic, elastic throttling with smooth
57
+ * acceleration and deceleration.
58
+ */
59
+ async customSleep() {
60
+ if (this.throttle === 0)
61
+ return;
62
+ if (this.isSleeping)
63
+ return;
64
+ this.isSleeping = true;
65
+ let startTime = Date.now(); //anchor the origin
66
+ await new Promise(async (outerResolve) => {
67
+ this.sleepPromiseResolve = outerResolve;
68
+ let elapsedTime = Date.now() - startTime;
69
+ while (elapsedTime < this.throttle) {
70
+ await new Promise((innerResolve) => {
71
+ this.innerPromiseResolve = innerResolve;
72
+ this.sleepTimout = setTimeout(innerResolve, this.throttle - elapsedTime);
73
+ });
74
+ elapsedTime = Date.now() - startTime;
75
+ }
76
+ this.resetThrottleState();
77
+ outerResolve();
78
+ });
79
+ }
39
80
  async consumeMessages(stream, group, consumer, callback) {
40
81
  this.logger.info(`stream-consumer-starting`, { group, consumer, stream });
41
82
  Router.instances.add(this);
@@ -43,9 +84,7 @@ class Router {
43
84
  await this.createGroup(stream, group);
44
85
  let lastCheckedPendingMessagesAt = Date.now();
45
86
  async function consume() {
46
- let sleep = (0, utils_1.XSleepFor)(this.throttle);
47
- this.currentTimerId = sleep.timerId;
48
- await sleep.promise;
87
+ await this.customSleep();
49
88
  if (!this.shouldConsume) {
50
89
  this.logger.info(`stream-consumer-stopping`, { group, consumer, stream });
51
90
  return;
@@ -229,17 +268,26 @@ class Router {
229
268
  this.cancelThrottle();
230
269
  }
231
270
  cancelThrottle() {
232
- if (this.currentTimerId !== undefined) {
233
- clearTimeout(this.currentTimerId);
234
- this.currentTimerId = undefined;
271
+ if (this.sleepTimout) {
272
+ clearTimeout(this.sleepTimout);
235
273
  }
274
+ this.resetThrottleState();
236
275
  }
237
276
  setThrottle(delayInMillis) {
238
277
  if (!Number.isInteger(delayInMillis) || delayInMillis < 0) {
239
278
  throw new Error('Throttle must be a non-negative integer');
240
279
  }
280
+ const wasDecreased = delayInMillis < this.throttle;
241
281
  this.throttle = delayInMillis;
242
- this.logger.info(`stream-throttle-reset`, { delay: this.throttle, topic: this.topic });
282
+ // If the throttle was decreased, and we're in the middle of a sleep cycle, adjust immediately
283
+ if (wasDecreased) {
284
+ if (this.sleepTimout) {
285
+ clearTimeout(this.sleepTimout);
286
+ }
287
+ if (this.innerPromiseResolve) {
288
+ this.innerPromiseResolve();
289
+ }
290
+ }
243
291
  }
244
292
  async claimUnacknowledged(stream, group, consumer, idleTimeMs = this.reclaimDelay, limit = enums_1.HMSH_XPENDING_COUNT) {
245
293
  let pendingMessages = [];
@@ -10,6 +10,7 @@ class RedisStoreService extends index_1.StoreService {
10
10
  setnx: 'SETNX',
11
11
  del: 'DEL',
12
12
  expire: 'EXPIRE',
13
+ hscan: 'HSCAN',
13
14
  hset: 'HSET',
14
15
  hsetnx: 'HSETNX',
15
16
  hincrby: 'HINCRBY',
@@ -29,6 +30,7 @@ class RedisStoreService extends index_1.StoreService {
29
30
  lpop: 'LPOP',
30
31
  rename: 'RENAME',
31
32
  rpush: 'RPUSH',
33
+ scan: 'SCAN',
32
34
  xack: 'XACK',
33
35
  xdel: 'XDEL',
34
36
  xlen: 'XLEN',
@@ -152,5 +152,7 @@ declare abstract class StoreService<T, U extends AbstractRedisClient> {
152
152
  */
153
153
  interrupt(topic: string, jobId: string, options?: JobInterruptOptions): Promise<void>;
154
154
  scrub(jobId: string): Promise<void>;
155
+ findJobs(queryString?: string, limit?: number, batchSize?: number): Promise<string[]>;
156
+ findJobFields(jobId: string, fieldMatchPattern?: string, limit?: number, batchSize?: number, cursor?: string): Promise<[string, StringStringType]>;
155
157
  }
156
158
  export { StoreService };
@@ -38,6 +38,7 @@ class StoreService {
38
38
  del: 'del',
39
39
  expire: 'expire',
40
40
  hset: 'hset',
41
+ hscan: 'hscan',
41
42
  hsetnx: 'hsetnx',
42
43
  hincrby: 'hincrby',
43
44
  hdel: 'hdel',
@@ -56,6 +57,7 @@ class StoreService {
56
57
  lrange: 'lrange',
57
58
  rename: 'rename',
58
59
  rpush: 'rpush',
60
+ scan: 'scan',
59
61
  xack: 'xack',
60
62
  xdel: 'xdel',
61
63
  };
@@ -876,5 +878,47 @@ class StoreService {
876
878
  const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId: this.appId, jobId });
877
879
  await this.redisClient[this.commands.del](jobKey);
878
880
  }
881
+ async findJobs(queryString = '*', limit = 1000, batchSize = 1000) {
882
+ const matchKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId: this.appId, jobId: queryString });
883
+ let cursor = '0';
884
+ let keys;
885
+ const matchingKeys = [];
886
+ do {
887
+ const output = await this.exec('SCAN', cursor, 'MATCH', matchKey, 'COUNT', batchSize.toString());
888
+ if (Array.isArray(output)) {
889
+ [cursor, keys] = output;
890
+ for (let key of [...keys]) {
891
+ matchingKeys.push(key);
892
+ }
893
+ if (matchingKeys.length >= limit) {
894
+ break;
895
+ }
896
+ }
897
+ else {
898
+ break;
899
+ }
900
+ } while (cursor !== '0');
901
+ return matchingKeys;
902
+ }
903
+ async findJobFields(jobId, fieldMatchPattern = '*', limit = 1000, batchSize = 1000, cursor = '0') {
904
+ let fields = [];
905
+ const matchingFields = {};
906
+ const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId: this.appId, jobId });
907
+ let len = 0;
908
+ do {
909
+ const output = await this.exec('HSCAN', jobKey, cursor, 'MATCH', fieldMatchPattern, 'COUNT', batchSize.toString());
910
+ if (Array.isArray(output)) {
911
+ [cursor, fields] = output;
912
+ for (let i = 0; i < fields.length; i += 2) {
913
+ len++;
914
+ matchingFields[fields[i]] = fields[i + 1];
915
+ }
916
+ }
917
+ else {
918
+ break;
919
+ }
920
+ } while (cursor !== '0' && len < limit);
921
+ return [cursor, matchingFields];
922
+ }
879
923
  }
880
924
  exports.StoreService = StoreService;
@@ -18,6 +18,7 @@ declare class WorkerService {
18
18
  router: Router | null;
19
19
  logger: ILogger;
20
20
  reporting: boolean;
21
+ inited: string;
21
22
  static init(namespace: string, appId: string, guid: string, config: HotMeshConfig, logger: ILogger): Promise<WorkerService[]>;
22
23
  verifyWorkerFields(worker: HotMeshWorker): void;
23
24
  initStoreChannel(service: WorkerService, store: RedisClient): Promise<void>;
@@ -38,6 +38,7 @@ class WorkerService {
38
38
  service.router = service.initRouter(worker, logger);
39
39
  const key = service.stream.mintKey(key_1.KeyType.STREAMS, { appId: service.appId, topic: worker.topic });
40
40
  await service.router.consumeMessages(key, 'WORKER', service.guid, worker.callback);
41
+ service.inited = (0, utils_1.formatISODate)(new Date());
41
42
  services.push(service);
42
43
  }
43
44
  }
@@ -116,6 +117,10 @@ class WorkerService {
116
117
  stream: this.stream.mintKey(key_1.KeyType.STREAMS, params),
117
118
  counts: this.router.counts,
118
119
  timestamp: (0, utils_1.formatISODate)(new Date()),
120
+ inited: this.inited,
121
+ throttle: this.router.throttle,
122
+ reclaimDelay: this.router.reclaimDelay,
123
+ reclaimCount: this.router.reclaimCount,
119
124
  system: await (0, utils_1.getSystemHealth)(),
120
125
  };
121
126
  }
@@ -1,5 +1,6 @@
1
1
  import { LogLevel } from './logger';
2
2
  import { RedisClass, RedisOptions } from './redis';
3
+ import { StringStringType } from './serializer';
3
4
  type WorkflowConfig = {
4
5
  backoffCoefficient?: number;
5
6
  maximumAttempts?: number;
@@ -11,6 +12,14 @@ type WorkflowContext = {
11
12
  * the reentrant semaphore, incremented in real-time as idempotent statements are re-traversed upon reentry. Indicates the current semaphore count.
12
13
  */
13
14
  counter: number;
15
+ /**
16
+ * number as string for the replay cursor
17
+ */
18
+ cursor: string;
19
+ /**
20
+ * the replay hash of name/value pairs representing prior executions
21
+ */
22
+ replay: StringStringType;
14
23
  /**
15
24
  * the HotMesh App namespace. `durable` is the default.
16
25
  */
@@ -39,7 +39,11 @@ export interface QuorumProfile {
39
39
  stream?: string;
40
40
  stream_depth?: number;
41
41
  counts?: Record<string, number>;
42
+ inited?: string;
42
43
  timestamp?: string;
44
+ throttle?: number;
45
+ reclaimDelay?: number;
46
+ reclaimCount?: number;
43
47
  system?: SystemHealth;
44
48
  }
45
49
  export interface PingMessage {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
4
4
  "description": "Unbreakable Workflows",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -288,13 +288,17 @@ export class MeshOSService {
288
288
  args.push('LIMIT', '0', '0');
289
289
  } else {
290
290
  //limit which hash fields to return
291
- if (options.return?.length) {
292
- args.push('RETURN');
293
- args.push(options.return.length.toString());
294
- options.return.forEach(returnField => {
291
+ args.push('RETURN');
292
+ args.push(((options.return?.length ?? 0) + 1).toString());
293
+ args.push('$');
294
+ options.return?.forEach(returnField => {
295
+ if (returnField.startsWith('"')) {
296
+ //allow literal values to be requested
297
+ args.push(returnField.slice(1, -1));
298
+ } else {
295
299
  args.push(`_${returnField}`);
296
- });
297
- }
300
+ }
301
+ });
298
302
  //paginate
299
303
  if (options.limit) {
300
304
  args.push('LIMIT', options.limit.start.toString(), options.limit.size.toString());
@@ -3,6 +3,7 @@ import { RedisClient, RedisMulti } from '../../types/redis';
3
3
  import { StoreService } from '../store';
4
4
  import { KeyService, KeyType } from '../../modules/key';
5
5
  import { WorkflowSearchOptions } from '../../types/durable';
6
+ import { asyncLocalStorage } from '../../modules/storage';
6
7
 
7
8
  export class Search {
8
9
  jobId: string;
@@ -66,9 +67,14 @@ export class Search {
66
67
  * @returns {Promise<string[]>} - the list of search indexes
67
68
  */
68
69
  static async listSearchIndexes(hotMeshClient: HotMesh): Promise<string[]> {
69
- const store = hotMeshClient.engine.store;
70
- const searchIndexes = await store.exec('FT._LIST');
71
- return searchIndexes as string[];
70
+ try {
71
+ const store = hotMeshClient.engine.store;
72
+ const searchIndexes = await store.exec('FT._LIST');
73
+ return searchIndexes as string[];
74
+ } catch (err) {
75
+ hotMeshClient.engine.logger.info('durable-client-search-list-err', { err });
76
+ return [];
77
+ }
72
78
  }
73
79
 
74
80
  /**
@@ -80,18 +86,28 @@ export class Search {
80
86
  return `${this.searchSessionId}-${this.searchSessionIndex++}-`;
81
87
  }
82
88
 
83
- async set(...args: string[]): Promise<void> {
89
+ /**
90
+ * Sets the fields listed in args. Returns the
91
+ * count of new fields that were set (does not
92
+ * count fields that were updated)
93
+ */
94
+ async set(...args: string[]): Promise<number> {
84
95
  const ssGuid = this.getSearchSessionGuid();
85
- const ssGuidValue = Number(await this.store.exec('HINCRBYFLOAT', this.jobId, ssGuid, '1') as string);
86
- if (ssGuidValue === 1) {
87
- const safeArgs: string[] = [];
88
- for (let i = 0; i < args.length; i += 2) {
89
- const key = this.safeKey(args[i]);
90
- const value = args[i+1].toString();
91
- safeArgs.push(key, value);
92
- }
93
- await this.store.exec('HSET', this.jobId, ...safeArgs);
96
+ const store = asyncLocalStorage.getStore();
97
+ const replay = store?.get('replay') ?? {};
98
+ if (ssGuid in replay) {
99
+ return Number(replay[ssGuid]);
94
100
  }
101
+ const safeArgs: string[] = [];
102
+ for (let i = 0; i < args.length; i += 2) {
103
+ const key = this.safeKey(args[i]);
104
+ const value = args[i+1].toString();
105
+ safeArgs.push(key, value);
106
+ }
107
+ const fieldCount = await this.store.exec('HSET', this.jobId, ...safeArgs);
108
+ //no need to wait; set this interim value in the replay
109
+ this.store.exec('HSET', this.jobId, ssGuid, fieldCount.toString());
110
+ return Number(fieldCount);
95
111
  }
96
112
 
97
113
  async get(key: string): Promise<string> {
@@ -116,34 +132,65 @@ export class Search {
116
132
  }
117
133
  }
118
134
 
135
+ /**
136
+ * Deletes the fields listed in args. Returns the
137
+ * count of fields that were deleted.
138
+ */
119
139
  async del(...args: string[]): Promise<number | void> {
120
140
  const ssGuid = this.getSearchSessionGuid();
121
- const ssGuidValue = Number(await this.store.exec('HINCRBYFLOAT', this.jobId, ssGuid, '1') as string);
122
- if (ssGuidValue === 1) {
123
- const safeArgs: string[] = [];
124
- for (let i = 0; i < args.length; i++) {
125
- safeArgs.push(this.safeKey(args[i]));
126
- }
127
- const response = await this.store.exec('HDEL', this.jobId, ...safeArgs);
128
- return isNaN(response as unknown as number) ? undefined : Number(response);
141
+ const store = asyncLocalStorage.getStore();
142
+ const replay = store?.get('replay') ?? {};
143
+ if (ssGuid in replay) {
144
+ return Number(replay[ssGuid]);
145
+ }
146
+ const safeArgs: string[] = [];
147
+ for (let i = 0; i < args.length; i++) {
148
+ safeArgs.push(this.safeKey(args[i]));
129
149
  }
150
+ const response = await this.store.exec('HDEL', this.jobId, ...safeArgs);
151
+ const formattedResponse = isNaN(response as unknown as number) ? 0 : Number(response);
152
+ //no need to wait; set this interim value in the replay
153
+ this.store.exec('HSET', this.jobId, ssGuid, formattedResponse.toString());
154
+ return formattedResponse;
130
155
  }
131
156
 
157
+ /**
158
+ * Increments the value of a field by the given amount. Returns the
159
+ * new value of the field after the increment. Can be
160
+ * used to decrement the value of a field by specifying a negative.
161
+ */
132
162
  async incr(key: string, val: number): Promise<number> {
133
163
  const ssGuid = this.getSearchSessionGuid();
134
- const ssGuidValue = Number(await this.store.exec('HINCRBYFLOAT', this.jobId, ssGuid, '1') as string);
135
- if (ssGuidValue === 1) {
136
- return Number(await this.store.exec('HINCRBYFLOAT', this.jobId, this.safeKey(key), val.toString()) as string);
164
+ const store = asyncLocalStorage.getStore();
165
+ const replay = store?.get('replay') ?? {};
166
+ if (ssGuid in replay) {
167
+ return Number(replay[ssGuid]);
137
168
  }
169
+ const num = await this.store.exec('HINCRBYFLOAT', this.jobId, this.safeKey(key), val.toString()) as string;
170
+ //no need to wait; set this interim value in the replay
171
+ this.store.exec('HSET', this.jobId, ssGuid, num.toString());
172
+ return Number(num);
138
173
  }
139
174
 
175
+ /**
176
+ * Multiplies the value of a field by the given amount. Returns the
177
+ * new value of the field after the multiplication. NOTE:
178
+ * this is exponential multiplication.
179
+ */
140
180
  async mult(key: string, val: number): Promise<number> {
141
181
  const ssGuid = this.getSearchSessionGuid();
182
+ const store = asyncLocalStorage.getStore();
183
+ const replay = store?.get('replay') ?? {};
184
+ if (ssGuid in replay) {
185
+ return Math.exp(Number(replay[ssGuid]));
186
+ }
142
187
  const ssGuidValue = Number(await this.store.exec('HINCRBYFLOAT', this.jobId, ssGuid, '1') as string);
143
188
  if (ssGuidValue === 1) {
144
189
  const log = Math.log(val);
145
- const logTotal = Number(await this.store.exec('HINCRBYFLOAT', this.jobId, this.safeKey(key), log.toString()) as string);
146
- return Math.exp(logTotal);
190
+ const logTotal = await this.store.exec('HINCRBYFLOAT', this.jobId, this.safeKey(key), log.toString()) as string;
191
+ //no need to wait; set this interim value in the replay
192
+ this.store.exec('HSET', this.jobId, ssGuid, logTotal.toString());
193
+ return Math.exp(Number(logTotal));
147
194
  }
148
195
  }
149
196
  }
@@ -211,16 +211,29 @@ export class WorkerService {
211
211
  // garbage collect (expire) this job when originJobId is expired
212
212
  context.set('originJobId', workflowInput.originJobId);
213
213
  }
214
+ let replayQuery = '';
214
215
  if (workflowInput.workflowDimension) {
215
216
  //every hook function runs in an isolated dimension controlled
216
217
  //by the index assigned when the signal was received; even if the
217
218
  //hook function re-runs, its scope will always remain constant
218
219
  context.set('workflowDimension', workflowInput.workflowDimension);
220
+ replayQuery = `-*${workflowInput.workflowDimension}-*`;
221
+ } else {
222
+ //last letter of words like 'hook', 'sleep', 'wait', 'signal', 'search', 'start'
223
+ replayQuery = '-*[ehklpt]-*';
219
224
  }
220
225
  context.set('workflowTopic', workflowTopic);
221
226
  context.set('workflowName', workflowTopic.split('-').pop());
222
227
  context.set('workflowTrace', data.metadata.trc);
223
228
  context.set('workflowSpan', data.metadata.spn);
229
+ const store = this.workflowRunner.engine.store;
230
+ const [cursor, replay] = await store.findJobFields(
231
+ workflowInput.workflowId,
232
+ replayQuery,
233
+ 50_000,
234
+ 5_000,);
235
+ context.set('replay', replay);
236
+ context.set('cursor', cursor); // if != 0, more remain
224
237
  const workflowResponse = await asyncLocalStorage.run(context, async () => {
225
238
  return await workflowFunction.apply(this, workflowInput.arguments);
226
239
  });
@@ -232,7 +245,6 @@ export class WorkerService {
232
245
  data: { response: workflowResponse, done: true }
233
246
  };
234
247
  } catch (err) {
235
-
236
248
  //not an error...just a trigger to sleep
237
249
  if (err instanceof DurableSleepForError) {
238
250
  return {
@@ -21,6 +21,7 @@ import {
21
21
  import { JobInterruptOptions, JobOutput, JobState } from '../../types/job';
22
22
  import { StreamStatus } from '../../types/stream';
23
23
  import { deterministicRandom } from '../../modules/utils';
24
+ import { StringStringType } from '../../types';
24
25
 
25
26
  export class WorkflowService {
26
27
 
@@ -92,6 +93,10 @@ export class WorkflowService {
92
93
  const COUNTER = store.get('counter');
93
94
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
94
95
  const sessionId = `-start${workflowDimension}-${execIndex}-`;
96
+ const replay = store.get('replay') as StringStringType;
97
+ if (sessionId in replay) {
98
+ return replay[sessionId];
99
+ }
95
100
  //NOTE: this is the hash prefix; necessary for the search index to locate the entity
96
101
  const entityOrEmptyString = options.entity ?? '';
97
102
  //If the workflowId is not provided, it is generated from the entity and the workflow name
@@ -193,6 +198,8 @@ export class WorkflowService {
193
198
  static getContext(): WorkflowContext {
194
199
  const store = asyncLocalStorage.getStore();
195
200
  const workflowId = store.get('workflowId');
201
+ const replay = store.get('replay');
202
+ const cursor = store.get('cursor');
196
203
  const workflowDimension = store.get('workflowDimension') ?? '';
197
204
  const workflowTopic = store.get('workflowTopic');
198
205
  const namespace = store.get('namespace');
@@ -201,7 +208,9 @@ export class WorkflowService {
201
208
  const COUNTER = store.get('counter');
202
209
  return {
203
210
  counter: COUNTER.counter,
211
+ cursor,
204
212
  namespace,
213
+ replay,
205
214
  workflowId,
206
215
  workflowDimension,
207
216
  workflowTopic,
@@ -223,6 +232,10 @@ export class WorkflowService {
223
232
  const COUNTER = store.get('counter');
224
233
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
225
234
  const sessionId = `-${prefix}${workflowDimension}-${execIndex}-`;
235
+ const replay = store.get('replay') as StringStringType;
236
+ if (sessionId in replay) {
237
+ return false;
238
+ }
226
239
  const keyParams = {
227
240
  appId: hotMeshClient.appId,
228
241
  jobId: workflowId
@@ -300,6 +313,7 @@ export class WorkflowService {
300
313
  workflowTopic: store.get('workflowTopic'),
301
314
  workflowDimension: store.get('workflowDimension') ?? '',
302
315
  counter: store.get('counter'),
316
+ replay: store.get('replay'),
303
317
  }
304
318
  }
305
319
 
@@ -317,9 +331,13 @@ export class WorkflowService {
317
331
  workflowTopic,
318
332
  workflowDimension,
319
333
  counter: COUNTER,
334
+ replay,
320
335
  } = WorkflowService.getLocalState();
321
336
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
322
337
  const sessionId = `-once${workflowDimension}-${execIndex}-`;
338
+ if (sessionId in replay) {
339
+ return JSON.parse(replay[sessionId]);
340
+ }
323
341
  const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
324
342
  const keyParams = {
325
343
  appId: hotMeshClient.appId,
@@ -98,6 +98,7 @@ class EngineService {
98
98
  jobCallbacks: Record<string, JobMessageCallback> = {};
99
99
  reporting = false;
100
100
  jobId = 1;
101
+ inited: string;
101
102
 
102
103
  static async init(namespace: string, appId: string, guid: string, config: HotMeshConfig, logger: ILogger): Promise<EngineService> {
103
104
  if (config.engine) {
@@ -133,6 +134,7 @@ class EngineService {
133
134
  instance.store,
134
135
  logger,
135
136
  );
137
+ instance.inited = formatISODate(new Date());
136
138
  return instance;
137
139
  }
138
140
  }
@@ -158,6 +158,10 @@ class QuorumService {
158
158
  stream,
159
159
  counts: this.engine.router.counts,
160
160
  timestamp: formatISODate(new Date()),
161
+ inited: this.engine.inited,
162
+ throttle: this.engine.router.throttle,
163
+ reclaimDelay: this.engine.router.reclaimDelay,
164
+ reclaimCount: this.engine.router.reclaimCount,
161
165
  system: await getSystemHealth(),
162
166
  };
163
167
  }
@@ -10,7 +10,7 @@ import {
10
10
  HMSH_XCLAIM_DELAY_MS,
11
11
  HMSH_XPENDING_COUNT } from '../../modules/enums';
12
12
  import { KeyType } from '../../modules/key';
13
- import { XSleepFor, guid, sleepFor } from '../../modules/utils';
13
+ import { guid, sleepFor } from '../../modules/utils';
14
14
  import { ILogger } from '../logger';
15
15
  import { StoreService } from '../store';
16
16
  import { StreamService } from '../stream';
@@ -43,6 +43,10 @@ class Router {
43
43
  counts: { [key: string]: number } = {};
44
44
  currentTimerId: NodeJS.Timeout | null = null;
45
45
  shouldConsume: boolean;
46
+ sleepPromiseResolve: (() => void) | null = null;
47
+ innerPromiseResolve: (() => void) | null = null;
48
+ isSleeping: boolean = false;
49
+ sleepTimout: NodeJS.Timeout | null = null;
46
50
 
47
51
  constructor(config: StreamConfig, stream: StreamService<RedisClient, RedisMulti>, store: StoreService<RedisClient, RedisMulti>, logger: ILogger) {
48
52
  this.appId = config.appId;
@@ -54,6 +58,14 @@ class Router {
54
58
  this.reclaimDelay = config.reclaimDelay || HMSH_XCLAIM_DELAY_MS;
55
59
  this.reclaimCount = config.reclaimCount || HMSH_XCLAIM_COUNT;
56
60
  this.logger = logger;
61
+ this.resetThrottleState();
62
+ }
63
+
64
+ private resetThrottleState() {
65
+ this.sleepPromiseResolve = null;
66
+ this.innerPromiseResolve = null;
67
+ this.isSleeping = false;
68
+ this.sleepTimout = null;
57
69
  }
58
70
 
59
71
  async createGroup(stream: string, group: string) {
@@ -72,6 +84,36 @@ class Router {
72
84
  return await this.store.xadd(stream, '*', 'message', JSON.stringify(streamData), multi);
73
85
  }
74
86
 
87
+ /**
88
+ * An adjustable throttle that will interrupt a sleeping
89
+ * router if the throttle is reduced and the sleep time
90
+ * has elapsed. If the throttle is increased, or if
91
+ * the sleep time has not elapsed, the router will continue
92
+ * to sleep until the new termination point. This
93
+ * allows for dynamic, elastic throttling with smooth
94
+ * acceleration and deceleration.
95
+ */
96
+ public async customSleep(): Promise<void> {
97
+ if (this.throttle === 0) return;
98
+ if (this.isSleeping) return;
99
+ this.isSleeping = true;
100
+ let startTime = Date.now(); //anchor the origin
101
+
102
+ await new Promise<void>(async (outerResolve) => {
103
+ this.sleepPromiseResolve = outerResolve;
104
+ let elapsedTime = Date.now() - startTime;
105
+ while (elapsedTime < this.throttle) {
106
+ await new Promise<void>((innerResolve) => {
107
+ this.innerPromiseResolve = innerResolve;
108
+ this.sleepTimout = setTimeout(innerResolve, this.throttle - elapsedTime);
109
+ });
110
+ elapsedTime = Date.now() - startTime;
111
+ }
112
+ this.resetThrottleState();
113
+ outerResolve();
114
+ });
115
+ }
116
+
75
117
  async consumeMessages(stream: string, group: string, consumer: string, callback: (streamData: StreamData) => Promise<StreamDataResponse|void>): Promise<void> {
76
118
  this.logger.info(`stream-consumer-starting`, { group, consumer, stream });
77
119
  Router.instances.add(this);
@@ -80,9 +122,7 @@ class Router {
80
122
  let lastCheckedPendingMessagesAt = Date.now();
81
123
 
82
124
  async function consume() {
83
- let sleep = XSleepFor(this.throttle);
84
- this.currentTimerId = sleep.timerId;
85
- await sleep.promise;
125
+ await this.customSleep();
86
126
  if (!this.shouldConsume) {
87
127
  this.logger.info(`stream-consumer-stopping`, { group, consumer, stream });
88
128
  return;
@@ -273,18 +313,28 @@ class Router {
273
313
  }
274
314
 
275
315
  cancelThrottle() {
276
- if (this.currentTimerId !== undefined) {
277
- clearTimeout(this.currentTimerId);
278
- this.currentTimerId = undefined;
316
+ if (this.sleepTimout) {
317
+ clearTimeout(this.sleepTimout);
279
318
  }
319
+ this.resetThrottleState();
280
320
  }
281
321
 
282
- setThrottle(delayInMillis: number) {
322
+ public setThrottle(delayInMillis: number): void {
283
323
  if (!Number.isInteger(delayInMillis) || delayInMillis < 0) {
284
324
  throw new Error('Throttle must be a non-negative integer');
285
325
  }
326
+ const wasDecreased = delayInMillis < this.throttle;
286
327
  this.throttle = delayInMillis;
287
- this.logger.info(`stream-throttle-reset`, { delay: this.throttle, topic: this.topic });
328
+
329
+ // If the throttle was decreased, and we're in the middle of a sleep cycle, adjust immediately
330
+ if (wasDecreased) {
331
+ if (this.sleepTimout) {
332
+ clearTimeout(this.sleepTimout);
333
+ }
334
+ if (this.innerPromiseResolve) {
335
+ this.innerPromiseResolve();
336
+ }
337
+ }
288
338
  }
289
339
 
290
340
  async claimUnacknowledged(stream: string, group: string, consumer: string, idleTimeMs = this.reclaimDelay, limit = HMSH_XPENDING_COUNT): Promise<[string, [string, string]][]> {
@@ -22,6 +22,7 @@ class RedisStoreService extends StoreService<RedisClientType, RedisMultiType> {
22
22
  setnx: 'SETNX',
23
23
  del: 'DEL',
24
24
  expire: 'EXPIRE',
25
+ hscan: 'HSCAN',
25
26
  hset: 'HSET',
26
27
  hsetnx: 'HSETNX',
27
28
  hincrby: 'HINCRBY',
@@ -41,6 +42,7 @@ class RedisStoreService extends StoreService<RedisClientType, RedisMultiType> {
41
42
  lpop: 'LPOP',
42
43
  rename: 'RENAME',
43
44
  rpush: 'RPUSH',
45
+ scan: 'SCAN',
44
46
  xack: 'XACK',
45
47
  xdel: 'XDEL',
46
48
  xlen: 'XLEN',
@@ -50,6 +50,7 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
50
50
  del: 'del',
51
51
  expire: 'expire',
52
52
  hset: 'hset',
53
+ hscan: 'hscan',
53
54
  hsetnx: 'hsetnx',
54
55
  hincrby: 'hincrby',
55
56
  hdel: 'hdel',
@@ -68,6 +69,7 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
68
69
  lrange: 'lrange',
69
70
  rename: 'rename',
70
71
  rpush: 'rpush',
72
+ scan: 'scan',
71
73
  xack: 'xack',
72
74
  xdel: 'xdel',
73
75
  };
@@ -998,6 +1000,63 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
998
1000
  const jobKey = this.mintKey(KeyType.JOB_STATE, { appId: this.appId, jobId });
999
1001
  await this.redisClient[this.commands.del](jobKey);
1000
1002
  }
1003
+
1004
+ async findJobs(queryString: string = '*', limit: number = 1000, batchSize: number = 1000): Promise<string[]> {
1005
+ const matchKey = this.mintKey(KeyType.JOB_STATE, { appId: this.appId, jobId: queryString });
1006
+ let cursor = '0';
1007
+ let keys: string[];
1008
+ const matchingKeys: string[] = [];
1009
+ do {
1010
+ const output = await this.exec(
1011
+ 'SCAN',
1012
+ cursor,
1013
+ 'MATCH',
1014
+ matchKey,
1015
+ 'COUNT',
1016
+ batchSize.toString(),
1017
+ ) as unknown as [string, string[]];
1018
+ if (Array.isArray(output)) {
1019
+ [cursor, keys] = output;
1020
+ for (let key of [...keys]) {
1021
+ matchingKeys.push(key);
1022
+ }
1023
+ if (matchingKeys.length >= limit) {
1024
+ break;
1025
+ }
1026
+ } else {
1027
+ break;
1028
+ }
1029
+ } while (cursor !== '0');
1030
+ return matchingKeys;
1031
+ }
1032
+
1033
+ async findJobFields(jobId: string, fieldMatchPattern: string = '*', limit: number = 1000, batchSize: number = 1000, cursor = '0'): Promise<[string, StringStringType]> {
1034
+ let fields: string[] = [];
1035
+ const matchingFields: StringStringType = {};
1036
+ const jobKey = this.mintKey(KeyType.JOB_STATE, { appId: this.appId, jobId });
1037
+ let len = 0;
1038
+ do {
1039
+ const output = await this.exec(
1040
+ 'HSCAN',
1041
+ jobKey,
1042
+ cursor,
1043
+ 'MATCH',
1044
+ fieldMatchPattern,
1045
+ 'COUNT',
1046
+ batchSize.toString(),
1047
+ ) as unknown as [string, string[]];
1048
+ if (Array.isArray(output)) {
1049
+ [cursor, fields] = output;
1050
+ for (let i = 0; i < fields.length; i += 2) {
1051
+ len++;
1052
+ matchingFields[fields[i]] = fields[i + 1];
1053
+ }
1054
+ } else {
1055
+ break;
1056
+ }
1057
+ } while (cursor !== '0' && len < limit);
1058
+ return [cursor, matchingFields];
1059
+ }
1001
1060
  }
1002
1061
 
1003
1062
  export { StoreService };
@@ -34,6 +34,7 @@ class WorkerService {
34
34
  router: Router | null;
35
35
  logger: ILogger;
36
36
  reporting = false;
37
+ inited: string;
37
38
 
38
39
  static async init(
39
40
  namespace: string,
@@ -76,6 +77,7 @@ class WorkerService {
76
77
  service.guid,
77
78
  worker.callback
78
79
  );
80
+ service.inited = formatISODate(new Date());
79
81
  services.push(service);
80
82
  }
81
83
  }
@@ -176,6 +178,10 @@ class WorkerService {
176
178
  stream: this.stream.mintKey(KeyType.STREAMS, params),
177
179
  counts: this.router.counts,
178
180
  timestamp: formatISODate(new Date()),
181
+ inited: this.inited,
182
+ throttle: this.router.throttle,
183
+ reclaimDelay: this.router.reclaimDelay,
184
+ reclaimCount: this.router.reclaimCount,
179
185
  system: await getSystemHealth(),
180
186
  };
181
187
  }
package/types/durable.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { LogLevel } from './logger';
2
2
  import { RedisClass, RedisOptions } from './redis';
3
+ import { StringStringType } from './serializer';
3
4
 
4
5
  type WorkflowConfig = {
5
6
  backoffCoefficient?: number; //default 10
@@ -15,6 +16,16 @@ type WorkflowContext = {
15
16
  */
16
17
  counter: number;
17
18
 
19
+ /**
20
+ * number as string for the replay cursor
21
+ */
22
+ cursor: string;
23
+
24
+ /**
25
+ * the replay hash of name/value pairs representing prior executions
26
+ */
27
+ replay: StringStringType;
28
+
18
29
  /**
19
30
  * the HotMesh App namespace. `durable` is the default.
20
31
  */
package/types/quorum.ts CHANGED
@@ -44,7 +44,11 @@ export interface QuorumProfile {
44
44
  stream?: string;
45
45
  stream_depth?: number;
46
46
  counts?: Record<string, number>;
47
+ inited?: string;
47
48
  timestamp?: string;
49
+ throttle?: number;
50
+ reclaimDelay?: number;
51
+ reclaimCount?: number;
48
52
  system?: SystemHealth;
49
53
  }
50
54