@hotmeshio/hotmesh 0.0.19 → 0.0.21

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 (58) hide show
  1. package/README.md +4 -4
  2. package/build/modules/errors.d.ts +2 -1
  3. package/build/modules/errors.js +2 -1
  4. package/build/package.json +2 -1
  5. package/build/services/activities/activity.d.ts +2 -2
  6. package/build/services/activities/activity.js +10 -8
  7. package/build/services/activities/hook.d.ts +2 -1
  8. package/build/services/activities/hook.js +12 -9
  9. package/build/services/activities/signal.d.ts +4 -0
  10. package/build/services/activities/signal.js +16 -2
  11. package/build/services/durable/client.d.ts +15 -5
  12. package/build/services/durable/client.js +37 -14
  13. package/build/services/durable/factory.d.ts +2 -16
  14. package/build/services/durable/factory.js +276 -46
  15. package/build/services/durable/handle.d.ts +1 -1
  16. package/build/services/durable/handle.js +18 -5
  17. package/build/services/durable/search.d.ts +8 -1
  18. package/build/services/durable/search.js +36 -10
  19. package/build/services/durable/worker.d.ts +7 -9
  20. package/build/services/durable/worker.js +29 -23
  21. package/build/services/durable/workflow.d.ts +23 -2
  22. package/build/services/durable/workflow.js +143 -37
  23. package/build/services/engine/index.d.ts +2 -2
  24. package/build/services/engine/index.js +7 -12
  25. package/build/services/hotmesh/index.d.ts +2 -2
  26. package/build/services/hotmesh/index.js +2 -2
  27. package/build/services/signaler/store.d.ts +2 -2
  28. package/build/services/signaler/store.js +17 -7
  29. package/build/services/signaler/stream.js +1 -0
  30. package/build/services/store/clients/redis.js +1 -1
  31. package/build/services/store/index.js +3 -0
  32. package/build/services/telemetry/index.js +7 -1
  33. package/build/types/activity.d.ts +5 -3
  34. package/build/types/durable.d.ts +13 -2
  35. package/build/types/hook.d.ts +0 -1
  36. package/build/types/index.d.ts +1 -1
  37. package/modules/errors.ts +4 -2
  38. package/package.json +2 -1
  39. package/services/activities/activity.ts +10 -8
  40. package/services/activities/hook.ts +13 -10
  41. package/services/activities/signal.ts +17 -3
  42. package/services/durable/client.ts +40 -15
  43. package/services/durable/factory.ts +274 -46
  44. package/services/durable/handle.ts +18 -5
  45. package/services/durable/search.ts +38 -10
  46. package/services/durable/worker.ts +30 -24
  47. package/services/durable/workflow.ts +158 -40
  48. package/services/engine/index.ts +8 -12
  49. package/services/hotmesh/index.ts +3 -3
  50. package/services/signaler/store.ts +18 -8
  51. package/services/signaler/stream.ts +1 -0
  52. package/services/store/clients/redis.ts +1 -1
  53. package/services/store/index.ts +2 -0
  54. package/services/telemetry/index.ts +6 -1
  55. package/types/activity.ts +10 -8
  56. package/types/durable.ts +14 -1
  57. package/types/hook.ts +0 -1
  58. package/types/index.ts +1 -0
@@ -6,6 +6,7 @@ import {
6
6
  DurableSleepError,
7
7
  DurableTimeoutError,
8
8
  DurableWaitForSignalError} from '../../modules/errors';
9
+ import { KeyService, KeyType } from '../../modules/key';
9
10
  import { asyncLocalStorage } from './asyncLocalStorage';
10
11
  import { APP_ID, APP_VERSION, getWorkflowYAML } from './factory';
11
12
  import { HotMeshService as HotMesh } from '../hotmesh';
@@ -16,13 +17,12 @@ import {
16
17
  WorkerConfig,
17
18
  WorkerOptions,
18
19
  WorkflowDataType,
19
- WorkflowSearchOptions} from "../../types/durable";
20
+ WorkflowSearchOptions} from '../../types/durable';
20
21
  import { RedisClass, RedisOptions } from '../../types/redis';
21
22
  import {
22
23
  StreamData,
23
24
  StreamDataResponse,
24
25
  StreamStatus } from '../../types/stream';
25
- import { KeyService, KeyType } from '../../modules/key';
26
26
 
