@hotmeshio/hotmesh 0.0.52 → 0.0.54

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