@hotmeshio/hotmesh 0.0.48 → 0.0.50

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.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/build/modules/enums.d.ts +1 -0
  3. package/build/modules/enums.js +2 -1
  4. package/build/modules/key.d.ts +5 -1
  5. package/build/modules/key.js +10 -2
  6. package/build/package.json +2 -1
  7. package/build/services/activities/await.js +6 -0
  8. package/build/services/activities/hook.js +1 -1
  9. package/build/services/activities/trigger.d.ts +1 -0
  10. package/build/services/activities/trigger.js +23 -2
  11. package/build/services/durable/exporter.js +19 -5
  12. package/build/services/durable/meshos.js +11 -6
  13. package/build/services/durable/search.d.ts +20 -1
  14. package/build/services/durable/search.js +73 -25
  15. package/build/services/durable/worker.js +10 -0
  16. package/build/services/durable/workflow.d.ts +1 -0
  17. package/build/services/durable/workflow.js +17 -1
  18. package/build/services/engine/index.d.ts +1 -1
  19. package/build/services/engine/index.js +12 -3
  20. package/build/services/exporter/index.js +3 -2
  21. package/build/services/hotmesh/index.js +4 -0
  22. package/build/services/quorum/index.d.ts +11 -2
  23. package/build/services/quorum/index.js +33 -0
  24. package/build/services/router/index.d.ts +15 -0
  25. package/build/services/router/index.js +55 -7
  26. package/build/services/serializer/index.js +1 -1
  27. package/build/services/store/clients/redis.js +2 -0
  28. package/build/services/store/index.d.ts +6 -4
  29. package/build/services/store/index.js +86 -21
  30. package/build/services/task/index.d.ts +2 -1
  31. package/build/services/task/index.js +30 -13
  32. package/build/services/worker/index.d.ts +13 -2
  33. package/build/services/worker/index.js +44 -3
  34. package/build/types/activity.d.ts +1 -0
  35. package/build/types/durable.d.ts +9 -0
  36. package/build/types/exporter.d.ts +2 -0
  37. package/build/types/job.d.ts +1 -0
  38. package/build/types/quorum.d.ts +22 -8
  39. package/build/types/stream.d.ts +1 -0
  40. package/modules/enums.ts +1 -0
  41. package/modules/key.ts +7 -2
  42. package/package.json +2 -1
  43. package/services/activities/await.ts +6 -0
  44. package/services/activities/hook.ts +1 -0
  45. package/services/activities/trigger.ts +25 -1
  46. package/services/durable/exporter.ts +18 -7
  47. package/services/durable/meshos.ts +10 -6
  48. package/services/durable/search.ts +73 -26
  49. package/services/durable/worker.ts +13 -1
  50. package/services/durable/workflow.ts +18 -0
  51. package/services/engine/index.ts +13 -5
  52. package/services/exporter/index.ts +3 -2
  53. package/services/hotmesh/index.ts +4 -0
  54. package/services/quorum/index.ts +38 -2
  55. package/services/router/index.ts +59 -9
  56. package/services/serializer/index.ts +1 -1
  57. package/services/store/clients/redis.ts +2 -0
  58. package/services/store/index.ts +108 -22
  59. package/services/task/index.ts +31 -11
  60. package/services/worker/index.ts +49 -5
  61. package/types/activity.ts +1 -0
  62. package/types/durable.ts +11 -0
  63. package/types/exporter.ts +2 -0
  64. package/types/job.ts +1 -0
  65. package/types/quorum.ts +28 -13
  66. package/types/stream.ts +1 -0