27
27
  export class WorkerService {
28
28
  static activityRegistry: Registry = {}; //user's activities
@@ -31,25 +31,26 @@ export class WorkerService {
31
31
  workflowRunner: HotMesh;
32
32
  activityRunner: HotMesh;
33
33
 
34
- static getHotMesh = async (worflowTopic: string, options?: WorkerOptions) => {
35
- if (WorkerService.instances.has(worflowTopic)) {
36
- return await WorkerService.instances.get(worflowTopic);
34
+ static getHotMesh = async (workflowTopic: string, config?: Partial<WorkerConfig>, options?: WorkerOptions) => {
35
+ if (WorkerService.instances.has(workflowTopic)) {
36
+ return await WorkerService.instances.get(workflowTopic);
37
37
  }
38
38
  const hotMeshClient = HotMesh.init({
39
- appId: APP_ID,
39
+ logLevel: options?.logLevel as 'debug' ?? 'info',
40
+ appId: config.namespace ?? APP_ID,
40
41
  engine: { redis: { ...WorkerService.connection } }
41
42
  });
42
- WorkerService.instances.set(worflowTopic, hotMeshClient);
43
+ WorkerService.instances.set(workflowTopic, hotMeshClient);
43
44
  await WorkerService.activateWorkflow(await hotMeshClient);
44
45
  return hotMeshClient;
45
46
  }
46
47
 
47
48
  static async activateWorkflow(hotMesh: HotMesh) {
48
- const app = await hotMesh.engine.store.getApp(APP_ID);
49
+ const app = await hotMesh.engine.store.getApp(hotMesh.engine.appId);
49
50
  const appVersion = app?.version;
50
51
  if(!appVersion) {
51
52
  try {
52
- await hotMesh.deploy(getWorkflowYAML(APP_ID, APP_VERSION));
53
+ await hotMesh.deploy(getWorkflowYAML(hotMesh.engine.appId, APP_VERSION));
53
54
  await hotMesh.activate(APP_VERSION);
54
55
  } catch (err) {
55
56
  hotMesh.engine.logger.error('durable-worker-deploy-activate-err', err);
@@ -65,11 +66,6 @@ export class WorkerService {
65
66
  }
66
67
  }
67
68
 
68
- /**
69
- * NOTE: Because the worker imports the workflows dynamically AFTER
70
- * the activities are loaded, there will be items in the registry,
71
- * allowing proxyActivities to succeed.
72
- */
73
69
  static registerActivities<ACT>(activities: ACT): Registry {
74
70
  if (typeof activities === 'function' && typeof WorkerService.activityRegistry[activities.name] !== 'function') {
75
71
  WorkerService.activityRegistry[activities.name] = activities as Function;
@@ -85,10 +81,11 @@ export class WorkerService {
85
81
 
86
82
  /**
87
83
  * For those deployments with a redis stack backend (with the FT module),
88
- * this method will configure the search index for the workflow.
84
+ * this method will configure the search index for the workflow. For all
85
+ * others, this method will fail gracefully. In all cases, the values
86
+ * will be stored in the workflow's central HASH data structure, allowing
87
+ * for manual traversal and inspection as well.
89
88
  */
90
- //todo: bind this to the Search service; update constructor to expect hotMeshClient as first param (id is optional
91
- //refactor and delete other one as well)
92
89
  static async configureSearchIndex(hotMeshClient: HotMesh, search?: WorkflowSearchOptions): Promise<void> {
93
90
  if (search?.schema) {
94
91
  const store = hotMeshClient.engine.store;
@@ -116,7 +113,6 @@ export class WorkerService {
116
113
  }
117
114
 
118
115
  static async create(config: WorkerConfig) {
119
- //always call `registerActivities` before `import`
120
116
  WorkerService.connection = config.connection;
121
117
  const workflow = config.workflow;
122
118
  const [workflowFunctionName, workflowFunction] = WorkerService.resolveWorkflowTarget(workflow);
@@ -155,7 +151,8 @@ export class WorkerService {
155
151
  options: config.connection.options as RedisOptions
156
152
  };
157
153
  const hotMeshWorker = await HotMesh.init({
158
- appId: APP_ID,
154
+ logLevel: config.options?.logLevel as 'debug' ?? 'info',
155
+ appId: config.namespace ?? APP_ID,
159
156
  engine: { redis: redisConfig },
160
157
  workers: [
161
158
  { topic: activityTopic,
@@ -205,12 +202,13 @@ export class WorkerService {
205
202
  options: config.connection.options as RedisOptions
206
203
  };
207
204
  const hotMeshWorker = await HotMesh.init({
208
- appId: APP_ID,
205
+ logLevel: config.options?.logLevel as 'debug' ?? 'info',
206
+ appId: config.namespace ?? APP_ID,
209
207
  engine: { redis: redisConfig },
210
208
  workers: [{
211
209
  topic: workflowTopic,
212
210
  redis: redisConfig,
213
- callback: this.wrapWorkflowFunction(workflowFunction, workflowTopic).bind(this)
211
+ callback: this.wrapWorkflowFunction(workflowFunction, workflowTopic, config).bind(this)
214
212
  }]
215
213
  });
216
214
  WorkerService.instances.set(workflowTopic, hotMeshWorker);
@@ -226,15 +224,22 @@ export class WorkerService {
226
224
  },
227
225
  };
228
226
 
229
- wrapWorkflowFunction(workflowFunction: Function, workflowTopic: string): Function {
227
+ wrapWorkflowFunction(workflowFunction: Function, workflowTopic: string, config: WorkerConfig): Function {
230
228
  return async (data: StreamData): Promise<StreamDataResponse> => {
231
229
  const counter = { counter: 0 };
232
230
  try {
233
231
  //incoming data payload has arguments and workflowId
234
232
  const workflowInput = data.data as unknown as WorkflowDataType;
235
233
  const context = new Map();
234
+ context.set('namespace', config.namespace ?? APP_ID);
236
235
  context.set('counter', counter);
237
236
  context.set('workflowId', workflowInput.workflowId);
237
+ if (data.data.workflowDimension) {
238
+ //every hook function runs in an isolated dimension controlled
239
+ //by the index assigned when the signal was received; even if the
240
+ //hook function re-runs, its scope will always remain constant
241
+ context.set('workflowDimension', data.data.workflowDimension);
242
+ }
238
243
  context.set('workflowTopic', workflowTopic);
239
244
  context.set('workflowName', workflowTopic.split('-').pop());
240
245
  context.set('workflowTrace', data.metadata.trc);
@@ -259,9 +264,10 @@ export class WorkerService {
259
264
  metadata: { ...data.metadata },
260
265
  data: {
261
266
  code: err.code,
262
- message: JSON.stringify({ duration: err.duration, index: err.index }),
267
+ message: JSON.stringify({ duration: err.duration, index: err.index, dimension: err.dimension }),
263
268
  duration: err.duration,
264
- index: err.index
269
+ index: err.index,
270
+ dimension: err.dimension
265
271
  }
266
272
  } as StreamDataResponse;
267
273
 
@@ -1,31 +1,43 @@
1
1
  import ms from 'ms';
2
2
 
3
+ import {
4
+ DurableIncompleteSignalError,
5
+ DurableSleepError,
6
+ DurableWaitForSignalError } from '../../modules/errors';
7
+ import { KeyService, KeyType } from '../../modules/key';
3
8
  import { asyncLocalStorage } from './asyncLocalStorage';
4
- import { WorkerService } from './worker';
5
9
  import { ClientService as Client } from './client';
6
10
  import { ConnectionService as Connection } from './connection';
7
- import { ActivityConfig, ProxyType, WorkflowConfig, WorkflowOptions, WorkflowSearchOptions } from "../../types/durable";
8
- import { JobOutput, JobState } from '../../types';
9
- import { ACTIVITY_PUBLISHES_TOPIC, ACTIVITY_SUBSCRIBES_TOPIC, SLEEP_SUBSCRIBES_TOPIC, WFS_SUBSCRIBES_TOPIC } from './factory';
10
- import { DurableIncompleteSignalError, DurableSleepError, DurableWaitForSignalError } from '../../modules/errors';
11
+ import { DEFAULT_COEFFICIENT } from './factory';
11
12
  import { Search } from './search';
13
+ import { WorkerService } from './worker';
14
+ import { HotMeshService as HotMesh } from '../hotmesh';
15
+ import {
16
+ ActivityConfig,
17
+ HookOptions,
18
+ ProxyType,
19
+ WorkflowOptions } from "../../types/durable";
20
+ import { JobOutput, JobState } from '../../types/job';
21
+ import { StreamStatus } from '../../types/stream';
12
22
 
13
23
  export class WorkflowService {
14
24
 
15
25
  /**
16
- * Spawn a child workflow. await the result.
26
+ * Spawn a child workflow. await and return the result.
17
27
  */
18
28
  static async executeChild<T>(options: WorkflowOptions): Promise<T> {
19
29
  const store = asyncLocalStorage.getStore();
20
- if (!store) {
21
- throw new Error('durable-store-not-found');
22
- }
30
+ const namespace = store.get('namespace');
23
31
  const workflowId = store.get('workflowId');
32
+ const workflowDimension = store.get('workflowDimension') ?? '';
24
33
  const workflowTrace = store.get('workflowTrace');
25
34
  const workflowSpan = store.get('workflowSpan');
26
35
  const COUNTER = store.get('counter');
27
36
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
28
- const childJobId = `${workflowId}-$${options.workflowName}-${execIndex}`;
37
+ //this is risky but MUST be allowed. Users MAY set the workflowId,
38
+ //but if there is a naming collision, the data from the target entity will be used
39
+ //as there is know way of knowing if the item was generated via a prior run of the workflow
40
+ const childJobId = options.workflowId ?? `${workflowId}-$${options.workflowName}${workflowDimension}-${execIndex}`;
29
41
  const parentWorkflowId = `${workflowId}-f`;
30
42
 
31
43
  const client = new Client({
@@ -35,24 +47,65 @@ export class WorkflowService {
35
47
  let handle = await client.workflow.getHandle(
36
48
  options.taskQueue,
37
49
  options.workflowName,
38
- childJobId
50
+ childJobId,
51
+ namespace,
39
52
  );
40
53
 
41
54
  try {
42
- return await handle.result() as T;
55
+ return await handle.result(true) as T;
43
56
  } catch (error) {
44
57
  handle = await client.workflow.start({
45
58
  ...options,
59
+ namespace,
46
60
  workflowId: childJobId,
47
61
  parentWorkflowId,
48
62
  workflowTrace,
49
63
  workflowSpan,
50
64
  });
65
+ //todo: options.startToCloseTimeout
51
66
  const result = await handle.result();
52
67
  return result as T;
53
68
  }
54
69
  }
55
70
 
71
+ /**
72
+ * spawn a child workflow. return the childJobId.
73
+ */
74
+ static async startChild<T>(options: WorkflowOptions): Promise<string> {
75
+ const store = asyncLocalStorage.getStore();
76
+ const namespace = store.get('namespace');
77
+ const workflowId = store.get('workflowId');
78
+ const workflowDimension = store.get('workflowDimension') ?? '';
79
+ const workflowTrace = store.get('workflowTrace');
80
+ const workflowSpan = store.get('workflowSpan');
81
+ const COUNTER = store.get('counter');
82
+ const execIndex = COUNTER.counter = COUNTER.counter + 1;
83
+ const childJobId = options.workflowId ?? `${workflowId}-$${options.workflowName}${workflowDimension}-${execIndex}`;
84
+ const parentWorkflowId = `${workflowId}-f`;
85
+ const workflowTopic = `${options.taskQueue}-${options.workflowName}`;
86
+
87
+ try {
88
+ //get the status; if there is no error, return childJobId (what was spawned)
89
+ const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
90
+ await hotMeshClient.getStatus(childJobId);
91
+ return childJobId;
92
+ } catch (error) {
93
+ const client = new Client({
94
+ connection: await Connection.connect(WorkerService.connection),
95
+ });
96
+
97
+ await client.workflow.start({
98
+ ...options,
99
+ namespace,
100
+ workflowId: childJobId,
101
+ parentWorkflowId,
102
+ workflowTrace,
103
+ workflowSpan,
104
+ });
105
+ return childJobId;
106
+ }
107
+ }
108
+
56
109
  static proxyActivities<ACT>(options?: ActivityConfig): ProxyType<ACT> {
57
110
  if (options.activities) {
58
111
  WorkerService.registerActivities(options.activities)
@@ -71,51 +124,117 @@ export class WorkflowService {
71
124
 
72
125
  static async search(): Promise<Search> {
73
126
  const store = asyncLocalStorage.getStore();
74
- if (!store) {
75
- throw new Error('durable-store-not-found');
76
- }
77
127
  const workflowId = store.get('workflowId');
128
+ const workflowDimension = store.get('workflowDimension') ?? '';
78
129
  const workflowTopic = store.get('workflowTopic');
130
+ const namespace = store.get('namespace');
131
+ const COUNTER = store.get('counter');
132
+ const execIndex = COUNTER.counter = COUNTER.counter + 1;
133
+ //this ID is used as a item key with a hash (dash prefix ensures no collision)
134
+ const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
135
+ const searchSessionId = `-search${workflowDimension}-${execIndex}`;
136
+ return new Search(workflowId, hotMeshClient, searchSessionId);
137
+ }
79
138
 
80
- const hotMeshClient = await WorkerService.getHotMesh(workflowTopic);
81
- return new Search(workflowId, hotMeshClient);
139
+ /**
140
+ * those methods that may only be called once must be protected by flagging
141
+ * their execution with a unique key (the key is stored in the workflow state)
142
+ */
143
+ static async isSideEffectAllowed(hotMeshClient: HotMesh, prefix:string): Promise<boolean> {
144
+ const store = asyncLocalStorage.getStore();
145
+ const workflowId = store.get('workflowId');
146
+ const workflowDimension = store.get('workflowDimension') ?? '';
147
+ const COUNTER = store.get('counter');
148
+ const execIndex = COUNTER.counter = COUNTER.counter + 1;
149
+ //this ID is used as a item key with a hash (dash prefix ensures no collision)
150
+ const sessionId = `-${prefix}${workflowDimension}-${execIndex}-`;
151
+ //this ID is used as a item key with a hash (dash prefix ensures no collision)
152
+ const keyParams = {
153
+ appId: hotMeshClient.appId,
154
+ jobId: ''
155
+ }
156
+ const hotMeshPrefix = KeyService.mintKey(hotMeshClient.namespace, KeyType.JOB_STATE, keyParams);
157
+ const workflowGuid = `${hotMeshPrefix}${workflowId}`;
158
+ const guidValue = Number(await hotMeshClient.engine.store.exec('HINCRBYFLOAT', workflowGuid, sessionId, '1') as string);
159
+ return guidValue === 1;
160
+ }
161
+
162
+ /**
163
+ * send signal data into any other paused thread (which is paused and
164
+ * awaiting the signal) from within a hook-thread or the main-thread
165
+ */
166
+ static async signal(signalId: string, data: Record<any, any>): Promise<string> {
167
+ const store = asyncLocalStorage.getStore();
168
+ const namespace = store.get('namespace');
169
+ const hotMeshClient = await WorkerService.getHotMesh(`${namespace}.wfs.signal`, { namespace });
170
+ if (await WorkflowService.isSideEffectAllowed(hotMeshClient, 'signal')) {
171
+ return await hotMeshClient.hook(`${namespace}.wfs.signal`, { id: signalId, data });
172
+ }
173
+ }
174
+
175
+ /**
176
+ * spawn a hook from either the main thread or a hook thread with
177
+ * the provided options; worflowId/TaskQueue/Name are optional and will
178
+ * default to the current workflowId/WorkflowTopic if not provided
179
+ */
180
+ static async hook(options: HookOptions): Promise<string> {
181
+ const store = asyncLocalStorage.getStore();
182
+ const namespace = store.get('namespace');
183
+ const hotMeshClient = await WorkerService.getHotMesh(`${namespace}.flow.signal`, { namespace });
184
+ if (await WorkflowService.isSideEffectAllowed(hotMeshClient, 'hook')) {
185
+ const store = asyncLocalStorage.getStore();
186
+ let workflowId: string;
187
+ let workflowTopic: string;
188
+ if (options.workflowId && options.taskQueue && options.workflowName) {
189
+ workflowId = options.workflowId;
190
+ workflowTopic = `${options.taskQueue}-${options.workflowName}`;
191
+ } else {
192
+ workflowId = store.get('workflowId');
193
+ workflowTopic = store.get('workflowTopic');
194
+ }
195
+ const payload = {
196
+ arguments: [...options.args],
197
+ id: workflowId,
198
+ workflowTopic,
199
+ backoffCoefficient: options.config?.backoffCoefficient || DEFAULT_COEFFICIENT,
200
+ }
201
+ return await hotMeshClient.hook(`${namespace}.flow.signal`, payload, StreamStatus.PENDING, 202);
202
+ }
82
203
  }
83
204
 
84
205
  static async sleep(duration: string): Promise<number> {
85
206
  const seconds = ms(duration) / 1000;
86
207
 
87
208
  const store = asyncLocalStorage.getStore();
88
- if (!store) {
89
- throw new Error('durable-store-not-found');
90
- }
91
209
  const COUNTER = store.get('counter');
92
210
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
93
211
  const workflowId = store.get('workflowId');
94
212
  const workflowTopic = store.get('workflowTopic');
95
- const sleepJobId = `${workflowId}-$sleep-${execIndex}`;
213
+ const workflowDimension = store.get('workflowDimension') ?? '';
214
+ const namespace = store.get('namespace');
215
+ const sleepJobId = `${workflowId}-$sleep${workflowDimension}-${execIndex}`;
96
216
 
97
217
  try {
98
- const hotMeshClient = await WorkerService.getHotMesh(workflowTopic);
99
- await hotMeshClient.getState(SLEEP_SUBSCRIBES_TOPIC, sleepJobId);
218
+ const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
219
+ await hotMeshClient.getState(`${hotMeshClient.appId}.sleep.execute`, sleepJobId);
100
220
  //if no error is thrown, we've already slept, return the delay
101
221
  return seconds;
102
222
  } catch (e) {
103
223
  //if an error, the sleep job was not found...rethrow error; sleep job
104
224
  // will be automatically created according to the DAG rules (they
105
225
  // spawn a new sleep job if error code 595 is thrown by the worker)
106
- throw new DurableSleepError(workflowId, seconds, execIndex);
226
+ throw new DurableSleepError(workflowId, seconds, execIndex, workflowDimension);
107
227
  }
108
228
  }
109
229
 
110
230
  static async waitForSignal(signals: string[], options?: Record<string, string>): Promise<Record<any, any>[]> {
111
231
  const store = asyncLocalStorage.getStore();
112
- if (!store) {
113
- throw new Error('durable-store-not-found');
114
- }
115
232
  const COUNTER = store.get('counter');
116
233
  const workflowId = store.get('workflowId');
117
234
  const workflowTopic = store.get('workflowTopic');
118
- const hotMeshClient = await WorkerService.getHotMesh(workflowTopic);
235
+ const workflowDimension = store.get('workflowDimension') ?? '';
236
+ const namespace = store.get('namespace');
237
+ const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
119
238
 
120
239
  //iterate the list of signals and check for done
121
240
  let allAreComplete = true;
@@ -123,10 +242,10 @@ export class WorkflowService {
123
242
  const signalResults: any[] = [];
124
243
  for (const signal of signals) {
125
244
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
126
- const wfsJobId = `${workflowId}-$wfs-${execIndex}`;
245
+ const wfsJobId = `${workflowId}-$wfs${workflowDimension}-${execIndex}`;
127
246
  try {
128
247
  if (allAreComplete) {
129
- const state = await hotMeshClient.getState(WFS_SUBSCRIBES_TOPIC, wfsJobId);
248
+ const state = await hotMeshClient.getState(`${hotMeshClient.appId}.wfs.execute`, wfsJobId);
130
249
  if (state.data?.signalData) {
131
250
  //user data is nested to isolate from the signal id; unpackage it
132
251
  const signalData = state.data.signalData as { id: string, data: Record<any, any> };
@@ -160,23 +279,22 @@ export class WorkflowService {
160
279
  static wrapActivity<T>(activityName: string, options?: ActivityConfig): T {
161
280
  return async function() {
162
281
  const store = asyncLocalStorage.getStore();
163
- if (!store) {
164
- throw new Error('durable-store-not-found');
165
- }
166
282
  const COUNTER = store.get('counter');
167
283
  //increment by state (not value) to avoid race conditions
168
284
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
169
285
  const workflowId = store.get('workflowId');
286
+ const workflowDimension = store.get('workflowDimension') ?? '';
170
287
  const workflowTopic = store.get('workflowTopic');
171
288
  const trc = store.get('workflowTrace');
172
289
  const spn = store.get('workflowSpan');
290
+ const namespace = store.get('namespace');
173
291
  const activityTopic = `${workflowTopic}-activity`;
174
- const activityJobId = `${workflowId}-$${activityName}-${execIndex}`;
292
+ const activityJobId = `${workflowId}-$${activityName}${workflowDimension}-${execIndex}`;
175
293
 
176
294
  let activityState: JobOutput
177
295
  try {
178
- const hotMeshClient = await WorkerService.getHotMesh(activityTopic);
179
- activityState = await hotMeshClient.getState(ACTIVITY_SUBSCRIBES_TOPIC, activityJobId);
296
+ const hotMeshClient = await WorkerService.getHotMesh(activityTopic, { namespace });
297
+ activityState = await hotMeshClient.getState(`${hotMeshClient.appId}.activity.execute`, activityJobId);
180
298
  if (activityState.metadata.err) {
181
299
  await hotMeshClient.scrub(activityJobId);
182
300
  throw new Error(activityState.metadata.err);
@@ -185,9 +303,9 @@ export class WorkflowService {
185
303
  }
186
304
  //one time subscription
187
305
  return await new Promise((resolve, reject) => {
188
- hotMeshClient.sub(`${ACTIVITY_PUBLISHES_TOPIC}.${activityJobId}`, async (topic, message) => {
306
+ hotMeshClient.sub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`, async (topic, message) => {
189
307
  const response = message.data?.response;
190
- hotMeshClient.unsub(`${ACTIVITY_PUBLISHES_TOPIC}.${activityJobId}`);
308
+ hotMeshClient.unsub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`);
191
309
  // Resolve the Promise when the callback is triggered with a message
192
310
  resolve(response);
193
311
  });
@@ -204,9 +322,9 @@ export class WorkflowService {
204
322
  activityName,
205
323
  };
206
324
  //start the job
207
- const hotMeshClient = await WorkerService.getHotMesh(activityTopic);
325
+ const hotMeshClient = await WorkerService.getHotMesh(activityTopic, { namespace });
208
326
  const context = { metadata: { trc, spn }, data: {}};
209
- const jobOutput = await hotMeshClient.pubsub(ACTIVITY_SUBSCRIBES_TOPIC, payload, context as JobState, duration);
327
+ const jobOutput = await hotMeshClient.pubsub(`${hotMeshClient.appId}.activity.execute`, payload, context as JobState, duration);
210
328
  return jobOutput.data.response as T;
211
329
  }
212
330
  } as T;
@@ -60,6 +60,7 @@ import {
60
60
  StatsResponse
61
61
  } from '../../types/stats';
62
62
  import {
63
+ StreamCode,
63
64
  StreamData,
64
65
  StreamDataResponse,
65
66
  StreamDataType,
@@ -344,7 +345,8 @@ class EngineService {
344
345
  } else if (streamData.type === StreamDataType.TRANSITION) {
345
346
  await activityHandler.process();
346
347
  } else {
347
- await activityHandler.processWebHookEvent();
348
+ //a 202 code keeps the hook alive (hooks are single-use by default)
349
+ await activityHandler.processWebHookEvent(streamData.status, streamData.code);
348
350
  }
349
351
  } else if (streamData.type === StreamDataType.AWAIT) {
350
352
  context.metadata = {
@@ -362,7 +364,7 @@ class EngineService {
362
364
  await activityHandler.processEvent(streamData.status, streamData.code);
363
365
  } else {
364
366
  const activityHandler = await this.initActivity(`.${streamData.metadata.aid}`, streamData.data, context as JobState) as Worker;
365
- await activityHandler.processEvent(streamData.status, streamData.code);
367
+ await activityHandler.processEvent(streamData.status, streamData.code, 'output');
366
368
  }
367
369
  this.logger.debug('engine-process-stream-message-end', {
368
370
  jid: streamData.metadata.jid,
@@ -417,21 +419,15 @@ class EngineService {
417
419
  }
418
420
 
419
421
  // ****************** `HOOK` ACTIVITY RE-ENTRY POINT *****************
420
- async hook(topic: string, data: JobData, dad?: string): Promise<string> {
422
+ async hook(topic: string, data: JobData, status: StreamStatus = StreamStatus.SUCCESS, code: StreamCode = 200): Promise<string> {
421
423
  const hookRule = await this.storeSignaler.getHookRule(topic);
422
- const [aid, schema] = await this.getSchema(`.${hookRule.to}`);
423
- if (!dad) {
424
- //assume dimensional address is singular (0)
425
- // for ancestors and self if not provided
426
- // todo: register
427
- dad = ',0'.repeat(schema.ancestors.length + 1);
428
- }
424
+ const [aid] = await this.getSchema(`.${hookRule.to}`);
429
425
  const streamData: StreamData = {
430
426
  type: StreamDataType.WEBHOOK,
427
+ status,
428
+ code,
431
429
  metadata: {
432
- //jid is unknown at this point; will be resolved using the data
433
430
  aid,
434
- dad,
435
431
  topic
436
432
  },
437
433
  data,
@@ -20,7 +20,7 @@ import {
20
20
  IdsResponse,
21
21
  StatsResponse } from '../../types/stats';
22
22
  import { ConnectorService } from '../connector';
23
- import { StreamData, StreamDataResponse } from '../../types/stream';
23
+ import { StreamCode, StreamData, StreamDataResponse, StreamStatus } from '../../types/stream';
24
24
 
25
25
  class HotMeshService {
26
26
  namespace: string;
@@ -165,8 +165,8 @@ class HotMeshService {
165
165
  }
166
166
 
167
167
  // ****** `HOOK` ACTIVITY RE-ENTRY POINT ******
168
- async hook(topic: string, data: JobData, dad?: string): Promise<string> {
169
- return await this.engine?.hook(topic, data, dad);
168
+ async hook(topic: string, data: JobData, status?: StreamStatus, code?: StreamCode): Promise<string> {
169
+ return await this.engine?.hook(topic, data, status, code);
170
170
  }
171
171
  async hookAll(hookTopic: string, data: JobData, query: JobStatsInput, queryFacets: string[] = []): Promise<string[]> {
172
172
  return await this.engine?.hookAll(hookTopic, data, query, queryFacets);
@@ -19,7 +19,7 @@ class StoreSignaler {
19
19
  return rules?.[topic]?.[0] as HookRule;
20
20
  }
21
21
 
22
- async registerWebHook(topic: string, context: JobState, multi?: RedisMulti): Promise<string> {
22
+ async registerWebHook(topic: string, context: JobState, dad: string, multi?: RedisMulti): Promise<string> {
23
23
  const hookRule = await this.getHookRule(topic);
24
24
  if (hookRule) {
25
25
  const mapExpression = hookRule.conditions.match[0].expected;
@@ -28,7 +28,8 @@ class StoreSignaler {
28
28
  const hook: HookSignal = {
29
29
  topic,
30
30
  resolved,
31
- jobId,
31
+ //hookSignalId is composed of `<dad>::<jid>`
32
+ jobId: `${dad}::${jobId}`,
32
33
  }
33
34
  await this.store.setHookSignal(hook, multi);
34
35
  return jobId;
@@ -37,25 +38,34 @@ class StoreSignaler {
37
38
  }
38
39
  }
39
40
 
40
- async processWebHookSignal(topic: string, data: Record<string, unknown>): Promise<string> {
41
+ async processWebHookSignal(topic: string, data: Record<string, unknown>): Promise<[string, string, string] | undefined> {
41
42
  const hookRule = await this.getHookRule(topic);
42
43
  if (hookRule) {
43
44
  //NOTE: both formats are supported: $self.hook.data OR $hook.data
44
45
  const context = { $self: { hook: { data }}, $hook: { data }};
45
46
  const mapExpression = hookRule.conditions.match[0].actual;
46
47
  const resolved = Pipe.resolve(mapExpression, context);
47
- const jobId = await this.store.getHookSignal(topic, resolved);
48
- return jobId;
48
+ const hookSignalId = await this.store.getHookSignal(topic, resolved);
49
+ if (!hookSignalId) {
50
+ //messages can be double-processed; not an issue; return undefined
51
+ //users can also provide a bogus topic; not an issue; return undefined
52
+ return undefined;
53
+ }
54
+ const [dad, jid] = hookSignalId.split('::');
55
+ //return [jid, aid, dad]
56
+ return [jid, hookRule.to, dad];
49
57
  } else {
50
- throw new Error('signaler.process:error: hook rule not found');
58
+ throw new Error('signal-not-found');
51
59
  }
52
60
  }
53
61
 
54
62
  async deleteWebHookSignal(topic: string, data: Record<string, unknown>): Promise<number> {
55
63
  const hookRule = await this.getHookRule(topic);
56
64
  if (hookRule) {
57
- //todo: use the rule to generate `resolved`
58
- const resolved = (data as { id: string}).id;
65
+ //NOTE: both formats are supported: $self.hook.data OR $hook.data
66
+ const context = { $self: { hook: { data }}, $hook: { data }};
67
+ const mapExpression = hookRule.conditions.match[0].actual;
68
+ const resolved = Pipe.resolve(mapExpression, context);
59
69
  return await this.store.deleteHookSignal(topic, resolved);
60
70
  } else {
61
71
  throw new Error('signaler.process:error: hook rule not found');
@@ -150,6 +150,7 @@ class StreamSignaler {
150
150
  try {
151
151
  output = await callback(input);
152
152
  } catch (error) {
153
+ console.error(error);
153
154
  this.logger.error(`stream-call-function-error`, { error });
154
155
  output = this.structureUnhandledError(input, error);
155
156
  }
@@ -86,7 +86,7 @@ class RedisStoreService extends StoreService<RedisClientType, RedisMultiType> {
86
86
  return (await this.redisClient.sendCommand(['XGROUP', 'CREATE', key, groupName, id, ...args])) === 1;
87
87
  } catch (error) {
88
88
  const streamType = mkStream === 'MKSTREAM' ? 'with MKSTREAM' : 'without MKSTREAM';
89
- this.logger.warn(`x-group-error ${streamType} for key: ${key} and group: ${groupName}`, { error });
89
+ this.logger.info(`x-group-error ${streamType} for key: ${key} and group: ${groupName}`, { error });
90
90
  throw error;
91
91
  }
92
92
  }
@@ -504,6 +504,8 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
504
504
  delete state[':'];
505
505
  }
506
506
  return [state, status];
507
+ } else {
508
+ throw new Error(`Job ${jobId} not found`);
507
509
  }
508
510
  }
509
511
 
@@ -252,10 +252,15 @@ class TelemetryService {
252
252
 
253
253
  static bindActivityTelemetryToState(state: StringAnyType, config: ActivityType, metadata: ActivityMetadata, context: JobState, leg: number): void {
254
254
  if (config.type === 'trigger') {
255
+ //trigger activities run non-duplexed and only have a single leg (2)
255
256
  state[`${metadata.aid}/output/metadata/l1s`] = context['$self'].output.metadata.l1s;
256
257
  state[`${metadata.aid}/output/metadata/l2s`] = context['$self'].output.metadata.l2s;
257
258
  } else if (polyfill.resolveActivityType(config.type) === 'hook' && leg === 1) {
258
- //activities run non-duplexed and only have a single leg
259
+ //hook activities run non-duplexed and only have a single leg (1)
260
+ state[`${metadata.aid}/output/metadata/l1s`] = context['$self'].output.metadata.l1s;
261
+ state[`${metadata.aid}/output/metadata/l2s`] = context['$self'].output.metadata.l1s;
262
+ } else if (config.type === 'signal' && leg === 1) {
263
+ //signal activities run non-duplexed and only have a single leg (1)
259
264
  state[`${metadata.aid}/output/metadata/l1s`] = context['$self'].output.metadata.l1s;
260
265
  state[`${metadata.aid}/output/metadata/l2s`] = context['$self'].output.metadata.l1s;
261
266
  } else {