@hotmeshio/hotmesh 0.0.52 → 0.0.53

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 (126) hide show
  1. package/README.md +22 -18
  2. package/build/index.d.ts +1 -2
  3. package/build/index.js +1 -3
  4. package/build/modules/enums.d.ts +8 -3
  5. package/build/modules/enums.js +16 -8
  6. package/build/modules/errors.d.ts +98 -18
  7. package/build/modules/errors.js +90 -33
  8. package/build/package.json +7 -2
  9. package/build/services/activities/activity.d.ts +8 -0
  10. package/build/services/activities/activity.js +63 -14
  11. package/build/services/activities/await.js +6 -6
  12. package/build/services/activities/cycle.d.ts +2 -2
  13. package/build/services/activities/cycle.js +5 -5
  14. package/build/services/activities/hook.js +4 -4
  15. package/build/services/activities/interrupt.d.ts +3 -3
  16. package/build/services/activities/interrupt.js +15 -6
  17. package/build/services/activities/signal.d.ts +2 -2
  18. package/build/services/activities/signal.js +4 -4
  19. package/build/services/activities/trigger.js +12 -3
  20. package/build/services/activities/worker.js +6 -6
  21. package/build/services/compiler/deployer.js +33 -5
  22. package/build/services/compiler/validator.d.ts +2 -0
  23. package/build/services/compiler/validator.js +5 -1
  24. package/build/services/durable/client.d.ts +7 -1
  25. package/build/services/durable/client.js +56 -30
  26. package/build/services/durable/exporter.d.ts +7 -72
  27. package/build/services/durable/exporter.js +105 -295
  28. package/build/services/durable/handle.d.ts +11 -6
  29. package/build/services/durable/handle.js +59 -46
  30. package/build/services/durable/index.d.ts +0 -2
  31. package/build/services/durable/index.js +0 -2
  32. package/build/services/durable/schemas/factory.d.ts +33 -0
  33. package/build/services/durable/schemas/factory.js +2356 -0
  34. package/build/services/durable/search.js +8 -8
  35. package/build/services/durable/worker.js +117 -25
  36. package/build/services/durable/workflow.d.ts +46 -43
  37. package/build/services/durable/workflow.js +273 -277
  38. package/build/services/engine/index.js +3 -0
  39. package/build/services/exporter/index.d.ts +2 -4
  40. package/build/services/exporter/index.js +4 -5
  41. package/build/services/mapper/index.d.ts +6 -2
  42. package/build/services/mapper/index.js +6 -2
  43. package/build/services/pipe/functions/array.d.ts +2 -10
  44. package/build/services/pipe/functions/array.js +30 -28
  45. package/build/services/pipe/functions/conditional.d.ts +1 -0
  46. package/build/services/pipe/functions/conditional.js +3 -0
  47. package/build/services/pipe/functions/date.d.ts +1 -0
  48. package/build/services/pipe/functions/date.js +4 -0
  49. package/build/services/pipe/functions/index.d.ts +2 -0
  50. package/build/services/pipe/functions/index.js +2 -0
  51. package/build/services/pipe/functions/logical.d.ts +5 -0
  52. package/build/services/pipe/functions/logical.js +12 -0
  53. package/build/services/pipe/functions/object.d.ts +3 -0
  54. package/build/services/pipe/functions/object.js +25 -7
  55. package/build/services/pipe/index.d.ts +20 -3
  56. package/build/services/pipe/index.js +82 -16
  57. package/build/services/router/index.js +14 -3
  58. package/build/services/serializer/index.d.ts +3 -2
  59. package/build/services/serializer/index.js +11 -4
  60. package/build/services/store/clients/ioredis.js +6 -6
  61. package/build/services/store/clients/redis.js +7 -7
  62. package/build/services/store/index.d.ts +2 -0
  63. package/build/services/store/index.js +4 -1
  64. package/build/services/stream/clients/ioredis.js +8 -8
  65. package/build/services/stream/clients/redis.js +1 -1
  66. package/build/types/activity.d.ts +60 -5
  67. package/build/types/durable.d.ts +168 -33
  68. package/build/types/exporter.d.ts +26 -4
  69. package/build/types/index.d.ts +2 -2
  70. package/build/types/job.d.ts +69 -5
  71. package/build/types/pipe.d.ts +81 -3
  72. package/build/types/stream.d.ts +61 -1
  73. package/build/types/stream.js +4 -0
  74. package/index.ts +1 -2
  75. package/modules/enums.ts +16 -8
  76. package/modules/errors.ts +174 -32
  77. package/package.json +7 -2
  78. package/services/activities/activity.ts +63 -14
  79. package/services/activities/await.ts +6 -6
  80. package/services/activities/cycle.ts +7 -6
  81. package/services/activities/hook.ts +4 -4
  82. package/services/activities/interrupt.ts +19 -9
  83. package/services/activities/signal.ts +6 -5
  84. package/services/activities/trigger.ts +16 -4
  85. package/services/activities/worker.ts +7 -7
  86. package/services/compiler/deployer.ts +33 -6
  87. package/services/compiler/validator.ts +7 -3
  88. package/services/durable/client.ts +47 -14
  89. package/services/durable/exporter.ts +110 -318
  90. package/services/durable/handle.ts +63 -50
  91. package/services/durable/index.ts +0 -2
  92. package/services/durable/schemas/factory.ts +2358 -0
  93. package/services/durable/search.ts +8 -8
  94. package/services/durable/worker.ts +128 -29
  95. package/services/durable/workflow.ts +304 -288
  96. package/services/engine/index.ts +4 -0
  97. package/services/exporter/index.ts +10 -12
  98. package/services/mapper/index.ts +6 -2
  99. package/services/pipe/functions/array.ts +24 -37
  100. package/services/pipe/functions/conditional.ts +4 -0
  101. package/services/pipe/functions/date.ts +6 -0
  102. package/services/pipe/functions/index.ts +7 -5
  103. package/services/pipe/functions/logical.ts +11 -0
  104. package/services/pipe/functions/object.ts +26 -7
  105. package/services/pipe/index.ts +99 -21
  106. package/services/quorum/index.ts +1 -3
  107. package/services/router/index.ts +14 -3
  108. package/services/serializer/index.ts +12 -5
  109. package/services/store/clients/ioredis.ts +6 -6
  110. package/services/store/clients/redis.ts +7 -7
  111. package/services/store/index.ts +4 -1
  112. package/services/stream/clients/ioredis.ts +8 -8
  113. package/services/stream/clients/redis.ts +1 -1
  114. package/types/activity.ts +87 -15
  115. package/types/durable.ts +246 -73
  116. package/types/exporter.ts +31 -5
  117. package/types/index.ts +6 -7
  118. package/types/job.ts +130 -36
  119. package/types/pipe.ts +84 -3
  120. package/types/stream.ts +82 -23
  121. package/build/services/durable/factory.d.ts +0 -17
  122. package/build/services/durable/factory.js +0 -817
  123. package/build/services/durable/meshos.d.ts +0 -127
  124. package/build/services/durable/meshos.js +0 -380
  125. package/services/durable/factory.ts +0 -818
  126. package/services/durable/meshos.ts +0 -441
