@hotmeshio/hotmesh 0.0.18 → 0.0.20

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 (60) 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/modules/utils.js +7 -0
  5. package/build/package.json +2 -1
  6. package/build/services/activities/activity.d.ts +2 -2
  7. package/build/services/activities/activity.js +10 -8
  8. package/build/services/activities/hook.d.ts +4 -3
  9. package/build/services/activities/hook.js +15 -12
  10. package/build/services/activities/signal.d.ts +4 -0
  11. package/build/services/activities/signal.js +16 -2
  12. package/build/services/durable/client.d.ts +15 -5
  13. package/build/services/durable/client.js +45 -54
  14. package/build/services/durable/factory.d.ts +2 -16
  15. package/build/services/durable/factory.js +276 -46
  16. package/build/services/durable/handle.d.ts +1 -1
  17. package/build/services/durable/handle.js +18 -5
  18. package/build/services/durable/search.d.ts +8 -1
  19. package/build/services/durable/search.js +34 -7
  20. package/build/services/durable/worker.d.ts +10 -7
  21. package/build/services/durable/worker.js +59 -49
  22. package/build/services/durable/workflow.d.ts +20 -2
  23. package/build/services/durable/workflow.js +97 -84
  24. package/build/services/engine/index.d.ts +2 -2
  25. package/build/services/engine/index.js +7 -12
  26. package/build/services/hotmesh/index.d.ts +2 -2
  27. package/build/services/hotmesh/index.js +2 -2
  28. package/build/services/signaler/store.d.ts +2 -2
  29. package/build/services/signaler/store.js +17 -7
  30. package/build/services/signaler/stream.js +1 -0
  31. package/build/services/store/clients/redis.js +1 -1
  32. package/build/services/store/index.js +3 -0
  33. package/build/services/telemetry/index.js +7 -1
  34. package/build/types/activity.d.ts +5 -3
  35. package/build/types/durable.d.ts +17 -4
  36. package/build/types/hook.d.ts +0 -1
  37. package/build/types/index.d.ts +1 -1
  38. package/modules/errors.ts +4 -2
  39. package/modules/utils.ts +6 -0
  40. package/package.json +2 -1
  41. package/services/activities/activity.ts +10 -8
  42. package/services/activities/hook.ts +17 -14
  43. package/services/activities/signal.ts +17 -3
  44. package/services/durable/client.ts +48 -56
  45. package/services/durable/factory.ts +274 -46
  46. package/services/durable/handle.ts +18 -5
  47. package/services/durable/search.ts +36 -7
  48. package/services/durable/worker.ts +61 -51
  49. package/services/durable/workflow.ts +110 -84
  50. package/services/engine/index.ts +8 -12
  51. package/services/hotmesh/index.ts +3 -3
  52. package/services/signaler/store.ts +18 -8
  53. package/services/signaler/stream.ts +1 -0
  54. package/services/store/clients/redis.ts +1 -1
  55. package/services/store/index.ts +2 -0
  56. package/services/telemetry/index.ts +6 -1
  57. package/types/activity.ts +10 -8
  58. package/types/durable.ts +18 -3
  59. package/types/hook.ts +0 -1
  60. 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';
@@ -15,44 +16,14 @@ import {
15
16
  Registry,
16
17
  WorkerConfig,
17
18
  WorkerOptions,
18
- WorkflowDataType } from "../../types/durable";
19
+ WorkflowDataType,
20
+ WorkflowSearchOptions} from '../../types/durable';
19
21
  import { RedisClass, RedisOptions } from '../../types/redis';
20
22
  import {
21
23
  StreamData,
22
24
  StreamDataResponse,
23
25
  StreamStatus } from '../../types/stream';
24
26
 
