@hotmeshio/hotmesh 0.0.19 → 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 (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 +34 -7
  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 +18 -1
  22. package/build/services/durable/workflow.js +99 -35
  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 +12 -1
  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 +36 -7
  46. package/services/durable/worker.ts +30 -24
  47. package/services/durable/workflow.ts +111 -38
  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 +13 -0
  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,14 +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';
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
 
@@ -17,15 +27,13 @@ export class WorkflowService {
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
- }
23
30
  const workflowId = store.get('workflowId');
31
+ const workflowDimension = store.get('workflowDimension') ?? '';
24
32
  const workflowTrace = store.get('workflowTrace');
25
33
  const workflowSpan = store.get('workflowSpan');
26
34
  const COUNTER = store.get('counter');
27
35
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
28
- const childJobId = `${workflowId}-$${options.workflowName}-${execIndex}`;
36
+ const childJobId = `${workflowId}-$${options.workflowName}${workflowDimension}-${execIndex}`;
29
37
  const parentWorkflowId = `${workflowId}-f`;
30
38
 
31
39
  const client = new Client({
@@ -39,7 +47,7 @@ export class WorkflowService {
39
47
  );
40
48
 
41
49
  try {
42
- return await handle.result() as T;
50
+ return await handle.result(true) as T;
43
51
  } catch (error) {
44
52
  handle = await client.workflow.start({
45
53
  ...options,
@@ -71,51 +79,117 @@ export class WorkflowService {
71
79
 
72
80
  static async search(): Promise<Search> {
73
81
  const store = asyncLocalStorage.getStore();
74
- if (!store) {
75
- throw new Error('durable-store-not-found');
76
- }
77
82
  const workflowId = store.get('workflowId');
83
+ const workflowDimension = store.get('workflowDimension') ?? '';
78
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
+ }
93
+
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
+ }
79
116
 
80
- const hotMeshClient = await WorkerService.getHotMesh(workflowTopic);
81
- return new Search(workflowId, hotMeshClient);
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');
149
+ }
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);
157
+ }
82
158
  }
83
159
 
84
160
  static async sleep(duration: string): Promise<number> {
85
161
  const seconds = ms(duration) / 1000;
86
162
 
87
163
  const store = asyncLocalStorage.getStore();
88
- if (!store) {
89
- throw new Error('durable-store-not-found');
90
- }
91
164
  const COUNTER = store.get('counter');
92
165
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
93
166
  const workflowId = store.get('workflowId');
94
167
  const workflowTopic = store.get('workflowTopic');
95
- const sleepJobId = `${workflowId}-$sleep-${execIndex}`;
168
+ const workflowDimension = store.get('workflowDimension') ?? '';
169
+ const namespace = store.get('namespace');
170
+ const sleepJobId = `${workflowId}-$sleep${workflowDimension}-${execIndex}`;
96
171
 
97
172
  try {
98
- const hotMeshClient = await WorkerService.getHotMesh(workflowTopic);
99
- await hotMeshClient.getState(SLEEP_SUBSCRIBES_TOPIC, sleepJobId);
173
+ const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
174
+ await hotMeshClient.getState(`${hotMeshClient.appId}.sleep.execute`, sleepJobId);
100
175
  //if no error is thrown, we've already slept, return the delay
101
176
  return seconds;
102
177
  } catch (e) {
103
178
  //if an error, the sleep job was not found...rethrow error; sleep job
104
179
  // will be automatically created according to the DAG rules (they
105
180
  // spawn a new sleep job if error code 595 is thrown by the worker)
106
- throw new DurableSleepError(workflowId, seconds, execIndex);
181
+ throw new DurableSleepError(workflowId, seconds, execIndex, workflowDimension);
107
182
  }
108
183
  }
109
184
 
110
185
  static async waitForSignal(signals: string[], options?: Record<string, string>): Promise<Record<any, any>[]> {
111
186
  const store = asyncLocalStorage.getStore();
112
- if (!store) {
113
- throw new Error('durable-store-not-found');
114
- }
115
187
  const COUNTER = store.get('counter');
116
188
  const workflowId = store.get('workflowId');
117
189
  const workflowTopic = store.get('workflowTopic');
118
- 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 });
119
193
 
120
194
  //iterate the list of signals and check for done
121
195
  let allAreComplete = true;
@@ -123,10 +197,10 @@ export class WorkflowService {
123
197
  const signalResults: any[] = [];
124
198
  for (const signal of signals) {
125
199
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
126
- const wfsJobId = `${workflowId}-$wfs-${execIndex}`;
200
+ const wfsJobId = `${workflowId}-$wfs${workflowDimension}-${execIndex}`;
127
201
  try {
128
202
  if (allAreComplete) {
129
- const state = await hotMeshClient.getState(WFS_SUBSCRIBES_TOPIC, wfsJobId);
203
+ const state = await hotMeshClient.getState(`${hotMeshClient.appId}.wfs.execute`, wfsJobId);
130
204
  if (state.data?.signalData) {
131
205
  //user data is nested to isolate from the signal id; unpackage it
132
206
  const signalData = state.data.signalData as { id: string, data: Record<any, any> };
@@ -160,23 +234,22 @@ export class WorkflowService {
160
234
  static wrapActivity<T>(activityName: string, options?: ActivityConfig): T {
161
235
  return async function() {
162
236
  const store = asyncLocalStorage.getStore();
163
- if (!store) {
164
- throw new Error('durable-store-not-found');
165
- }
166
237
  const COUNTER = store.get('counter');
167
238
  //increment by state (not value) to avoid race conditions
168
239
  const execIndex = COUNTER.counter = COUNTER.counter + 1;
169
240
  const workflowId = store.get('workflowId');
241
+ const workflowDimension = store.get('workflowDimension') ?? '';
170
242
  const workflowTopic = store.get('workflowTopic');
171
243
  const trc = store.get('workflowTrace');
172
244
  const spn = store.get('workflowSpan');
245
+ const namespace = store.get('namespace');
173
246
  const activityTopic = `${workflowTopic}-activity`;
174
- const activityJobId = `${workflowId}-$${activityName}-${execIndex}`;
247
+ const activityJobId = `${workflowId}-$${activityName}${workflowDimension}-${execIndex}`;
175
248
 
176
249
  let activityState: JobOutput
177
250
  try {
178
- const hotMeshClient = await WorkerService.getHotMesh(activityTopic);
179
- 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);
180
253
  if (activityState.metadata.err) {
181
254
  await hotMeshClient.scrub(activityJobId);
182
255
  throw new Error(activityState.metadata.err);
@@ -185,9 +258,9 @@ export class WorkflowService {
185
258
  }
186
259
  //one time subscription
187
260
  return await new Promise((resolve, reject) => {
188
- hotMeshClient.sub(`${ACTIVITY_PUBLISHES_TOPIC}.${activityJobId}`, async (topic, message) => {
261
+ hotMeshClient.sub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`, async (topic, message) => {
189
262
  const response = message.data?.response;
190
- hotMeshClient.unsub(`${ACTIVITY_PUBLISHES_TOPIC}.${activityJobId}`);
263
+ hotMeshClient.unsub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`);
191
264
  // Resolve the Promise when the callback is triggered with a message
192
265
  resolve(response);
193
266
  });
@@ -204,9 +277,9 @@ export class WorkflowService {
204
277
  activityName,
205
278
  };
206
279
  //start the job
207
- const hotMeshClient = await WorkerService.getHotMesh(activityTopic);
280
+ const hotMeshClient = await WorkerService.getHotMesh(activityTopic, { namespace });
208
281
  const context = { metadata: { trc, spn }, data: {}};
209
- 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);
210
283
  return jobOutput.data.response as T;
211
284
  }
212
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
  }
@@ -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 {
package/types/activity.ts CHANGED
@@ -70,14 +70,16 @@ interface HookActivity extends BaseActivity {
70
70
  }
71
71
 
72
72
  interface SignalActivity extends BaseActivity {
73
- type: 'signal'; //signal activities call hook/hookAll
74
- subtype: 'one' | 'all'; //trigger: hook(One) or hookAll
75
- topic: string; //e.g., 'hook.resume'
76
- key_name: string; //e.g., 'parent_job_id'
77
- key_value: string; //e.g., '1234567890'
78
- scrub: boolean; //if true, the index will be deleted after use
79
- signal?: Record<string, any>; //used to define/map the signal input data
80
- resolver?: Record<string, any>; //used to define/map the signal key resolver
73
+ type: 'signal'; //signal activities call hook/hookAll
74
+ subtype: 'one' | 'all'; //trigger: hook(One) or hookAll
75
+ topic: string; //e.g., 'hook.resume'
76
+ key_name?: string; //e.g., 'parent_job_id'
77
+ key_value?: string; //e.g., '1234567890'
78
+ scrub?: boolean; //if true, the index will be deleted after use
79
+ signal?: Record<string, any>; //used to define/map the signal input data (what to send/singnal into the job(s))
80
+ resolver?: Record<string, any>; //used to define/map the signal key resolver (the key used to lookup the job(s that are assigned to the key)
81
+ status?: string; //pending, success (default), error
82
+ code?: number; //202, 200 (default)
81
83
  }
82
84
 
83
85
  interface IterateActivity extends BaseActivity {
package/types/durable.ts CHANGED
@@ -15,6 +15,7 @@ type WorkflowSearchOptions = {
15
15
  }
16
16
 
17
17
  type WorkflowOptions = {
18
+ namespace?: string; //'durable' is the default namespace if not provided; similar to setting `appid` in the YAML
18
19
  taskQueue: string;
19
20
  args: any[]; //input arguments to pass in
20
21
  workflowId: string; //execution id (the job id)
@@ -26,6 +27,16 @@ type WorkflowOptions = {
26
27
  config?: WorkflowConfig;
27
28
  }
28
29
 
30
+ type HookOptions = {
31
+ namespace?: string; //'durable' is the default namespace if not provided; similar to setting `appid` in the YAML
32
+ taskQueue: string;
33
+ args: any[]; //input arguments to pass into the hook
34
+ workflowId: string; //execution id (the job id to hook into)
35
+ workflowName?: string; //the name of the user's hook function
36
+ search?: WorkflowSearchOptions //bind additional search terms immediately before hook reentry
37
+ config?: WorkflowConfig; //hook function constraints (backoffCoefficient, maximumAttempts, maximumInterval, initialInterval)
38
+ }
39
+
29
40
  type SignalOptions = {
30
41
  taskQueue: string;
31
42
  data: Record<string, any>; //input data (any serializable object)
@@ -71,6 +82,7 @@ type WorkerConfig = {
71
82
  }
72
83
 
73
84
  type WorkerOptions = {
85
+ logLevel?: string; //debug, info, warn, error
74
86
  maxSystemRetries?: number; //1-3 (10ms, 100ms, 1_000ms)
75
87
  backoffCoefficient?: number; //2-10ish
76
88
  }
@@ -107,6 +119,7 @@ export {
107
119
  ProxyType,
108
120
  Registry,
109
121
  SignalOptions,
122
+ HookOptions,
110
123
  WorkerConfig,
111
124
  WorkflowConfig,
112
125
  WorkerOptions,
package/types/hook.ts CHANGED
@@ -16,7 +16,6 @@ interface HookConditions {
16
16
 
17
17
  interface HookRule {
18
18
  to: string;
19
- keep_alive?: boolean; //if true, the hook will not be deleted after use
20
19
  conditions: HookConditions;
21
20
  }
22
21