@@ -1,142 +1,191 @@
1
1
  import ms from 'ms';
2
2
 
3
3
  import {
4
- DurableIncompleteSignalError,
5
- DurableSleepForError,
6
- DurableWaitForSignalError } from '../../modules/errors';
4
+ DurableChildError,
5
+ DurableFatalError,
6
+ DurableMaxedError,
7
+ DurableProxyError,
8
+ DurableRetryError,
9
+ DurableSleepError,
10
+ DurableTimeoutError,
11
+ DurableWaitForError } from '../../modules/errors';
7
12
  import { KeyService, KeyType } from '../../modules/key';
8
13
  import { asyncLocalStorage } from '../../modules/storage';
9
- import { ClientService as Client } from './client';
10
- import { ConnectionService as Connection } from './connection';
11
- import { DEFAULT_COEFFICIENT } from './factory';
14
+ import {
15
+ deterministicRandom,
16
+ formatISODate,
17
+ sleepFor } from '../../modules/utils';
12
18
  import { Search } from './search';
13
19
  import { WorkerService } from './worker';
14
20
  import { HotMeshService as HotMesh } from '../hotmesh';
15
21
  import {
16
22
  ActivityConfig,
23
+ ChildResponseType,
17
24
  HookOptions,
25
+ ProxyResponseType,
18
26
  ProxyType,
19
27
  WorkflowContext,
20
- WorkflowOptions } from "../../types/durable";
21
- import { JobInterruptOptions, JobOutput, JobState } from '../../types/job';
22
- import { StreamStatus } from '../../types/stream';
23
- import { deterministicRandom } from '../../modules/utils';
28
+ WorkflowOptions
29
+ } from "../../types/durable";
30
+ import { JobInterruptOptions } from '../../types/job';
31
+ import { StreamCode, StreamError, StreamStatus } from '../../types/stream';
24
32
  import { StringStringType } from '../../types';
33
+ import {
34
+ HMSH_CODE_DURABLE_CHILD,
35
+ HMSH_CODE_DURABLE_FATAL,
36
+ HMSH_CODE_DURABLE_MAXED,
37
+ HMSH_CODE_DURABLE_PROXY,
38
+ HMSH_CODE_DURABLE_SLEEP,
39
+ HMSH_CODE_DURABLE_TIMEOUT,
40
+ HMSH_CODE_DURABLE_WAIT,
41
+ HMSH_DURABLE_EXP_BACKOFF,
42
+ HMSH_DURABLE_MAX_ATTEMPTS,
43
+ HMSH_DURABLE_MAX_INTERVAL} from '../../modules/enums';
44
+ import { SerializerService } from '../serializer';
25
45
 
