@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
@@ -12,6 +12,7 @@ const redis_2 = require("../stream/clients/redis");
12
12
  const ioredis_3 = require("../sub/clients/ioredis");
13
13
  const redis_3 = require("../sub/clients/redis");
14
14
  const stream_1 = require("../../types/stream");
15
+ const enums_1 = require("../../modules/enums");
15
16
  class WorkerService {
16
17
  constructor() {
17
18
  this.reporting = false;
@@ -26,6 +27,7 @@ class WorkerService {
26
27
  service.namespace = namespace;
27
28
  service.appId = appId;
28
29
  service.guid = guid;
30
+ service.callback = worker.callback;
29
31
  service.topic = worker.topic;
30
32
  service.config = config;
31
33
  service.logger = logger;
@@ -95,14 +97,51 @@ class WorkerService {
95
97
  return async (topic, message) => {
96
98
  self.logger.debug('worker-event-received', { topic, type: message.type });
97
99
  if (message.type === 'throttle') {
98
- self.throttle(message.throttle);
100
+ if (message.topic !== null) { //undefined allows passthrough
101
+ self.throttle(message.throttle);
102
+ }
99
103
  }
100
104
  else if (message.type === 'ping') {
101
105
  self.sayPong(self.appId, self.guid, message.originator, message.details);
102
106
  }
107
+ else if (message.type === 'rollcall') {
108
+ if (message.topic !== null) { //undefined allows passthrough
109
+ self.doRollCall(message);
110
+ }
111
+ }
103
112
  };
104
113
  }
105
- async sayPong(appId, guid, originator, details = false) {
114
+ /**
115
+ * A quorum-wide command to broadcaset system details.
116
+ *
117
+ */
118
+ async doRollCall(message) {
119
+ let iteration = 0;
120
+ let max = !isNaN(message.max) ? message.max : enums_1.HMSH_QUORUM_ROLLCALL_CYCLES;
121
+ if (this.rollCallInterval)
122
+ clearTimeout(this.rollCallInterval);
123
+ const base = (message.interval / 2);
124
+ const amount = base + Math.ceil(Math.random() * base);
125
+ do {
126
+ await (0, utils_1.sleepFor)(Math.ceil(Math.random() * 1000));
127
+ await this.sayPong(this.appId, this.guid, null, true, message.signature);
128
+ if (!message.interval)
129
+ return;
130
+ const { promise, timerId } = (0, utils_1.XSleepFor)(amount * 1000);
131
+ this.rollCallInterval = timerId;
132
+ await promise;
133
+ } while (this.rollCallInterval && iteration++ < max - 1);
134
+ }
135
+ cancelRollCall() {
136
+ if (this.rollCallInterval) {
137
+ clearTimeout(this.rollCallInterval);
138
+ delete this.rollCallInterval;
139
+ }
140
+ }
141
+ stop() {
142
+ this.cancelRollCall();
143
+ }
144
+ async sayPong(appId, guid, originator, details = false, signature = false) {
106
145
  let profile;
107
146
  if (details) {
108
147
  const params = {
@@ -122,11 +161,13 @@ class WorkerService {
122
161
  reclaimDelay: this.router.reclaimDelay,
123
162
  reclaimCount: this.router.reclaimCount,
124
163
  system: await (0, utils_1.getSystemHealth)(),
164
+ signature: signature ? this.callback.toString() : undefined,
125
165
  };
126
166
  }
127
167
  this.store.publish(key_1.KeyType.QUORUM, {
128
168
  type: 'pong',
129
- guid, originator,
169
+ guid,
170
+ originator,
130
171
  profile,
131
172
  }, appId);
132
173
  }
@@ -74,6 +74,7 @@ interface AwaitActivity extends BaseActivity {
74
74
  type: 'await';
75
75
  eventName: string;
76
76
  timeout: number;
77
+ await?: boolean;
77
78
  }
78
79
  interface WorkerActivity extends BaseActivity {
79
80
  type: 'worker';
@@ -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
  */
@@ -21,6 +21,8 @@ export interface JobTimeline {
21
21
  dimension: string;
22
22
  duplex: 'entry' | 'exit';
23
23
  timestamp: string;
24
+ created?: string;
25
+ updated?: string;
24
26
  actions?: ActivityAction[];
25
27
  }
26
28
  export interface DependencyExport {
@@ -15,6 +15,7 @@ type JobMetadata = {
15
15
  pg?: string;
16
16
  pd?: string;
17
17
  pa?: string;
18
+ px?: boolean;
18
19
  ngn?: string;
19
20
  app: string;
20
21
  vrs: string;
@@ -45,42 +45,56 @@ export interface QuorumProfile {
45
45
  reclaimDelay?: number;
46
46
  reclaimCount?: number;
47
47
  system?: SystemHealth;
48
+ signature?: string;
48
49
  }
49
- export interface PingMessage {
50
+ interface QuorumMessageBase {
51
+ guid?: string;
52
+ topic?: string;
53
+ type?: string;
54
+ }
55
+ export interface PingMessage extends QuorumMessageBase {
50
56
  type: 'ping';
51
57
  originator: string;
52
58
  details?: boolean;
53
59
  }
54
- export interface WorkMessage {
60
+ export interface WorkMessage extends QuorumMessageBase {
55
61
  type: 'work';
56
62
  originator: string;
57
63
  }
58
- export interface CronMessage {
64
+ export interface CronMessage extends QuorumMessageBase {
59
65
  type: 'cron';
60
66
  originator: string;
61
67
  }
62
- export interface PongMessage {
68
+ export interface PongMessage extends QuorumMessageBase {
63
69
  type: 'pong';
64
70
  guid: string;
65
71
  originator: string;
66
72
  profile?: QuorumProfile;
67
73
  }
68
- export interface ActivateMessage {
74
+ export interface ActivateMessage extends QuorumMessageBase {
69
75
  type: 'activate';
70
76
  cache_mode: 'nocache' | 'cache';
71
77
  until_version: string;
72
78
  }
73
- export interface JobMessage {
79
+ export interface JobMessage extends QuorumMessageBase {
74
80
  type: 'job';
75
81
  topic: string;
76
82
  job: JobOutput;
77
83
  }
78
- export interface ThrottleMessage {
84
+ export interface ThrottleMessage extends QuorumMessageBase {
79
85
  type: 'throttle';
80
86
  guid?: string;
81
87
  topic?: string;
82
88
  throttle: number;
83
89
  }
90
+ export interface RollCallMessage extends QuorumMessageBase {
91
+ type: 'rollcall';
92
+ guid?: string;
93
+ topic?: string | null;
94
+ interval: number;
95
+ max?: number;
96
+ signature?: boolean;
97
+ }
84
98
  export interface JobMessageCallback {
85
99
  (topic: string, message: JobOutput): void;
86
100
  }
@@ -96,5 +110,5 @@ export interface QuorumMessageCallback {
96
110
  * These messages serve to coordinate the cache invalidation and switch-over
97
111
  * to the new version without any downtime and a coordinating parent server.
98
112
  */
99
- export type QuorumMessage = PingMessage | PongMessage | ActivateMessage | WorkMessage | JobMessage | ThrottleMessage | CronMessage;
113
+ export type QuorumMessage = PingMessage | PongMessage | ActivateMessage | WorkMessage | JobMessage | ThrottleMessage | RollCallMessage | CronMessage;
100
114
  export {};
@@ -37,6 +37,7 @@ export interface StreamData {
37
37
  trc?: string;
38
38
  spn?: string;
39
39
  try?: number;
40
+ await?: boolean;
40
41
  };
41
42
  type?: StreamDataType;
42
43
  data: Record<string, unknown>;
package/modules/enums.ts CHANGED
@@ -23,6 +23,7 @@ export const HMSH_CODE_DURABLE_RETRYABLE = 599;
23
23
  export const HMSH_STATUS_UNKNOWN = 'unknown';
24
24
 
25
25
  // QUORUM
26
+ export const HMSH_QUORUM_ROLLCALL_CYCLES = 12; //max iterations
26
27
  export const HMSH_QUORUM_DELAY_MS = 250;
27
28
  export const HMSH_ACTIVATION_MAX_RETRY = 3;
28
29
 
package/modules/key.ts CHANGED
@@ -28,7 +28,12 @@ import { KeyStoreParams, KeyType } from '../types/hotmesh';
28
28
  * hmsh:<appid>:sym:vals: -> {hash} list of symbols for job values across all app versions
29
29
  */
30
30
 
31
- const HMNS = "hmsh"; //default
31
+ const HMNS = "hmsh";
32
+
33
+ const KEYSEP = ':'; //default delimiter for keys
34
+ const VALSEP = '::'; //default delimiter for vals
35
+ const WEBSEP = '::'; //default delimiter for webhook vals
36
+ const TYPSEP = '::'; //delimiter for ZSET task typing (how should a list be used?)
32
37
 
33
38
  class KeyService {
34
39
 
@@ -93,4 +98,4 @@ class KeyService {
93
98
  }
94
99
  }
95
100
 
96
- export { KeyService, KeyType, KeyStoreParams, HMNS };
101
+ export { KeyService, KeyType, KeyStoreParams, HMNS, KEYSEP, TYPSEP, WEBSEP, VALSEP };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.0.48",
3
+ "version": "0.0.50",
4
4
  "description": "Unbreakable Workflows",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -28,6 +28,7 @@
28
28
  "test:connect:redis": "NODE_ENV=test jest ./tests/unit/services/connector/clients/redis.test.ts --detectOpenHandles --forceExit --verbose",
29
29
  "test:connect:ioredis": "NODE_ENV=test jest ./tests/unit/services/connector/clients/ioredis.test.ts --detectOpenHandles --forceExit --verbose",
30
30
  "test:emit": "NODE_ENV=test jest ./tests/functional/emit/index.test.ts --detectOpenHandles --forceExit --verbose",
31
+ "test:await": "NODE_ENV=test jest ./tests/functional/awaiter/index.test.ts --detectOpenHandles --forceExit --verbose",
31
32
  "test:hook": "NODE_ENV=test jest ./tests/functional/hook/index.test.ts --detectOpenHandles --forceExit --verbose",
32
33
  "test:signal": "NODE_ENV=test jest ./tests/functional/signal/index.test.ts --detectOpenHandles --forceExit --verbose",
33
34
  "test:interrupt": "NODE_ENV=test jest ./tests/functional/interrupt/index.test.ts --detectOpenHandles --forceExit --verbose",
@@ -95,6 +95,12 @@ class Await extends Activity {
95
95
  type: StreamDataType.AWAIT,
96
96
  data: this.context.data
97
97
  };
98
+ if (this.config.await !== true) {
99
+ const doAwait = Pipe.resolve(this.config.await, this.context);
100
+ if (doAwait === false) {
101
+ streamData.metadata.await = false;
102
+ }
103
+ }
98
104
  if (this.config.retry) {
99
105
  streamData.policies = {
100
106
  retry: this.config.retry
@@ -148,6 +148,7 @@ class Hook extends Activity {
148
148
  `${this.metadata.aid}${this.metadata.dad || ''}`,
149
149
  'sleep',
150
150
  duration,
151
+ this.metadata.dad || '',
151
152
  );
152
153
  return this.context.metadata.jid;
153
154
  }
@@ -15,6 +15,7 @@ import {
15
15
  import { JobState } from '../../types/job';
16
16
  import { RedisMulti } from '../../types/redis';
17
17
  import { StringScalarType } from '../../types/serializer';
18
+ import { WorkListTaskType } from '../../types/task';
18
19
 
19
20
  class Trigger extends Activity {
20
21
  config: TriggerActivity;
@@ -50,6 +51,10 @@ class Trigger extends Activity {
50
51
  await this.registerJobDependency(multi);
51
52
  await multi.exec();
52
53
 
54
+ //if the parent (spawner) chose not to await,
55
+ // emit the job_id as the data payload { job_id }
56
+ this.execAdjacentParent();
57
+
53
58
  telemetry.mapActivityAttributes();
54
59
  const jobStatus = Number(this.context.metadata.js);
55
60
  telemetry.setJobAttributes({ 'app.job.jss': jobStatus });
@@ -79,6 +84,12 @@ class Trigger extends Activity {
79
84
  this.context.metadata.js = amount;
80
85
  }
81
86
 
87
+ async execAdjacentParent() {
88
+ if (this.context.metadata.px) {
89
+ await this.engine.execAdjacentParent(this.context, {metadata: this.context.metadata, data: { job_id: this.context.metadata.jid }});
90
+ }
91
+ }
92
+
82
93
  createInputContext(): Partial<JobState> {
83
94
  const input = {
84
95
  [this.metadata.aid]: {
@@ -115,6 +126,7 @@ class Trigger extends Activity {
115
126
  pg: this.context.metadata.pg,
116
127
  pd: this.context.metadata.pd,
117
128
  pa: this.context.metadata.pa,
129
+ px: this.context.metadata.px,
118
130
  app: id,
119
131
  vrs: version,
120
132
  tpc: this.config.subscribes,
@@ -193,12 +205,23 @@ class Trigger extends Activity {
193
205
  }
194
206
  if (resolvedDepKey) {
195
207
  const isParentOrigin = (resolvedDepKey === this.context.metadata.pj) || (resolvedDepKey === resolvedAdjKey);
208
+ let type: WorkListTaskType;
209
+ if (isParentOrigin) {
210
+ if (this.context.metadata.px) {
211
+ type = 'child'
212
+ } else {
213
+ type = 'expire-child'
214
+ }
215
+ } else {
216
+ type = 'expire';
217
+ }
196
218
  await this.store.registerJobDependency(
197
- isParentOrigin ? 'expire-child' : 'expire',
219
+ type,
198
220
  resolvedDepKey,
199
221
  this.context.metadata.tpc,
200
222
  this.context.metadata.jid,
201
223
  this.context.metadata.gid,
224
+ this.context.metadata.pd,
202
225
  multi,
203
226
  );
204
227
  }
@@ -209,6 +232,7 @@ class Trigger extends Activity {
209
232
  this.context.metadata.tpc,
210
233
  this.context.metadata.jid,
211
234
  this.context.metadata.gid,
235
+ this.context.metadata.pd,
212
236
  multi,
213
237
  );
214
238
  }
@@ -13,6 +13,7 @@ import {
13
13
  JobTimeline } from '../../types/exporter';
14
14
  import { SerializerService } from '../serializer';
15
15
  import { restoreHierarchy } from '../../modules/utils';
16
+ import { VALSEP } from '../../modules/key';
16
17
 
17
18
  /**
18
19
  * Downloads job data from Redis (hscan, hmget, hgetall)
@@ -116,19 +117,31 @@ class ExporterService {
116
117
  const activityName = item[1].split('/')[0];
117
118
  const duplex = item[1].endsWith('/ac') ? 'entry' : 'exit';
118
119
  const timestamp = item[2];
119
- const event: JobTimeline = {
120
+ let event: JobTimeline = {
120
121
  activity: activityName,
121
122
  duplex: duplex as 'entry' | 'exit',
122
123
  dimension: dimensions,
123
124
  timestamp,
125
+ created: timestamp,
126
+ updated: timestamp,
124
127
  };
125
- timeline.push(event);
128
+ const prior = timeline[timeline.length - 1];
129
+ if (prior && prior.activity === event.activity && prior.duplex !== event.duplex && prior.dimension === event.dimension) {
130
+ if (event.duplex === 'exit') {
131
+ prior.updated = event.timestamp;
132
+ } else {
133
+ prior.created = event.timestamp;
134
+ }
135
+ event = prior;
136
+ } else {
137
+ timeline.push(event);
138
+ }
126
139
 
127
140
  if (this.isMainEntry(item[1])) {
128
141
  event.actions = [] as ActivityAction[];
129
142
  this.interleaveActions(actions.main, event.actions);
130
143
  } else if (this.isHookEntry(item[1])) {
131
- const hookDimension = `/${parts[1]}/${parts[2]}`;
144
+ const hookDimension = `/${parts[1]}/${parts[2]}`;
132
145
  const hookActions = actions.hooks[hookDimension];
133
146
  event.actions = [] as ActivityAction[];
134
147
  this.interleaveActions(hookActions, event.actions);
@@ -191,17 +204,15 @@ class ExporterService {
191
204
  * @returns - the organized dependency data
192
205
  */
193
206
  inflateDependencyData(data: string[], actions: JobActionExport): DependencyExport[] {
194
- //console.log('dependency data>', data);
195
207
  const hookReg = /([0-9,]+)-(\d+)$/;
196
208
  const flowReg = /-(\d+)$/;
197
209
  return data.map((dependency, index: number): DependencyExport => {
198
- const [action, topic, gid, ...jid] = dependency.split('::');
199
- const jobId = jid.join('::');
210
+ const [action, topic, gid, _pd, ...jid] = dependency.split(VALSEP);
211
+ const jobId = jid.join(VALSEP);
200
212
  const match = jobId.match(hookReg);
201
213
  let prefix: string;
202
214
  let type: 'hook' | 'flow' | 'other';
203
215
  let dimensionKey: string = '';
204
-
205
216
  if (match) {
206
217
  //hook-originating dependency
207
218
  const [_, dimension, counter] = match;
@@ -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 {