@@ -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,
@@ -1,4 +1,4 @@
1
- import { KeyType } from '../../modules/key';
1
+ import { KeyType, VALSEP } from '../../modules/key';
2
2
  import {
3
3
  HMSH_OTT_WAIT_TIME,
4
4
  HMSH_CODE_SUCCESS,
@@ -386,9 +386,10 @@ class EngineService {
386
386
  pg: streamData.metadata.gid,
387
387
  pd: streamData.metadata.dad,
388
388
  pa: streamData.metadata.aid,
389
+ px: streamData.metadata.await === false, //sever the parent connection (px)
389
390
  trc: streamData.metadata.trc,
390
391
  spn: streamData.metadata.spn,
391
- };
392
+ };
392
393
  const activityHandler = await this.initActivity(
393
394
  streamData.metadata.topic,
394
395
  streamData.data,
@@ -459,7 +460,10 @@ class EngineService {
459
460
  return (await this.router?.publishMessage(null, streamData)) as string;
460
461
  }
461
462
  }
462
- hasParentJob(context: JobState): boolean {
463
+ hasParentJob(context: JobState, checkSevered = false): boolean {
464
+ if (checkSevered) {
465
+ return Boolean(context.metadata.pj && context.metadata.pa && !context.metadata.px);
466
+ }
463
467
  return Boolean(context.metadata.pj && context.metadata.pa);
464
468
  }
465
469
  resolveError(metadata: JobMetadata): StreamError | undefined {
@@ -537,7 +541,11 @@ class EngineService {
537
541
  const taskService = new TaskService(this.store, this.logger);
538
542
  await taskService.enqueueWorkItems(
539
543
  workItems.map(
540
- workItem => `${hookTopic}::${workItem}::${keyResolver.scrub || false}::${JSON.stringify(data)}`
544
+ workItem => [
545
+ hookTopic,
546
+ workItem,
547
+ keyResolver.scrub || false,
548
+ JSON.stringify(data)].join(VALSEP)
541
549
  ));
542
550
  this.store.publish(
543
551
  KeyType.QUORUM,
@@ -663,7 +671,7 @@ class EngineService {
663
671
  // ********** JOB COMPLETION/CLEANUP (AND JOB EMIT) ***********
664
672
  async runJobCompletionTasks(context: JobState, options: JobCompletionOptions = {}): Promise<string | void> {
665
673
  //'emit' indicates the job is still active
666
- const isAwait = this.hasParentJob(context);
674
+ const isAwait = this.hasParentJob(context, true);
667
675
  const isOneTimeSub = this.hasOneTimeSubscription(context);
668
676
  const topic = await this.getPublishesTopic(context);
669
677
  let msgId: string;
@@ -12,6 +12,7 @@ import {
12
12
  JobExport } from '../../types/exporter';
13
13
  import { SerializerService } from '../serializer';
14
14
  import { restoreHierarchy } from '../../modules/utils';
15
+ import { VALSEP } from '../../modules/key';
15
16
 
16
17
  /**
17
18
  * Downloads job data from Redis (hscan, hmget, hgetall)
@@ -108,8 +109,8 @@ class ExporterService {
108
109
  const hookReg = /([0-9,]+)-(\d+)$/;
109
110
  const flowReg = /-(\d+)$/;
110
111
  return data.map((dependency, index: number): DependencyExport => {
111
- const [action, topic, gid, ...jid] = dependency.split('::');
112
- const jobId = jid.join('::');
112
+ const [action, topic, gid, _pd, ...jid] = dependency.split(VALSEP);
113
+ const jobId = jid.join(VALSEP);
113
114
  const match = jobId.match(hookReg);
114
115
  let prefix: string;
115
116
  let type: 'hook' | 'flow' | 'other';
@@ -222,6 +222,10 @@ class HotMeshService {
222
222
 
223
223
  stop() {
224
224
  this.engine?.taskService.cancelCleanup();
225
+ this.quorum?.stop();
226
+ this.workers?.forEach((worker: WorkerService) => {
227
+ worker.stop();
228
+ });
225
229
  }
226
230
 
227
231
  async compress(terms: string[]): Promise<boolean> {
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  HMSH_ACTIVATION_MAX_RETRY,
3
- HMSH_QUORUM_DELAY_MS } from '../../modules/enums';
3
+ HMSH_QUORUM_DELAY_MS,
4
+ HMSH_QUORUM_ROLLCALL_CYCLES} from '../../modules/enums';
4
5
  import {
6
+ XSleepFor,
5
7
  formatISODate,
6
8
  getSystemHealth,
7
9
  identifyRedisType,
@@ -22,6 +24,7 @@ import {
22
24
  QuorumMessage,
23
25
  QuorumMessageCallback,
24
26
  QuorumProfile,
27
+ RollCallMessage,
25
28
  SubscriptionCallback,
26
29
  ThrottleMessage
27
30
  } from '../../types/quorum';
@@ -41,6 +44,7 @@ class QuorumService {
41
44
  untilVersion: string | null = null;
42
45
  quorum: number | null = null;
43
46
  callbacks: QuorumMessageCallback[] = [];
47
+ rollCallInterval: NodeJS.Timeout;
44
48
 
45
49
  static async init(
46
50
  namespace: string,
@@ -136,6 +140,8 @@ class QuorumService {
136
140
  self.engine.routeToSubscribers(message.topic, message.job)
137
141
  } else if (message.type === 'cron') {
138
142
  self.engine.processTimeHooks();
143
+ } else if (message.type === 'rollcall') {
144
+ self.doRollCall(message);
139
145
  }
140
146
  //if there are any callbacks, call them
141
147
  if (self.callbacks.length > 0) {
@@ -193,10 +199,40 @@ class QuorumService {
193
199
  return quorum;
194
200
  }
195
201
 
202
+ /**
203
+ * A quorum-wide command to broadcaset system details.
204
+ *
205
+ */
206
+ async doRollCall(message: RollCallMessage) {
207
+ let iteration = 0;
208
+ let max = !isNaN(message.max) ? message.max : HMSH_QUORUM_ROLLCALL_CYCLES;
209
+ if (this.rollCallInterval) clearTimeout(this.rollCallInterval);
210
+ const base = (message.interval / 2);
211
+ const amount = base + Math.ceil(Math.random() * base);
212
+ do {
213
+ await sleepFor(Math.ceil(Math.random() * 1000));
214
+ await this.sayPong(this.appId, this.guid, null, true);
215
+ if (!message.interval) return;
216
+ const { promise, timerId } = XSleepFor(amount * 1000);
217
+ this.rollCallInterval = timerId;
218
+ await promise;
219
+ } while (this.rollCallInterval && iteration++ < max - 1);
220
+ }
221
+
222
+ cancelRollCall() {
223
+ if (this.rollCallInterval) {
224
+ clearTimeout(this.rollCallInterval);
225
+ delete this.rollCallInterval;
226
+ }
227
+ }
228
+
229
+ stop() {
230
+ this.cancelRollCall();
231
+ }
196
232
 
197
233
  // ************* PUB/SUB METHODS *************
198
234
  //publish a message to the quorum
199
- async pub(quorumMessage: ThrottleMessage) {
235
+ async pub(quorumMessage: QuorumMessage) {
200
236
  return await this.store.publish(KeyType.QUORUM, quorumMessage, this.appId, quorumMessage.topic || quorumMessage.guid);
201
237
  }
202
238
  //subscribe user to quorum messages
@@ -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]][]> {
@@ -19,7 +19,7 @@ export const MDATA_SYMBOLS = {
19
19
  KEYS: ['au', 'err', 'l2s']
20
20
  },
21
21
  JOB: {
22
- KEYS: ['ngn', 'tpc', 'pj', 'pg', 'pd', 'pa', 'key', 'app', 'vrs', 'jid', 'gid', 'aid', 'ts', 'jc', 'ju', 'js', 'err', 'trc']
22
+ KEYS: ['ngn', 'tpc', 'pj', 'pg', 'pd', 'px', 'pa', 'key', 'app', 'vrs', 'jid', 'gid', 'aid', 'ts', 'jc', 'ju', 'js', 'err', 'trc']
23
23
  },
24
24
  JOB_UPDATE: {
25
25
  KEYS: ['ju', 'err']
@@ -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',
@@ -2,7 +2,9 @@ import {
2
2
  KeyService,
3
3
  KeyStoreParams,
4
4
  KeyType,
5
- HMNS} from '../../modules/key';
5
+ HMNS,
6
+ VALSEP,
7
+ TYPSEP} from '../../modules/key';
6
8
  import { ILogger } from '../logger';
7
9
  import { MDATA_SYMBOLS, SerializerService as Serializer } from '../serializer';
8
10
  import { Cache } from './cache';
@@ -50,6 +52,7 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
50
52
  del: 'del',
51
53
  expire: 'expire',
52
54
  hset: 'hset',
55
+ hscan: 'hscan',
53
56
  hsetnx: 'hsetnx',
54
57
  hincrby: 'hincrby',
55
58
  hdel: 'hdel',
@@ -68,6 +71,7 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
68
71
  lrange: 'lrange',
69
72
  rename: 'rename',
70
73
  rpush: 'rpush',
74
+ scan: 'scan',
71
75
  xack: 'xack',
72
76
  xdel: 'xdel',
73
77
  };
@@ -426,7 +430,7 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
426
430
  * when `originJobId` is interrupted/expired, the items in the
427
431
  * list (added via RPUSH) will be interrupted/expired (removed via LPOPed).
428
432
  */
429
- async registerJobDependency(depType: WorkListTaskType, originJobId: string, topic: string, jobId: string, gId: string, multi? : U): Promise<any> {
433
+ async registerJobDependency(depType: WorkListTaskType, originJobId: string, topic: string, jobId: string, gId: string, pd = '', multi? : U): Promise<any> {
430
434
  const privateMulti = multi || this.getMulti();
431
435
  const dependencyParams = {
432
436
  appId: this.appId,
@@ -436,8 +440,13 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
436
440
  KeyType.JOB_DEPENDENTS,
437
441
  dependencyParams,
438
442
  );
439
- //items listed as job dependencies have different relationships
440
- const expireTask = `${depType}::${topic}::${gId}::${jobId}`;
443
+ const expireTask = [
444
+ depType,
445
+ topic,
446
+ gId,
447
+ pd,
448
+ jobId,
449
+ ].join(VALSEP);
441
450
  privateMulti[this.commands.rpush](depKey, expireTask);
442
451
  if (!multi) {
443
452
  return await privateMulti.exec();
@@ -448,15 +457,20 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
448
457
  * Ensures a `hook signal` is delisted when its parent activity/job
449
458
  * is interrupted/expired.
450
459
  */
451
- async registerSignalDependency(jobId: string, signalKey: string, multi? : U): Promise<any> {
460
+ async registerSignalDependency(jobId: string, signalKey: string, dad: string, multi? : U): Promise<any> {
452
461
  const privateMulti = multi || this.getMulti();
453
462
  const dependencyParams = { appId: this.appId, jobId };
454
463
  const dependencyKey = this.mintKey(
455
464
  KeyType.JOB_DEPENDENTS,
456
465
  dependencyParams,
457
466
  );
458
- //tasks have '4' segments
459
- const delistTask = `delist::signal::${jobId}::${signalKey}`;
467
+ //persiste dependency tasks as multi-segment composite keys
468
+ const delistTask = [
469
+ 'delist',
470
+ 'signal',
471
+ jobId,
472
+ dad,
473
+ signalKey].join(VALSEP);
460
474
  privateMulti[this.commands.rpush](
461
475
  dependencyKey,
462
476
  delistTask,
@@ -797,11 +811,14 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
797
811
 
798
812
  async setHookSignal(hook: HookSignal, multi?: U): Promise<any> {
799
813
  const key = this.mintKey(KeyType.SIGNALS, { appId: this.appId });
800
- const { topic, resolved, jobId} = hook; //`${activityId}::${dad}::${gId}::${jobId}`
814
+ //destructure the hook key
815
+ const { topic, resolved, jobId} = hook;
801
816
  const signalKey = `${topic}:${resolved}`;
802
817
  const payload = { [signalKey]: jobId };
803
818
  await (multi || this.redisClient)[this.commands.hset](key, payload);
804
- return await this.registerSignalDependency(jobId.split('::')[3], signalKey, multi);
819
+ //jobId needs even more destructuring
820
+ const [_aid, dad, _gid, jid] = jobId.split(VALSEP);
821
+ return await this.registerSignalDependency(jid, signalKey, dad, multi);
805
822
  }
806
823
 
807
824
  async getHookSignal(topic: string, resolved: string): Promise<string | undefined> {
@@ -873,7 +890,7 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
873
890
  const depParams = { appId: this.appId, jobId };
874
891
  const depKey = this.mintKey(KeyType.JOB_DEPENDENTS, depParams);
875
892
  const context = options.interrupt ? 'INTERRUPT' : 'EXPIRE';
876
- const depKeyContext = `::${context}::${depKey}`;
893
+ const depKeyContext = `${TYPSEP}${context}${TYPSEP}${depKey}`;
877
894
  const zsetKey = this.mintKey(KeyType.TIME_RANGE, { appId: this.appId });
878
895
  await this.zAdd(zsetKey, deletionTime.toString(), depKeyContext);
879
896
  }
@@ -890,9 +907,15 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
890
907
  * for the given sleep group. Sleep groups are
891
908
  * organized into 'n'-second blocks (LISTS))
892
909
  */
893
- async registerTimeHook(jobId: string, gId: string, activityId: string, type: WorkListTaskType, deletionTime: number, multi?: U): Promise<void> {
910
+ async registerTimeHook(jobId: string, gId: string, activityId: string, type: WorkListTaskType, deletionTime: number, dad: string, multi?: U): Promise<void> {
894
911
  const listKey = this.mintKey(KeyType.TIME_RANGE, { appId: this.appId, timeValue: deletionTime });
895
- const timeEvent = `${type}::${activityId}::${gId}::${jobId}`;
912
+ //construct the composite key (the key has enough info to signal the hook)
913
+ const timeEvent = [
914
+ type,
915
+ activityId,
916
+ gId,
917
+ dad,
918
+ jobId].join(VALSEP);
896
919
  const len = await (multi || this.redisClient)[this.commands.rpush](listKey, timeEvent);
897
920
  if (multi || len === 1) {
898
921
  const zsetKey = this.mintKey(KeyType.TIME_RANGE, { appId: this.appId });
@@ -907,17 +930,23 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
907
930
  let [pType, pKey] = this.resolveTaskKeyContext(listKey);
908
931
  const timeEvent = await this.redisClient[this.commands.lpop](pKey);
909
932
  if (timeEvent) {
910
- //there are task types
911
- //1) sleep (awaken), 2) expire (OR expire-child), 3) interrupt, 4) delist, 5) child (just an index helper; no work to do)
912
- let [type, activityId, gId, ...jobId] = timeEvent.split('::');
933
+ //deconstruct composite key
934
+ let [
935
+ type,
936
+ activityId,
937
+ gId,
938
+ _pd,
939
+ ...jobId] = timeEvent.split(VALSEP);
940
+ const jid = jobId.join(VALSEP);
941
+
913
942
  if (type === 'delist') {
914
943
  pType = 'delist';
915
944
  } else if (type === 'child') {
916
945
  pType = 'child';
917
946
  } else if (type === 'expire-child') {
918
- type = 'expire'; //use the same logic as 'expire'
947
+ type = 'expire';
919
948
  }
920
- return [listKey, jobId.join('::'), gId, activityId, pType];
949
+ return [listKey, jid, gId, activityId, pType];
921
950
  }
922
951
  await this.redisClient[this.commands.zrem](zsetKey, listKey);
923
952
  return true;
@@ -931,13 +960,13 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
931
960
  * do with the work list. (not everything is known in advance,
932
961
  * so the ZSET key defines HOW to approach the work in the
933
962
  * generic LIST (lists typically contain target job ids)
934
- * @param {string} listKey - for example `::INTERRUPT::job123` or `job123`
963
+ * @param {string} listKey - composite key
935
964
  */
936
965
  resolveTaskKeyContext(listKey: string): [WorkListTaskType, string] {
937
- if (listKey.startsWith('::INTERRUPT')) {
938
- return ['interrupt', listKey.split('::')[2]];
939
- } else if (listKey.startsWith('::EXPIRE')) {
940
- return ['expire', listKey.split('::')[2]];
966
+ if (listKey.startsWith(`${TYPSEP}INTERRUPT`)) {
967
+ return ['interrupt', listKey.split(TYPSEP)[2]];
968
+ } else if (listKey.startsWith(`${TYPSEP}EXPIRE`)) {
969
+ return ['expire', listKey.split(TYPSEP)[2]];
941
970
  } else {
942
971
  return ['sleep', listKey];
943
972
  }
@@ -998,6 +1027,63 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
998
1027
  const jobKey = this.mintKey(KeyType.JOB_STATE, { appId: this.appId, jobId });
999
1028
  await this.redisClient[this.commands.del](jobKey);
1000
1029
  }
1030
+
1031
+ async findJobs(queryString: string = '*', limit: number = 1000, batchSize: number = 1000): Promise<[string, string[]]> {
1032
+ const matchKey = this.mintKey(KeyType.JOB_STATE, { appId: this.appId, jobId: queryString });
1033
+ let cursor = '0';
1034
+ let keys: string[];
1035
+ const matchingKeys: string[] = [];
1036
+ do {
1037
+ const output = await this.exec(
1038
+ 'SCAN',
1039
+ cursor,
1040
+ 'MATCH',
1041
+ matchKey,
1042
+ 'COUNT',
1043
+ batchSize.toString(),
1044
+ ) as unknown as [string, string[]];
1045
+ if (Array.isArray(output)) {
1046
+ [cursor, keys] = output;
1047
+ for (let key of [...keys]) {
1048
+ matchingKeys.push(key);
1049
+ }
1050
+ if (matchingKeys.length >= limit) {
1051
+ break;
1052
+ }
1053
+ } else {
1054
+ break;
1055
+ }
1056
+ } while (cursor !== '0');
1057
+ return [cursor, matchingKeys];
1058
+ }
1059
+
1060
+ async findJobFields(jobId: string, fieldMatchPattern: string = '*', limit: number = 1000, batchSize: number = 1000, cursor = '0'): Promise<[string, StringStringType]> {
1061
+ let fields: string[] = [];
1062
+ const matchingFields: StringStringType = {};
1063
+ const jobKey = this.mintKey(KeyType.JOB_STATE, { appId: this.appId, jobId });
1064
+ let len = 0;
1065
+ do {
1066
+ const output = await this.exec(
1067
+ 'HSCAN',
1068
+ jobKey,
1069
+ cursor,
1070
+ 'MATCH',
1071
+ fieldMatchPattern,
1072
+ 'COUNT',
1073
+ batchSize.toString(),
1074
+ ) as unknown as [string, string[]];
1075
+ if (Array.isArray(output)) {
1076
+ [cursor, fields] = output;
1077
+ for (let i = 0; i < fields.length; i += 2) {
1078
+ len++;
1079
+ matchingFields[fields[i]] = fields[i + 1];
1080
+ }
1081
+ } else {
1082
+ break;
1083
+ }
1084
+ } while (cursor !== '0' && len < limit);
1085
+ return [cursor, matchingFields];
1086
+ }
1001
1087
  }
1002
1088
 
1003
1089
  export { StoreService };