26
46
  export class WorkflowService {
27
47
 
28
48
  /**
29
- * Spawns a child workflow. await and return the result.
30
- * @template T - the result type
31
- * @param {WorkflowOptions} options - the workflow options
32
- * @returns {Promise<T>} - the result of the child workflow
49
+ * Return a handle to the hotmesh client currently running the workflow
50
+ * @returns {Promise<HotMesh>} - a hotmesh client
33
51
  */
34
- static async executeChild<T>(options: WorkflowOptions): Promise<T> {
52
+ static async getHotMesh(): Promise<HotMesh> {
35
53
  const store = asyncLocalStorage.getStore();
54
+ const workflowTopic = store.get('workflowTopic');
36
55
  const namespace = store.get('namespace');
56
+ return await WorkerService.getHotMesh(workflowTopic, { namespace });
57
+ }
58
+
59
+ /**
60
+ * Returns the current workflow context restored
61
+ * from Redis
62
+ */
63
+ static getContext(): WorkflowContext {
64
+ const store = asyncLocalStorage.getStore();
37
65
  const workflowId = store.get('workflowId');
38
- const originJobId = store.get('originJobId');
66
+ const replay = store.get('replay');
67
+ const cursor = store.get('cursor');
39
68
  const workflowDimension = store.get('workflowDimension') ?? '';
69
+ const workflowTopic = store.get('workflowTopic');
70
+ const namespace = store.get('namespace');
40
71
  const workflowTrace = store.get('workflowTrace');
72
+ const canRetry = store.get('canRetry');
41
73
  const workflowSpan = store.get('workflowSpan');
42
74
  const COUNTER = store.get('counter');
43
- const execIndex = COUNTER.counter = COUNTER.counter + 1;
44
- //NOTE: this is the hash prefix; necessary for the search index to locate the entity
45
- //if the hash is a helper, a dash begins it, so it isn't indexed
46
- const entityOrEmptyString = options.entity ?? '';
47
- //If the workflowId is not provided, it is generated from the entity and the workflow name
48
- const childJobId = options.workflowId ?? `${entityOrEmptyString}-${workflowId}-$${options.entity ?? options.workflowName}${workflowDimension}-${execIndex}`;
49
- const parentWorkflowId = workflowId;
50
-
51
- const client = new Client({
52
- connection: await Connection.connect(WorkerService.connection),
53
- });
54
-
55
- let handle = await client.workflow.getHandle(
56
- options.entity ?? options.taskQueue,
57
- options.entity ?? options.workflowName,
58
- childJobId,
75
+ const raw = store.get('raw');
76
+ return {
77
+ canRetry,
78
+ COUNTER,
79
+ counter: COUNTER.counter,
80
+ cursor,
59
81
  namespace,
60
- );
61
-
62
- try {
63
- return await handle.result(true) as T;
64
- } catch (error) {
65
- handle = await client.workflow.start({
66
- ...options,
67
- namespace,
68
- workflowId: childJobId,
69
- originJobId: originJobId ?? workflowId,
70
- parentWorkflowId,
71
- workflowTrace,
72
- workflowSpan,
73
- });
74
- //todo: options.startToCloseTimeout
75
- const result = await handle.result();
76
- return result as T;
77
- }
82
+ raw,
83
+ replay,
84
+ workflowId,
85
+ workflowDimension,
86
+ workflowTopic,
87
+ workflowTrace,
88
+ workflowSpan,
89
+ };
78
90
  }
79
91
 
80
92
  /**
81
- * Spawns a child workflow. return the childJobId.
82
- * This method is used when the result of the child workflow is not needed.
93
+ * Spawns a child workflow and awaits the return.
94
+ * @template T - the result type
83
95
  * @param {WorkflowOptions} options - the workflow options
84
- * @returns {Promise<string>} - the childJobId
96
+ * @returns {Promise<T>} - the result of the child workflow
97
+ * @example
98
+ * const result = await Durable.workflow.execChild<typeof resultType>({ ...options });
85
99
  */
86
- static async startChild<T>(options: WorkflowOptions): Promise<string> {
100
+ static async execChild<T>(options: WorkflowOptions): Promise<T> {
101
+ //SYNC
102
+ //check if the activity already ran (check $error/done)
103
+ const isStartChild = options.await === false;
104
+ const [didRun, execIndex, result]: [boolean, number, ChildResponseType<T>] = await WorkflowService.didRun(isStartChild ? 'start' : 'child');
87
105
  const store = asyncLocalStorage.getStore();
88
- const namespace = store.get('namespace');
106
+ const canRetry = store.get('canRetry');
107
+
108
+ if (didRun) {
109
+ if (result?.$error && (!result.$error.is_stream_error || (result.$error.is_stream_error && !canRetry))) {
110
+ if (options?.config?.throwOnError !== false) {
111
+ //rethrow remote execution error (simulates throw)
112
+ const code: StreamCode = result.$error.code;
113
+ const message = result.$error.message;
114
+ const stack = result.$error.stack;
115
+ if (code === HMSH_CODE_DURABLE_FATAL) {
116
+ throw new DurableFatalError(message, stack);
117
+ } else if (code == HMSH_CODE_DURABLE_MAXED) {
118
+ throw new DurableMaxedError(message, stack);
119
+ } else if (code == HMSH_CODE_DURABLE_TIMEOUT) {
120
+ throw new DurableTimeoutError(message, stack);
121
+ } else {
122
+ throw new DurableRetryError(message, stack);
123
+ }
124
+ }
125
+ return result.$error as T;
126
+ } else if (result.data) {
127
+ return result.data as T;
128
+ }
129
+ }
130
+ //package the interruption inputs
131
+ const interruptionRegistry = store.get('interruptionRegistry');
89
132
  const workflowId = store.get('workflowId');
133
+ const originJobId = store.get('originJobId');
90
134
  const workflowDimension = store.get('workflowDimension') ?? '';
91
- const workflowTrace = store.get('workflowTrace');
92
- const workflowSpan = store.get('workflowSpan');
93
- const COUNTER = store.get('counter');
94
- const execIndex = COUNTER.counter = COUNTER.counter + 1;
95
- const sessionId = `-start${workflowDimension}-${execIndex}-`;
96
- const replay = store.get('replay') as StringStringType;
97
- if (sessionId in replay) {
98
- return replay[sessionId];
99
- }
100
- //NOTE: this is the hash prefix; necessary for the search index to locate the entity
101
135
  const entityOrEmptyString = options.entity ?? '';
102
- //If the workflowId is not provided, it is generated from the entity and the workflow name
136
+ const childJobId = options.workflowId ?? `${entityOrEmptyString}-${workflowId}-$${options.entity ?? options.workflowName}${workflowDimension}-${execIndex}`;
103
137
  const parentWorkflowId = workflowId;
104
- const workflowTopic = `${options.entity ?? options.taskQueue}-${options.entity ?? options.workflowName}`;
105
-
106
- const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
107
- const keyParams = { appId: hotMeshClient.appId, jobId: workflowId }
108
- const workflowGuid = KeyService.mintKey(hotMeshClient.namespace, KeyType.JOB_STATE, keyParams);
109
- let childJobId = await hotMeshClient.engine.store.exec('HGET', workflowGuid, sessionId) as string;
110
- if (childJobId) {
111
- return childJobId;
112
- } else {
113
- childJobId = options.workflowId ?? `${entityOrEmptyString}-${workflowId}-$${options.entity ?? options.workflowName}${workflowDimension}-${execIndex}`;
114
- }
115
- const client = new Client({
116
- connection: await Connection.connect(WorkerService.connection),
117
- });
118
- await client.workflow.start({
119
- ...options,
120
- namespace,
121
- workflowId: childJobId,
138
+ const taskQueueName = options.entity ?? options.taskQueue;
139
+ const workflowName = options.entity ?? options.workflowName;
140
+ const workflowTopic = `${taskQueueName}-${workflowName}`;
141
+ const interruptionMessage = {
142
+ arguments: [...(options.args || [])],
143
+ await: options?.await ?? true,
144
+ backoffCoefficient: options?.config?.backoffCoefficient ?? HMSH_DURABLE_EXP_BACKOFF,
145
+ index: execIndex,
146
+ maximumAttempts: options?.config?.maximumAttempts ?? HMSH_DURABLE_MAX_ATTEMPTS,
147
+ maximumInterval: ms(options?.config?.maximumInterval ?? HMSH_DURABLE_MAX_INTERVAL) / 1000,
148
+ originJobId: originJobId ?? workflowId,
122
149
  parentWorkflowId,
123
- workflowTrace,
124
- workflowSpan,
150
+ workflowDimension: workflowDimension,
151
+ workflowId: childJobId,
152
+ workflowTopic,
153
+ };
154
+ //push the packaged inputs to the registry
155
+ interruptionRegistry.push({
156
+ code: HMSH_CODE_DURABLE_CHILD,
157
+ ...interruptionMessage,
125
158
  });
126
- await hotMeshClient.engine.store.exec('HSET', workflowGuid, sessionId, childJobId);
127
- return childJobId;
159
+ //ASYNC
160
+ //sleep (allow others to be packaged / registered) and throw the error
161
+ await sleepFor(0);
162
+ throw new DurableChildError(interruptionMessage);
128
163
  }
129
164
 
130
165
  /**
131
- * Wraps activities in a proxy that will durably run them
132
- * @param {ActivityConfig} options - the activity configuration
133
- * that will be used to wrap the activities. You must pass an
134
- * `activities` object to this configuration. The activities object
135
- * should be a key-value pair of activity names and their respective
136
- * functions. This is typically done by importing the activities.
166
+ * Spawns a child workflow and returns the child Job ID.
167
+ * This method guarantees the spawned child has reserved the Job ID,
168
+ * returning a 'DuplicateJobError' error if not. Otherwise,
169
+ * this is a fire-and-forget method.
170
+ *
171
+ * @param {WorkflowOptions} options - the workflow options
172
+ * @returns {Promise<string>} - the childJobId
173
+ * @example
174
+ * const childJobId = await Durable.workflow.startChild({ ...options });
175
+ */
176
+ static async startChild(options: WorkflowOptions): Promise<string> {
177
+ return this.execChild({ ...options, await: false });
178
+ }
179
+
180
+ /**
181
+ * Wraps activities in a proxy that durably runs/re-runs them to completion.
182
+ * TODO: verify that activities do not collide if named same on same server but bound to different workflows
137
183
  *
184
+ * @param {ActivityConfig} options - the activity configuration
185
+ * that will be used to wrap the activities.
138
186
  * @returns {ProxyType<ACT>} - a proxy object with the same keys as the
139
187
  * activities object, but with the values replaced by a wrapped function
188
+ *
140
189
  * @example
141
190
  * // import the activities
142
191
  * import * as activities from './activities';
@@ -149,7 +198,6 @@ export class WorkflowService {
149
198
  if (options.activities) {
150
199
  WorkerService.registerActivities(options.activities);
151
200
  }
152
-
153
201
  const proxy: any = {};
154
202
  const keys = Object.keys(WorkerService.activityRegistry);
155
203
  if (keys.length) {
@@ -161,6 +209,68 @@ export class WorkflowService {
161
209
  return proxy;
162
210
  }
163
211
 
212
+ static wrapActivity<T>(activityName: string, options?: ActivityConfig): T {
213
+ return async function () {
214
+ //SYNC
215
+ //check if the activity already ran
216
+ const [didRun, execIndex, result]: [boolean, number, ProxyResponseType<T>] = await WorkflowService.didRun('proxy');
217
+ if (didRun) {
218
+ if (result?.$error) {
219
+ if (options?.retryPolicy?.throwOnError !== false) {
220
+ //rethrow remote execution error (simulates throw)
221
+ const code: StreamCode = result.$error.code;
222
+ const message = result.$error.message;
223
+ const stack = result.$error.stack;
224
+ if (code === HMSH_CODE_DURABLE_FATAL) {
225
+ throw new DurableFatalError(message, stack);
226
+ } else if (code == HMSH_CODE_DURABLE_MAXED) {
227
+ throw new DurableMaxedError(message, stack);
228
+ } else if (code == HMSH_CODE_DURABLE_TIMEOUT) {
229
+ throw new DurableTimeoutError(message, stack);
230
+ }
231
+ }
232
+ return result.$error as T;
233
+ }
234
+ return result.data as T;
235
+ }
236
+ //package the interruption inputs
237
+ const store = asyncLocalStorage.getStore();
238
+ const interruptionRegistry = store.get('interruptionRegistry');
239
+ const workflowDimension = store.get('workflowDimension') ?? '';
240
+ const workflowId = store.get('workflowId');
241
+ const originJobId = store.get('originJobId');
242
+ const workflowTopic = store.get('workflowTopic');
243
+ const activityTopic = `${workflowTopic}-activity`;
244
+ const activityJobId = `-${workflowId}-$${activityName}${workflowDimension}-${execIndex}`;
245
+ let maximumInterval: number;
246
+ if (options.retryPolicy?.maximumInterval) {
247
+ maximumInterval = ms(options.retryPolicy.maximumInterval) / 1000;
248
+ }
249
+ const interruptionMessage = {
250
+ arguments: Array.from(arguments),
251
+ workflowDimension: workflowDimension,
252
+ index: execIndex,
253
+ originJobId: originJobId || workflowId,
254
+ parentWorkflowId: workflowId,
255
+ workflowId: activityJobId,
256
+ workflowTopic: activityTopic,
257
+ activityName,
258
+ backoffCoefficient: options?.retryPolicy?.backoffCoefficient ?? undefined,
259
+ maximumAttempts: options?.retryPolicy?.maximumAttempts ?? undefined,
260
+ maximumInterval: maximumInterval ?? undefined,
261
+ };
262
+ //push the packaged inputs to the registry
263
+ interruptionRegistry.push({
264
+ code: HMSH_CODE_DURABLE_PROXY,
265
+ ...interruptionMessage,
266
+ });
267
+ //ASYNC
268
+ //sleep (allow others to be packaged / registered) and throw the error
269
+ await sleepFor(0);
270
+ throw new DurableProxyError(interruptionMessage);
271
+ } as T;
272
+ }
273
+
164
274
  /**
165
275
  * Returns a search session for use when reading/writing to the workflow HASH.
166
276
  * The search session provides access to methods like `get`, `mget`, `set`, `del`, and `incr`.
@@ -181,42 +291,23 @@ export class WorkflowService {
181
291
  }
182
292
 
183
293
  /**
184
- * Return a handle to the hotmesh client currently running the workflow
185
- * @returns {Promise<HotMesh>} - a hotmesh client
294
+ * Returns the synchronous output from the activity (replay)
295
+ * if available locally
296
+ * @param {string} prefix - one of: proxy, child, start, wait etc
297
+ * @returns
186
298
  */
187
- static async getHotMesh(): Promise<HotMesh> {
188
- const store = asyncLocalStorage.getStore();
189
- const workflowTopic = store.get('workflowTopic');
190
- const namespace = store.get('namespace');
191
- return await WorkerService.getHotMesh(workflowTopic, { namespace });
192
- }
193
-
194
- /**
195
- * Returns the current workflow context
196
- * @returns {WorkflowContext} - the current workflow context
197
- */
198
- static getContext(): WorkflowContext {
199
- const store = asyncLocalStorage.getStore();
200
- const workflowId = store.get('workflowId');
201
- const replay = store.get('replay');
202
- const cursor = store.get('cursor');
203
- const workflowDimension = store.get('workflowDimension') ?? '';
204
- const workflowTopic = store.get('workflowTopic');
205
- const namespace = store.get('namespace');
206
- const workflowTrace = store.get('workflowTrace');
207
- const workflowSpan = store.get('workflowSpan');
208
- const COUNTER = store.get('counter');
209
- return {
210
- counter: COUNTER.counter,
211
- cursor,
212
- namespace,
299
+ static async didRun(prefix: string): Promise<[boolean, number, any]> {
300
+ const {
301
+ COUNTER,
213
302
  replay,
214
- workflowId,
215
303
  workflowDimension,
216
- workflowTopic,
217
- workflowTrace,
218
- workflowSpan,
219
- };
304
+ } = WorkflowService.getContext();
305
+ const execIndex = COUNTER.counter = COUNTER.counter + 1;
306
+ const sessionId = `-${prefix}${workflowDimension}-${execIndex}-`;
307
+ if (sessionId in replay) {
308
+ return [true, execIndex, SerializerService.fromString(replay[sessionId])];
309
+ }
310
+ return [false, execIndex, null];
220
311
  }
221
312
 
222
313
  /**
@@ -225,7 +316,7 @@ export class WorkflowService {
225
316
  * process state and job state)
226
317
  * @private
227
318
  */
228
- static async isSideEffectAllowed(hotMeshClient: HotMesh, prefix:string): Promise<boolean> {
319
+ static async isSideEffectAllowed(hotMeshClient: HotMesh, prefix: string): Promise<boolean> {
229
320
  const store = asyncLocalStorage.getStore();
230
321
  const workflowId = store.get('workflowId');
231
322
  const workflowDimension = store.get('workflowDimension') ?? '';
@@ -259,8 +350,8 @@ export class WorkflowService {
259
350
  }
260
351
 
261
352
  /**
262
- * Sends signal data into any other paused thread (which is paused and
263
- * awaiting the signal) from within a hook-thread or the main-thread
353
+ * Sends signal data into any other paused thread (which is currently
354
+ * awaiting the signal)
264
355
  * @param {string} signalId - the signal id
265
356
  * @param {Record<any, any>} data - the signal data
266
357
  * @returns {Promise<string>} - the stream id
@@ -270,8 +361,6 @@ export class WorkflowService {
270
361
  const workflowTopic = store.get('workflowTopic');
271
362
  const namespace = store.get('namespace');
272
363
  const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
273
- //todo: this particular one is better patterned as a get/set,
274
- //since the receipt is a meaningful string (the stream id)
275
364
  if (await WorkflowService.isSideEffectAllowed(hotMeshClient, 'signal')) {
276
365
  return await hotMeshClient.hook(`${namespace}.wfs.signal`, { id: signalId, data });
277
366
  }
@@ -284,59 +373,50 @@ export class WorkflowService {
284
373
  * @param {HookOptions} options - the hook options
285
374
  */
286
375
  static async hook(options: HookOptions): Promise<string> {
287
- const store = asyncLocalStorage.getStore();
288
- const workflowTopic = store.get('workflowTopic');
289
- const namespace = store.get('namespace');
376
+ const {
377
+ workflowId,
378
+ namespace,
379
+ workflowTopic,
380
+ } = WorkflowService.getContext();
290
381
  const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
291
382
  if (await WorkflowService.isSideEffectAllowed(hotMeshClient, 'hook')) {
292
- const store = asyncLocalStorage.getStore();
293
- const workflowId = options.workflowId ?? store.get('workflowId');
294
- let workflowTopic = store.get('workflowTopic');
383
+ const targetWorkflowId = options.workflowId ?? workflowId;
384
+ let targetTopic: string;
295
385
  if (options.entity || (options.taskQueue && options.workflowName)) {
296
- workflowTopic = `${options.entity ?? options.taskQueue}-${options.entity ?? options.workflowName}`;
297
- } //else this is essentially recursion as the function calls itself
386
+ targetTopic = `${options.entity ?? options.taskQueue}-${options.entity ?? options.workflowName}`;
387
+ } else {
388
+ targetTopic = workflowTopic;
389
+ }
298
390
  const payload = {
299
391
  arguments: [...options.args],
300
- id: workflowId,
392
+ id: targetWorkflowId,
301
393
  workflowTopic,
302
- backoffCoefficient: options.config?.backoffCoefficient || DEFAULT_COEFFICIENT,
394
+ backoffCoefficient: options.config?.backoffCoefficient || HMSH_DURABLE_EXP_BACKOFF,
303
395
  }
304
396
  return await hotMeshClient.hook(`${namespace}.flow.signal`, payload, StreamStatus.PENDING, 202);
305
397
  }
306
398
  }
307
399
 
308
- static getLocalState() {
309
- const store = asyncLocalStorage.getStore();
310
- return {
311
- workflowId: store.get('workflowId'),
312
- namespace: store.get('namespace'),
313
- workflowTopic: store.get('workflowTopic'),
314
- workflowDimension: store.get('workflowDimension') ?? '',
315
- counter: store.get('counter'),
316
- replay: store.get('replay'),
317
- }
318
- }
319
-
320
400
  /**
321
401
  * Executes a function once and caches the result. If the function is called
322
402
  * again, the cached result is returned. This is useful for wrapping
323
403
  * expensive activity calls that should only be run once, but which might
324
- * not require the configuration nuance/expense provided by proxyActivities.
404
+ * not require the cost and safety provided by proxyActivities.
325
405
  * @template T - the result type
326
406
  */
327
407
  static async once<T>(fn: (...args: any[]) => Promise<T>, ...args: any[]): Promise<T> {
328
408
  const {
329
- workflowId,
409
+ COUNTER,
330
410
  namespace,
411
+ workflowId,
331
412
  workflowTopic,
332
413
  workflowDimension,
333
- counter: COUNTER,
334
414
  replay,
335
- } = WorkflowService.getLocalState();
415
+ } = WorkflowService.getContext();
336
416
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
337
417
  const sessionId = `-once${workflowDimension}-${execIndex}-`;
338
418
  if (sessionId in replay) {
339
- return JSON.parse(replay[sessionId]);
419
+ return SerializerService.fromString(replay[sessionId]).data as T;
340
420
  }
341
421
  const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
342
422
  const keyParams = {
@@ -344,21 +424,20 @@ export class WorkflowService {
344
424
  jobId: workflowId
345
425
  }
346
426
  const workflowGuid = KeyService.mintKey(hotMeshClient.namespace, KeyType.JOB_STATE, keyParams);
347
- const value = await hotMeshClient.engine.store.exec('HGET', workflowGuid, sessionId) as string;
348
- if (value) {
349
- return JSON.parse(value) as T;
350
- }
427
+ const t1 = new Date();
351
428
  const response = await fn(...args);
352
- await hotMeshClient.engine.store.exec('HSET', workflowGuid, sessionId, JSON.stringify(response));
429
+ const t2 = new Date();
430
+ const payload = {
431
+ data: response,
432
+ ac: formatISODate(t1),
433
+ au: formatISODate(t2),
434
+ };
435
+ await hotMeshClient.engine.store.exec('HSET', workflowGuid, sessionId, SerializerService.toString(payload));
353
436
  return response;
354
437
  }
355
438
 
356
439
  /**
357
440
  * Interrupts a running job
358
- *
359
- * @param {string} jobId - the target job id
360
- * @param {JobInterruptOptions} options - the interrupt options
361
- * @returns {Promise<string>} - the stream id
362
441
  */
363
442
  static async interrupt(jobId: string, options: JobInterruptOptions = {}): Promise<string | void> {
364
443
  const store = asyncLocalStorage.getStore();
@@ -373,136 +452,73 @@ export class WorkflowService {
373
452
  /**
374
453
  * Sleeps the workflow for a duration. As the function is reentrant,
375
454
  * upon reentry, the function will traverse prior execution paths up
376
- * until the sleep command and then resume execution from that point.
377
- * @param {string} duration - for example: '1 minute', '2 hours', '3 days'
378
- * @returns {Promise<number>}
455
+ * until the sleep command and then resume execution thereafter.
456
+ * @param {string} duration - See the `ms` package for syntax examples: '1 minute', '2 hours', '3 days'
457
+ * @returns {Promise<number>} - resolved duration in seconds
379
458
  */
380
459
  static async sleepFor(duration: string): Promise<number> {
381
- const seconds = ms(duration) / 1000;
460
+ //SYNC
461
+ //return early if this sleep command has already run
462
+ const [didRun, execIndex, result] = await WorkflowService.didRun('sleep');
463
+ if (didRun) {
464
+ return (result as { completion: string, duration: number }).duration; //in seconds
465
+ }
466
+ //package the interruption inputs
382
467
  const store = asyncLocalStorage.getStore();
383
- const namespace = store.get('namespace');
384
- const workflowTopic = store.get('workflowTopic');
385
- const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
386
- if (await WorkflowService.isSideEffectAllowed(hotMeshClient, 'sleep')) {
387
- const workflowId = store.get('workflowId');
388
- const workflowDimension = store.get('workflowDimension') ?? '';
389
- const COUNTER = store.get('counter');
390
- const execIndex = COUNTER.counter;
391
- // spawn a new sleep job if error code 592 is thrown by the worker
392
- // NOTE: If this message appears in the stack trace, the `.sleepFor()` method in your workflow code was NOT awaited.
393
- throw new DurableSleepForError(workflowId, seconds, execIndex, workflowDimension);
468
+ const interruptionRegistry = store.get('interruptionRegistry');
469
+ const workflowId = store.get('workflowId');
470
+ const workflowDimension = store.get('workflowDimension') ?? '';
471
+ const interruptionMessage = {
472
+ workflowId,
473
+ duration: ms(duration) / 1000,
474
+ index: execIndex,
475
+ workflowDimension,
394
476
  }
395
- return seconds;
477
+ interruptionRegistry.push({
478
+ code: HMSH_CODE_DURABLE_SLEEP,
479
+ ...interruptionMessage,
480
+ });
481
+ //ASYNC
482
+ //sleep to allow other interruptions to be packaged and registered
483
+ await sleepFor(0);
484
+ // NOTE: If you are reading this in the stack trace, await `sleepFor`
485
+ throw new DurableSleepError(interruptionMessage);
396
486
  }
397
487
 
398
488
  /**
399
- * Waits for a signal to awaken
400
- * @param {string[]} signals - the signals to wait for
401
- * @param {Record<string, string>} options - the options
402
- * @returns {Promise<Record<any, any>[]>}
489
+ * Pauses the workflow until `signalId` is received.
490
+ * @template T - the result type
491
+ * @param {string} signalId - a unique, shareable guid (e.g, 'abc123')
492
+ * @returns {Promise<T>}
493
+ * @example
494
+ * const result = await Durable.workflow.waitFor<typeof resultType>('abc123');
403
495
  */
404
- static async waitForSignal(signals: string[], options?: Record<string, string>): Promise<Record<any, any>[]> {
496
+ static async waitFor<T>(signalId: string): Promise<T> {
497
+ //SYNC
498
+ //return early if this waitFor command has already run
499
+ const [didRun, execIndex, result] = await WorkflowService.didRun('wait');
500
+ if (didRun) {
501
+ return (result as { id: string, data: { data: T }}).data.data as T;
502
+ }
503
+ //package the interruption inputs
405
504
  const store = asyncLocalStorage.getStore();
406
- const COUNTER = store.get('counter');
505
+ const interruptionRegistry = store.get('interruptionRegistry');
407
506
  const workflowId = store.get('workflowId');
408
- const workflowTopic = store.get('workflowTopic');
409
507
  const workflowDimension = store.get('workflowDimension') ?? '';
410
- const namespace = store.get('namespace');
411
- const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
412
-
413
- //iterate the list of signals and check for done
414
- let allAreComplete = true;
415
- let noneAreComplete = false;
416
- const signalResults: any[] = [];
417
- for (const signal of signals) {
418
- const execIndex = COUNTER.counter = COUNTER.counter + 1;
419
- const wfsJobId = `-${workflowId}-$wfs${workflowDimension}-${execIndex}`;
420
- try {
421
- if (allAreComplete) {
422
- const state = await hotMeshClient.getState(`${hotMeshClient.appId}.wfs.execute`, wfsJobId);
423
- if (state.data?.signalData) {
424
- //user data is nested to isolate from the signal id; unpackage it
425
- const signalData = state.data.signalData as { id: string, data: Record<any, any> };
426
- signalResults.push(signalData.data);
427
- } else {
428
- allAreComplete = false;
429
- }
430
- } else {
431
- signalResults.push({ signal, index: execIndex });
432
- }
433
- } catch (err) {
434
- //todo: options.startToCloseTimeout
435
- allAreComplete = false;
436
- noneAreComplete = true;
437
- signalResults.push({ signal, index: execIndex });
438
- }
439
- };
440
-
441
- if(allAreComplete) {
442
- return signalResults;
443
- } else if(noneAreComplete) {
444
- //this error is caught by the workflow runner
445
- //it is then returned as the workflow result (594)
446
- throw new DurableWaitForSignalError(workflowId, signalResults);
447
- } else {
448
- //this error happens when a signal is received but others are still open
449
- throw new DurableIncompleteSignalError(workflowId);
508
+ const interruptionMessage = {
509
+ workflowId,
510
+ signalId,
511
+ index: execIndex,
512
+ workflowDimension,
450
513
  }
451
- }
452
-
453
- static wrapActivity<T>(activityName: string, options?: ActivityConfig): T {
454
- return async function() {
455
- const store = asyncLocalStorage.getStore();
456
- const COUNTER = store.get('counter');
457
- //increment by state (not value) to avoid race conditions
458
- const execIndex = COUNTER.counter = COUNTER.counter + 1;
459
- const workflowId = store.get('workflowId');
460
- const originJobId = store.get('originJobId');
461
- const workflowDimension = store.get('workflowDimension') ?? '';
462
- const workflowTopic = store.get('workflowTopic');
463
- const trc = store.get('workflowTrace');
464
- const spn = store.get('workflowSpan');
465
- const namespace = store.get('namespace');
466
- const activityTopic = `${workflowTopic}-activity`;
467
- const activityJobId = `-${workflowId}-$${activityName}${workflowDimension}-${execIndex}`;
468
-
469
- let activityState: JobOutput
470
- try {
471
- const hotMeshClient = await WorkerService.getHotMesh(activityTopic, { namespace });
472
- activityState = await hotMeshClient.getState(`${hotMeshClient.appId}.activity.execute`, activityJobId);
473
- if (activityState.metadata.err) {
474
- await hotMeshClient.scrub(activityJobId);
475
- throw new Error(activityState.metadata.err);
476
- } else if (activityState.metadata.js === 0 || activityState.data?.done) {
477
- return activityState.data?.response as T;
478
- }
479
- //one time subscription
480
- return await new Promise((resolve, reject) => {
481
- hotMeshClient.sub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`, async (topic, message) => {
482
- const response = message.data?.response;
483
- hotMeshClient.unsub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`);
484
- // Resolve the Promise when the callback is triggered with a message
485
- resolve(response);
486
- });
487
- });
488
- } catch (e) {
489
- //expected; thrown by `getState` when the job cannot be found
490
- const duration = ms(options?.startToCloseTimeout || '1 minute');
491
- const payload = {
492
- arguments: Array.from(arguments),
493
- //when the origin job is removed
494
- originJobId: originJobId ?? workflowId,
495
- parentWorkflowId: workflowId,
496
- workflowId: activityJobId,
497
- workflowTopic: activityTopic,
498
- activityName,
499
- };
500
- //start the job
501
- const hotMeshClient = await WorkerService.getHotMesh(activityTopic, { namespace });
502
- const context = { metadata: { trc, spn }, data: {}};
503
- const jobOutput = await hotMeshClient.pubsub(`${hotMeshClient.appId}.activity.execute`, payload, context as JobState, duration);
504
- return jobOutput.data.response as T;
505
- }
506
- } as T;
514
+ interruptionRegistry.push({
515
+ code: HMSH_CODE_DURABLE_WAIT,
516
+ ...interruptionMessage,
517
+ });
518
+ //ASYNC
519
+ //sleep to allow other interruptions to be packaged and registered
520
+ await sleepFor(0);
521
+ // NOTE: If you are reading this in the stack trace, await `waitFor`
522
+ throw new DurableWaitForError(interruptionMessage);
507
523
  }
508
524
  }