25
- /*
26
- Here is an example of how the methods in this file are used:
27
-
28
- ./worker.ts
29
-
30
- import { Durable } from '@hotmeshio/hotmesh';
31
- import Redis from 'ioredis'; //OR `import * as Redis from 'redis';`
32
-
33
- import * as workflows from './workflows';
34
-
35
- async function run() {
36
- const worker = await Durable.Worker.create({
37
- connection: {
38
- class: Redis,
39
- options: {
40
- host: 'localhost',
41
- port: 6379,
42
- },
43
- },
44
- taskQueue: 'hello-world',
45
- workflow: workflows.example,
46
- });
47
- await worker.run();
48
- }
49
-
50
- run().catch((err) => {
51
- console.error(err);
52
- process.exit(1);
53
- });
54
- */
55
-
56
27
  export class WorkerService {
57
28
  static activityRegistry: Registry = {}; //user's activities
58
29
  static connection: Connection;
@@ -60,25 +31,26 @@ export class WorkerService {
60
31
  workflowRunner: HotMesh;
61
32
  activityRunner: HotMesh;
62
33
 
63
- static getHotMesh = async (worflowTopic: string, options?: WorkerOptions) => {
64
- if (WorkerService.instances.has(worflowTopic)) {
65
- 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);
66
37
  }
67
38
  const hotMeshClient = HotMesh.init({
68
- appId: APP_ID,
39
+ logLevel: options?.logLevel as 'debug' ?? 'info',
40
+ appId: config.namespace ?? APP_ID,
69
41
  engine: { redis: { ...WorkerService.connection } }
70
42
  });
71
- WorkerService.instances.set(worflowTopic, hotMeshClient);
43
+ WorkerService.instances.set(workflowTopic, hotMeshClient);
72
44
  await WorkerService.activateWorkflow(await hotMeshClient);
73
45
  return hotMeshClient;
74
46
  }
75
47
 
76
48
  static async activateWorkflow(hotMesh: HotMesh) {
77
- const app = await hotMesh.engine.store.getApp(APP_ID);
49
+ const app = await hotMesh.engine.store.getApp(hotMesh.engine.appId);
78
50
  const appVersion = app?.version;
79
51
  if(!appVersion) {
80
52
  try {
81
- await hotMesh.deploy(getWorkflowYAML(APP_ID, APP_VERSION));
53
+ await hotMesh.deploy(getWorkflowYAML(hotMesh.engine.appId, APP_VERSION));
82
54
  await hotMesh.activate(APP_VERSION);
83
55
  } catch (err) {
84
56
  hotMesh.engine.logger.error('durable-worker-deploy-activate-err', err);
@@ -94,11 +66,6 @@ export class WorkerService {
94
66
  }
95
67
  }
96
68
 
97
- /**
98
- * NOTE: Because the worker imports the workflows dynamically AFTER
99
- * the activities are loaded, there will be items in the registry,
100
- * allowing proxyActivities to succeed.
101
- */
102
69
  static registerActivities<ACT>(activities: ACT): Registry {
103
70
  if (typeof activities === 'function' && typeof WorkerService.activityRegistry[activities.name] !== 'function') {
104
71
  WorkerService.activityRegistry[activities.name] = activities as Function;
@@ -112,8 +79,40 @@ export class WorkerService {
112
79
  return WorkerService.activityRegistry;
113
80
  }
114
81
 
82
+ /**
83
+ * For those deployments with a redis stack backend (with the FT module),
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.
88
+ */
89
+ static async configureSearchIndex(hotMeshClient: HotMesh, search?: WorkflowSearchOptions): Promise<void> {
90
+ if (search?.schema) {
91
+ const store = hotMeshClient.engine.store;
92
+ const schema: string[] = [];
93
+ for (const [key, value] of Object.entries(search.schema)) {
94
+ //prefix with a comma (avoids collisions with hotmesh reserved words)
95
+ schema.push(`_${key}`);
96
+ schema.push(value.type);
97
+ if (value.sortable) {
98
+ schema.push('SORTABLE');
99
+ }
100
+ }
101
+ try {
102
+ const keyParams = {
103
+ appId: hotMeshClient.appId,
104
+ jobId: ''
105
+ }
106
+ const hotMeshPrefix = KeyService.mintKey(hotMeshClient.namespace, KeyType.JOB_STATE, keyParams);
107
+ const prefixes = search.prefix.map((prefix) => `${hotMeshPrefix}${prefix}`);
108
+ await store.exec('FT.CREATE', `${search.index}`, 'ON', 'HASH', 'PREFIX', prefixes.length, ...prefixes, 'SCHEMA', ...schema);
109
+ } catch (err) {
110
+ hotMeshClient.engine.logger.info('durable-client-search-err', { err });
111
+ }
112
+ }
113
+ }
114
+
115
115
  static async create(config: WorkerConfig) {
116
- //always call `registerActivities` before `import`
117
116
  WorkerService.connection = config.connection;
118
117
  const workflow = config.workflow;
119
118
  const [workflowFunctionName, workflowFunction] = WorkerService.resolveWorkflowTarget(workflow);
@@ -125,6 +124,7 @@ export class WorkerService {
125
124
  const worker = new WorkerService();
126
125
  worker.activityRunner = await worker.initActivityWorker(config, activityTopic);
127
126
  worker.workflowRunner = await worker.initWorkflowWorker(config, workflowTopic, workflowFunction);
127
+ WorkerService.configureSearchIndex(worker.workflowRunner, config.search)
128
128
  await WorkerService.activateWorkflow(worker.workflowRunner);
129
129
  return worker;
130
130
  }
@@ -151,7 +151,8 @@ export class WorkerService {
151
151
  options: config.connection.options as RedisOptions
152
152
  };
153
153
  const hotMeshWorker = await HotMesh.init({
154
- appId: APP_ID,
154
+ logLevel: config.options?.logLevel as 'debug' ?? 'info',
155
+ appId: config.namespace ?? APP_ID,
155
156
  engine: { redis: redisConfig },
156
157
  workers: [
157
158
  { topic: activityTopic,
@@ -201,12 +202,13 @@ export class WorkerService {
201
202
  options: config.connection.options as RedisOptions
202
203
  };
203
204
  const hotMeshWorker = await HotMesh.init({
204
- appId: APP_ID,
205
+ logLevel: config.options?.logLevel as 'debug' ?? 'info',
206
+ appId: config.namespace ?? APP_ID,
205
207
  engine: { redis: redisConfig },
206
208
  workers: [{
207
209
  topic: workflowTopic,
208
210
  redis: redisConfig,
209
- callback: this.wrapWorkflowFunction(workflowFunction, workflowTopic).bind(this)
211
+ callback: this.wrapWorkflowFunction(workflowFunction, workflowTopic, config).bind(this)
210
212
  }]
211
213
  });
212
214
  WorkerService.instances.set(workflowTopic, hotMeshWorker);
@@ -222,15 +224,22 @@ export class WorkerService {
222
224
  },
223
225
  };
224
226
 
225
- wrapWorkflowFunction(workflowFunction: Function, workflowTopic: string): Function {
227
+ wrapWorkflowFunction(workflowFunction: Function, workflowTopic: string, config: WorkerConfig): Function {
226
228
  return async (data: StreamData): Promise<StreamDataResponse> => {
227
229
  const counter = { counter: 0 };
228
230
  try {
229
231
  //incoming data payload has arguments and workflowId
230
232
  const workflowInput = data.data as unknown as WorkflowDataType;
231
233
  const context = new Map();
234
+ context.set('namespace', config.namespace ?? APP_ID);
232
235
  context.set('counter', counter);
233
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
+ }
234
243
  context.set('workflowTopic', workflowTopic);
235
244
  context.set('workflowName', workflowTopic.split('-').pop());
236
245
  context.set('workflowTrace', data.metadata.trc);
@@ -255,9 +264,10 @@ export class WorkerService {
255
264
  metadata: { ...data.metadata },
256
265
  data: {
257
266
  code: err.code,
258
- message: JSON.stringify({ duration: err.duration, index: err.index }),
267
+ message: JSON.stringify({ duration: err.duration, index: err.index, dimension: err.dimension }),
259
268
  duration: err.duration,
260
- index: err.index
269
+ index: err.index,
270
+ dimension: err.dimension
261
271
  }
262
272
  } as StreamDataResponse;
263
273
 
@@ -1,43 +1,24 @@
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';
12
-
13
- /*
14
- `proxyActivities` returns a wrapped instance of the
15
- target activity, so that when the workflow calls a
16
- proxied activity, it is actually calling the proxy
17
- function, which in turn calls the activity function.
18
-
19
- Here is an example of how the methods in this file are used:
20
-
21
- ./workflows.ts
22
-
23
- import { Durable } from '@hotmeshio/hotmesh';
24
- import * as activities from './activities';
25
-
26
- const { greet } = Durable.workflow.proxyActivities<typeof activities>({
27
- activities: activities,
28
- startToCloseTimeout: '1 minute',
29
- retryPolicy: {
30
- initialInterval: '5 seconds', // Initial delay between retries
31
- maximumAttempts: 3, // Max number of retry attempts
32
- backoffCoefficient: 2.0, // Backoff factor for delay between retries: delay = initialInterval * (backoffCoefficient ^ retry_attempt)
33
- maximumInterval: '30 seconds', // Max delay between retries
34
- },
35
- });
36
-
37
- export async function example(name: string): Promise<string> {
38
- return await greet(name);
39
- }
40
- */
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';
41
22
 
42
23
  export class WorkflowService {
43
24
 
@@ -46,15 +27,13 @@ export class WorkflowService {
46
27
  */
47
28
  static async executeChild<T>(options: WorkflowOptions): Promise<T> {
48
29
  const store = asyncLocalStorage.getStore();
49
- if (!store) {
50
- throw new Error('durable-store-not-found');
51
- }
52
30
  const workflowId = store.get('workflowId');
31
+ const workflowDimension = store.get('workflowDimension') ?? '';
53
32
  const workflowTrace = store.get('workflowTrace');
54
33
  const workflowSpan = store.get('workflowSpan');
55
34
  const COUNTER = store.get('counter');
56
35
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
57
- const childJobId = `${workflowId}-$${options.workflowName}-${execIndex}`;
36
+ const childJobId = `${workflowId}-$${options.workflowName}${workflowDimension}-${execIndex}`;
58
37
  const parentWorkflowId = `${workflowId}-f`;
59
38
 
60
39
  const client = new Client({
@@ -68,7 +47,7 @@ export class WorkflowService {
68
47
  );
69
48
 
70
49
  try {
71
- return await handle.result() as T;
50
+ return await handle.result(true) as T;
72
51
  } catch (error) {
73
52
  handle = await client.workflow.start({
74
53
  ...options,
@@ -98,33 +77,83 @@ export class WorkflowService {
98
77
  return proxy;
99
78
  }
100
79
 
101
- static async data(command: 'del' | 'get' | 'set' | 'incr' | 'mult', ...args: string[]): Promise<number | boolean | string> {
80
+ static async search(): Promise<Search> {
102
81
  const store = asyncLocalStorage.getStore();
103
- if (!store) {
104
- throw new Error('durable-store-not-found');
105
- }
106
82
  const workflowId = store.get('workflowId');
83
+ const workflowDimension = store.get('workflowDimension') ?? '';
107
84
  const workflowTopic = store.get('workflowTopic');
85
+ const namespace = store.get('namespace');
86
+ const COUNTER = store.get('counter');
87
+ const execIndex = COUNTER.counter = COUNTER.counter + 1;
88
+ //this ID is used as a item key with a hash (dash prefix ensures no collision)
89
+ const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
90
+ const searchSessionId = `-search${workflowDimension}-${execIndex}`;
91
+ return new Search(workflowId, hotMeshClient, searchSessionId);
92
+ }
108
93
 
109
- try {
110
- const hotMeshClient = await WorkerService.getHotMesh(workflowTopic);
111
- const search = new Search(workflowId, hotMeshClient);
112
- if (command === 'get') {
113
- return await search.get(args[0]) as string;
114
- } else if (command === 'set') {
115
- await search.set(args[0], args[1]);
116
- return true;
117
- } else if (command === 'del') {
118
- await search.del(args[0]);
119
- return true;
120
- } else if (command === 'incr') {
121
- return await search.incr(args[0], Number(args[1])) as number;
122
- } else if (command === 'mult') {
123
- return await search.mult(args[0], Number(args[1])) as number;
94
+ /**
95
+ * those methods that may only be called once must be protected by flagging
96
+ * their execution with a unique key (the key is stored in the workflow state)
97
+ */
98
+ static async isSideEffectAllowed(hotMeshClient: HotMesh, prefix:string): Promise<boolean> {
99
+ const store = asyncLocalStorage.getStore();
100
+ const workflowId = store.get('workflowId');
101
+ const workflowDimension = store.get('workflowDimension') ?? '';
102
+ const COUNTER = store.get('counter');
103
+ const execIndex = COUNTER.counter = COUNTER.counter + 1;
104
+ //this ID is used as a item key with a hash (dash prefix ensures no collision)
105
+ const sessionId = `-${prefix}${workflowDimension}-${execIndex}-`;
106
+ //this ID is used as a item key with a hash (dash prefix ensures no collision)
107
+ const keyParams = {
108
+ appId: hotMeshClient.appId,
109
+ jobId: ''
110
+ }
111
+ const hotMeshPrefix = KeyService.mintKey(hotMeshClient.namespace, KeyType.JOB_STATE, keyParams);
112
+ const workflowGuid = `${hotMeshPrefix}${workflowId}`;
113
+ const guidValue = Number(await hotMeshClient.engine.store.exec('HINCRBYFLOAT', workflowGuid, sessionId, '1') as string);
114
+ return guidValue === 1;
115
+ }
116
+
117
+ /**
118
+ * send signal data into any other paused thread (which is paused and
119
+ * awaiting the signal) from within a hook-thread or the main-thread
120
+ */
121
+ static async signal(signalId: string, data: Record<any, any>): Promise<string> {
122
+ const store = asyncLocalStorage.getStore();
123
+ const namespace = store.get('namespace');
124
+ const hotMeshClient = await WorkerService.getHotMesh(`${namespace}.wfs.signal`, { namespace });
125
+ if (await WorkflowService.isSideEffectAllowed(hotMeshClient, 'signal')) {
126
+ return await hotMeshClient.hook(`${namespace}.wfs.signal`, { id: signalId, data });
127
+ }
128
+ }
129
+
130
+ /**
131
+ * spawn a hook from either the main thread or a hook thread with
132
+ * the provided options; worflowId/TaskQueue/Name are optional and will
133
+ * default to the current workflowId/WorkflowTopic if not provided
134
+ */
135
+ static async hook(options: HookOptions): Promise<string> {
136
+ const store = asyncLocalStorage.getStore();
137
+ const namespace = store.get('namespace');
138
+ const hotMeshClient = await WorkerService.getHotMesh(`${namespace}.flow.signal`, { namespace });
139
+ if (await WorkflowService.isSideEffectAllowed(hotMeshClient, 'hook')) {
140
+ const store = asyncLocalStorage.getStore();
141
+ let workflowId: string;
142
+ let workflowTopic: string;
143
+ if (options.workflowId && options.taskQueue && options.workflowName) {
144
+ workflowId = options.workflowId;
145
+ workflowTopic = `${options.taskQueue}-${options.workflowName}`;
146
+ } else {
147
+ workflowId = store.get('workflowId');
148
+ workflowTopic = store.get('workflowTopic');
124
149
  }
125
- } catch (e) {
126
- console.error(e);
127
- return '';
150
+ const payload = {
151
+ arguments: [...options.args],
152
+ id: workflowId,
153
+ workflowTopic,
154
+ backoffCoefficient: options.config?.backoffCoefficient || DEFAULT_COEFFICIENT,
155
+ }
156
+ return await hotMeshClient.hook(`${namespace}.flow.signal`, payload, StreamStatus.PENDING, 202);
128
157
  }
129
158
  }
130
159
 
@@ -132,37 +161,35 @@ export class WorkflowService {
132
161
  const seconds = ms(duration) / 1000;
133
162
 
134
163
  const store = asyncLocalStorage.getStore();
135
- if (!store) {
136
- throw new Error('durable-store-not-found');
137
- }
138
164
  const COUNTER = store.get('counter');
139
165
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
140
166
  const workflowId = store.get('workflowId');
141
167
  const workflowTopic = store.get('workflowTopic');
142
- const sleepJobId = `${workflowId}-$sleep-${execIndex}`;
168
+ const workflowDimension = store.get('workflowDimension') ?? '';
169
+ const namespace = store.get('namespace');
170
+ const sleepJobId = `${workflowId}-$sleep${workflowDimension}-${execIndex}`;
143
171
 
144
172
  try {
145
- const hotMeshClient = await WorkerService.getHotMesh(workflowTopic);
146
- await hotMeshClient.getState(SLEEP_SUBSCRIBES_TOPIC, sleepJobId);
173
+ const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
174
+ await hotMeshClient.getState(`${hotMeshClient.appId}.sleep.execute`, sleepJobId);
147
175
  //if no error is thrown, we've already slept, return the delay
148
176
  return seconds;
149
177
  } catch (e) {
150
178
  //if an error, the sleep job was not found...rethrow error; sleep job
151
179
  // will be automatically created according to the DAG rules (they
152
180
  // spawn a new sleep job if error code 595 is thrown by the worker)
153
- throw new DurableSleepError(workflowId, seconds, execIndex);
181
+ throw new DurableSleepError(workflowId, seconds, execIndex, workflowDimension);
154
182
  }
155
183
  }
156
184
 
157
185
  static async waitForSignal(signals: string[], options?: Record<string, string>): Promise<Record<any, any>[]> {
158
186
  const store = asyncLocalStorage.getStore();
159
- if (!store) {
160
- throw new Error('durable-store-not-found');
161
- }
162
187
  const COUNTER = store.get('counter');
163
188
  const workflowId = store.get('workflowId');
164
189
  const workflowTopic = store.get('workflowTopic');
165
- const hotMeshClient = await WorkerService.getHotMesh(workflowTopic);
190
+ const workflowDimension = store.get('workflowDimension') ?? '';
191
+ const namespace = store.get('namespace');
192
+ const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
166
193
 
167
194
  //iterate the list of signals and check for done
168
195
  let allAreComplete = true;
@@ -170,10 +197,10 @@ export class WorkflowService {
170
197
  const signalResults: any[] = [];
171
198
  for (const signal of signals) {
172
199
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
173
- const wfsJobId = `${workflowId}-$wfs-${execIndex}`;
200
+ const wfsJobId = `${workflowId}-$wfs${workflowDimension}-${execIndex}`;
174
201
  try {
175
202
  if (allAreComplete) {
176
- const state = await hotMeshClient.getState(WFS_SUBSCRIBES_TOPIC, wfsJobId);
203
+ const state = await hotMeshClient.getState(`${hotMeshClient.appId}.wfs.execute`, wfsJobId);
177
204
  if (state.data?.signalData) {
178
205
  //user data is nested to isolate from the signal id; unpackage it
179
206
  const signalData = state.data.signalData as { id: string, data: Record<any, any> };
@@ -207,23 +234,22 @@ export class WorkflowService {
207
234
  static wrapActivity<T>(activityName: string, options?: ActivityConfig): T {
208
235
  return async function() {
209
236
  const store = asyncLocalStorage.getStore();
210
- if (!store) {
211
- throw new Error('durable-store-not-found');
212
- }
213
237
  const COUNTER = store.get('counter');
214
238
  //increment by state (not value) to avoid race conditions
215
239
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
216
240
  const workflowId = store.get('workflowId');
241
+ const workflowDimension = store.get('workflowDimension') ?? '';
217
242
  const workflowTopic = store.get('workflowTopic');
218
243
  const trc = store.get('workflowTrace');
219
244
  const spn = store.get('workflowSpan');
245
+ const namespace = store.get('namespace');
220
246
  const activityTopic = `${workflowTopic}-activity`;
221
- const activityJobId = `${workflowId}-$${activityName}-${execIndex}`;
247
+ const activityJobId = `${workflowId}-$${activityName}${workflowDimension}-${execIndex}`;
222
248
 
223
249
  let activityState: JobOutput
224
250
  try {
225
- const hotMeshClient = await WorkerService.getHotMesh(activityTopic);
226
- activityState = await hotMeshClient.getState(ACTIVITY_SUBSCRIBES_TOPIC, activityJobId);
251
+ const hotMeshClient = await WorkerService.getHotMesh(activityTopic, { namespace });
252
+ activityState = await hotMeshClient.getState(`${hotMeshClient.appId}.activity.execute`, activityJobId);
227
253
  if (activityState.metadata.err) {
228
254
  await hotMeshClient.scrub(activityJobId);
229
255
  throw new Error(activityState.metadata.err);
@@ -232,9 +258,9 @@ export class WorkflowService {
232
258
  }
233
259
  //one time subscription
234
260
  return await new Promise((resolve, reject) => {
235
- hotMeshClient.sub(`${ACTIVITY_PUBLISHES_TOPIC}.${activityJobId}`, async (topic, message) => {
261
+ hotMeshClient.sub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`, async (topic, message) => {
236
262
  const response = message.data?.response;
237
- hotMeshClient.unsub(`${ACTIVITY_PUBLISHES_TOPIC}.${activityJobId}`);
263
+ hotMeshClient.unsub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`);
238
264
  // Resolve the Promise when the callback is triggered with a message
239
265
  resolve(response);
240
266
  });
@@ -251,9 +277,9 @@ export class WorkflowService {
251
277
  activityName,
252
278
  };
253
279
  //start the job
254
- const hotMeshClient = await WorkerService.getHotMesh(activityTopic);
280
+ const hotMeshClient = await WorkerService.getHotMesh(activityTopic, { namespace });
255
281
  const context = { metadata: { trc, spn }, data: {}};
256
- const jobOutput = await hotMeshClient.pubsub(ACTIVITY_SUBSCRIBES_TOPIC, payload, context as JobState, duration);
282
+ const jobOutput = await hotMeshClient.pubsub(`${hotMeshClient.appId}.activity.execute`, payload, context as JobState, duration);
257
283
  return jobOutput.data.response as T;
258
284
  }
259
285
  } 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
  }