@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
@@ -15,6 +15,7 @@ type WorkflowSearchOptions = {
15
15
  data?: Record<string, string>;
16
16
  };
17
17
  type WorkflowOptions = {
18
+ namespace?: string;
18
19
  taskQueue: string;
19
20
  args: any[];
20
21
  workflowId: string;
@@ -25,6 +26,15 @@ type WorkflowOptions = {
25
26
  search?: WorkflowSearchOptions;
26
27
  config?: WorkflowConfig;
27
28
  };
29
+ type HookOptions = {
30
+ namespace?: string;
31
+ taskQueue: string;
32
+ args: any[];
33
+ workflowId: string;
34
+ workflowName?: string;
35
+ search?: WorkflowSearchOptions;
36
+ config?: WorkflowConfig;
37
+ };
28
38
  type SignalOptions = {
29
39
  taskQueue: string;
30
40
  data: Record<string, any>;
@@ -63,6 +73,7 @@ type WorkerConfig = {
63
73
  search?: WorkflowSearchOptions;
64
74
  };
65
75
  type WorkerOptions = {
76
+ logLevel?: string;
66
77
  maxSystemRetries?: number;
67
78
  backoffCoefficient?: number;
68
79
  };
@@ -84,4 +95,4 @@ type ActivityConfig = {
84
95
  maximumInterval: string;
85
96
  };
86
97
  };
87
- export { ActivityConfig, ActivityWorkflowDataType, ClientConfig, ContextType, ConnectionConfig, Connection, NativeConnection, ProxyType, Registry, SignalOptions, WorkerConfig, WorkflowConfig, WorkerOptions, WorkflowSearchOptions, WorkflowDataType, WorkflowOptions, };
98
+ export { ActivityConfig, ActivityWorkflowDataType, ClientConfig, ContextType, ConnectionConfig, Connection, NativeConnection, ProxyType, Registry, SignalOptions, HookOptions, WorkerConfig, WorkflowConfig, WorkerOptions, WorkflowSearchOptions, WorkflowDataType, WorkflowOptions, };
@@ -12,7 +12,6 @@ interface HookConditions {
12
12
  }
13
13
  interface HookRule {
14
14
  to: string;
15
- keep_alive?: boolean;
16
15
  conditions: HookConditions;
17
16
  }
18
17
  interface HookRules {
@@ -3,7 +3,7 @@ export { App, AppVID, AppTransitions, AppSubscriptions } from './app';
3
3
  export { AsyncSignal } from './async';
4
4
  export { CacheMode } from './cache';
5
5
  export { CollationFaultType, CollationStage } from './collator';
6
- export { ActivityConfig, ActivityWorkflowDataType, ClientConfig, ContextType, ConnectionConfig, Connection, NativeConnection, ProxyType, Registry, WorkflowConfig, WorkerConfig, WorkerOptions, WorkflowSearchOptions, WorkflowDataType, WorkflowOptions, } from './durable';
6
+ export { ActivityConfig, ActivityWorkflowDataType, ClientConfig, ContextType, ConnectionConfig, Connection, NativeConnection, ProxyType, Registry, HookOptions, WorkflowConfig, WorkerConfig, WorkerOptions, WorkflowSearchOptions, WorkflowDataType, WorkflowOptions, } from './durable';
7
7
  export { HookCondition, HookConditions, HookGate, HookInterface, HookRule, HookRules, HookSignal } from './hook';
8
8
  export { RedisClientType as IORedisClientType, RedisMultiType as IORedisMultiType } from './ioredisclient';
9
9
  export { ILogger } from './logger';
package/modules/errors.ts CHANGED
@@ -36,11 +36,13 @@ class DurableWaitForSignalError extends Error {
36
36
  class DurableSleepError extends Error {
37
37
  code: number;
38
38
  duration: number; //seconds
39
- index: number; //execution order in the workflow
40
- constructor(message: string, duration: number, index: number) {
39
+ index: number; //execution order in the workflow
40
+ dimension: string; //hook dimension (e.g., ',0,1,0') (uses empty string for `null`)
41
+ constructor(message: string, duration: number, index: number, dimension: string) {
41
42
  super(message);
42
43
  this.duration = duration;
43
44
  this.index = index;
45
+ this.dimension = dimension;
44
46
  this.code = 595;
45
47
  }
46
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "description": "Unbreakable Workflows",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -45,6 +45,7 @@
45
45
  "test:durable": "NODE_ENV=test jest ./tests/durable/*/index.test.ts --detectOpenHandles --forceExit --verbose",
46
46
  "test:durable:hello": "NODE_ENV=test jest ./tests/durable/helloworld/index.test.ts --detectOpenHandles --forceExit --verbose",
47
47
  "test:durable:goodbye": "NODE_ENV=test jest ./tests/durable/goodbye/index.test.ts --detectOpenHandles --forceExit --verbose",
48
+ "test:durable:hook": "NODE_ENV=test jest ./tests/durable/hook/index.test.ts --detectOpenHandles --forceExit --verbose",
48
49
  "test:durable:retry": "NODE_ENV=test jest ./tests/durable/retry/index.test.ts --detectOpenHandles --forceExit --verbose",
49
50
  "test:durable:fatal": "NODE_ENV=test jest ./tests/durable/fatal/index.test.ts --detectOpenHandles --forceExit --verbose",
50
51
  "test:durable:sleep": "NODE_ENV=test jest ./tests/durable/sleep/index.test.ts --detectOpenHandles --forceExit --verbose",
@@ -70,16 +70,20 @@ class Activity {
70
70
  }
71
71
 
72
72
  //******** DUPLEX RE-ENTRY POINT ********//
73
- async processEvent(status: StreamStatus = StreamStatus.SUCCESS, code: StreamCode = 200, type: 'hook' | 'output' = 'output', jobId?: string): Promise<void> {
73
+ async processEvent(status: StreamStatus = StreamStatus.SUCCESS, code: StreamCode = 200, type: 'hook' | 'output' = 'output'): Promise<void> {
74
74
  this.setLeg(2);
75
- const jid = this.context.metadata.jid || jobId;
75
+ const jid = this.context.metadata.jid;
76
+ if (!jid) {
77
+ this.logger.error('activity-process-event-error', { message: 'job id is undefined' });
78
+ return;
79
+ }
76
80
  const aid = this.metadata.aid;
77
81
  this.status = status;
78
82
  this.code = code;
79
83
  this.logger.debug('activity-process-event', { topic: this.config.subtype, jid, aid, status, code });
80
84
  let telemetry: TelemetryService;
81
85
  try {
82
- await this.getState(jobId);
86
+ await this.getState();
83
87
  const aState = await CollatorService.notarizeReentry(this);
84
88
  this.adjacentIndex = CollatorService.getDimensionalIndex(aState);
85
89
 
@@ -107,7 +111,6 @@ class Activity {
107
111
  this.logger.info('process-event-inactive-error', { error });
108
112
  return;
109
113
  }
110
- console.error(error);
111
114
  this.logger.error('activity-process-event-error', { error });
112
115
  telemetry && telemetry.setActivityError(error.message);
113
116
  throw error;
@@ -323,7 +326,7 @@ class Activity {
323
326
  return MDATA_SYMBOLS[keys_to_save].KEYS.map((key) => `output/metadata/${key}`);
324
327
  }
325
328
 
326
- async getState(jobId?: string) {
329
+ async getState() {
327
330
  //assemble list of paths necessary to create 'job state' from the 'symbol hash'
328
331
  const jobSymbolHashName = `$${this.config.subscribes}`;
329
332
  const consumes: Consumes = {
@@ -348,10 +351,9 @@ class Activity {
348
351
  }
349
352
  TelemetryService.addTargetTelemetryPaths(consumes, this.config, this.metadata, this.leg);
350
353
  let { dad, jid } = this.context.metadata;
351
- jobId = jobId || jid;
352
- const dIds = CollatorService.getDimensionsById([...this.config.ancestors, this.metadata.aid], dad);
354
+ const dIds = CollatorService.getDimensionsById([...this.config.ancestors, this.metadata.aid], dad || '');
353
355
  //`state` is a flat hash; context is a tree
354
- const [state, status] = await this.store.getState(jobId, consumes, dIds);
356
+ const [state, status] = await this.store.getState(jid, consumes, dIds);
355
357
  this.context = restoreHierarchy(state) as JobState;
356
358
  this.initDimensionalAddress(dad);
357
359
  this.initSelf(this.context);
@@ -16,7 +16,7 @@ import {
16
16
  MultiResponseFlags,
17
17
  RedisMulti } from '../../types/redis';
18
18
  import { StringScalarType } from '../../types/serializer';
19
- import { StreamStatus } from '../../types/stream';
19
+ import { StreamCode, StreamStatus } from '../../types/stream';
20
20
 
21
21
  /**
22
22
  * Listens for `webhook`, `timehook`, and `cycle` (repeat) signals
@@ -108,7 +108,7 @@ class Hook extends Activity {
108
108
  async registerHook(multi?: RedisMulti): Promise<string | void> {
109
109
  if (this.config.hook?.topic) {
110
110
  const signaler = new StoreSignaler(this.store, this.logger);
111
- return await signaler.registerWebHook(this.config.hook.topic, this.context, multi);
111
+ return await signaler.registerWebHook(this.config.hook.topic, this.context, this.resolveDad(), multi);
112
112
  } else if (this.config.sleep) {
113
113
  const durationInSeconds = Pipe.resolve(this.config.sleep, this.context);
114
114
  const jobId = this.context.metadata.jid;
@@ -119,19 +119,22 @@ class Hook extends Activity {
119
119
  }
120
120
  }
121
121
 
122
- async processWebHookEvent(): Promise<JobStatus | void> {
122
+ async processWebHookEvent(status: StreamStatus = StreamStatus.SUCCESS, code: StreamCode = 200): Promise<JobStatus | void> {
123
123
  this.logger.debug('hook-process-web-hook-event', {
124
124
  topic: this.config.hook.topic,
125
- aid: this.metadata.aid
125
+ aid: this.metadata.aid,
126
+ status,
127
+ code,
126
128
  });
127
129
  const signaler = new StoreSignaler(this.store, this.logger);
128
130
  const data = { ...this.data };
129
- const jobId = await signaler.processWebHookSignal(this.config.hook.topic, data);
130
- if (jobId) {
131
- //if a webhook signal is sent that includes 'keep_alive' the hook will remain open
132
- const code = data.keep_alive ? 202 : 200;
133
- await this.processEvent(StreamStatus.SUCCESS, code, 'hook', jobId);
134
- if (code === 200) {
131
+ const signal = await signaler.processWebHookSignal(this.config.hook.topic, data);
132
+ if (signal) {
133
+ const [jobId, aid, dad] = signal;
134
+ this.context.metadata.jid = jobId;
135
+ this.context.metadata.dad = dad;
136
+ await this.processEvent(status, code, 'hook');
137
+ if (code === 200) { //otherwise 202 for pending/keepalive
135
138
  await signaler.deleteWebHookSignal(this.config.hook.topic, data);
136
139
  }
137
140
  } //else => already resolved
@@ -10,7 +10,7 @@ import {
10
10
  ActivityMetadata,
11
11
  SignalActivity } from '../../types/activity';
12
12
  import { JobState } from '../../types/job';
13
- import { MultiResponseFlags, RedisMulti } from '../../types/redis';
13
+ import { MultiResponseFlags } from '../../types/redis';
14
14
  import { StringScalarType } from '../../types/serializer';
15
15
  import { JobStatsInput } from '../../types/stats';
16
16
 
@@ -50,8 +50,11 @@ class Signal extends Activity {
50
50
  await this.setStatus(this.adjacencyList.length - 1, multi);
51
51
  const multiResponse = await multi.exec() as MultiResponseFlags;
52
52
 
53
- //signal to awaken all paused jobs that share the targeted job key
54
- await this.hookAll();
53
+ if (this.config.subtype === 'all') {
54
+ await this.hookAll();
55
+ } else {
56
+ await this.hookOne();
57
+ }
55
58
 
56
59
  //transition to adjacent activities
57
60
  const jobStatus = this.resolveStatus(multiResponse);
@@ -92,6 +95,17 @@ class Signal extends Activity {
92
95
  }
93
96
  }
94
97
 
98
+ /**
99
+ * The signal activity will hook one
100
+ */
101
+ async hookOne(): Promise<string> {
102
+ const topic = Pipe.resolve(this.config.topic, this.context);
103
+ const signalInputData = this.mapSignalData();
104
+ const status = Pipe.resolve(this.config.status, this.context);
105
+ const code = Pipe.resolve(this.config.code, this.context);
106
+ return await this.engine.hook(topic, signalInputData, status, code);
107
+ }
108
+
95
109
  /**
96
110
  * The signal activity will hook all paused jobs that share the same job key.
97
111
  */
@@ -1,15 +1,17 @@
1
1
  import { nanoid } from 'nanoid';
2
- import { APP_ID, APP_VERSION, DEFAULT_COEFFICIENT, SUBSCRIBES_TOPIC, getWorkflowYAML } from './factory';
2
+ import { APP_ID, APP_VERSION, DEFAULT_COEFFICIENT, getWorkflowYAML } from './factory';
3
3
  import { WorkflowHandleService } from './handle';
4
4
  import { HotMeshService as HotMesh } from '../hotmesh';
5
5
  import {
6
6
  ClientConfig,
7
7
  Connection,
8
+ HookOptions,
8
9
  WorkflowOptions,
9
10
  WorkflowSearchOptions} from '../../types/durable';
10
11
  import { JobState } from '../../types/job';
11
12
  import { KeyService, KeyType } from '../../modules/key';
12
13
  import { Search } from './search';
14
+ import { StreamStatus } from '../../types';
13
15
 
14
16
  export class ClientService {
15
17
 
@@ -21,14 +23,14 @@ export class ClientService {
21
23
  this.connection = config.connection;
22
24
  }
23
25
 
24
- getHotMeshClient = async (worflowTopic: string) => {
26
+ getHotMeshClient = async (worflowTopic: string, namespace?: string) => {
25
27
  //NOTE: every unique topic inits a new engine
26
28
  if (ClientService.instances.has(worflowTopic)) {
27
29
  return await ClientService.instances.get(worflowTopic);
28
30
  }
29
31
 
30
32
  const hotMeshClient = HotMesh.init({
31
- appId: APP_ID,
33
+ appId: namespace ?? APP_ID,
32
34
  engine: {
33
35
  redis: {
34
36
  class: this.connection.class,
@@ -40,14 +42,14 @@ export class ClientService {
40
42
 
41
43
  //since the YAML topic is dynamic, it MUST be manually created before use
42
44
  const store = (await hotMeshClient).engine.store;
43
- const params = { appId: APP_ID, topic: worflowTopic };
45
+ const params = { appId: namespace ?? APP_ID, topic: worflowTopic };
44
46
  const streamKey = store.mintKey(KeyType.STREAMS, params);
45
47
  try {
46
48
  await store.xgroup('CREATE', streamKey, 'WORKER', '$', 'MKSTREAM');
47
49
  } catch (err) {
48
50
  //ignore if already exists
49
51
  }
50
- await this.activateWorkflow(await hotMeshClient);
52
+ await this.activateWorkflow(await hotMeshClient, namespace ?? APP_ID);
51
53
  return hotMeshClient;
52
54
  }
53
55
 
@@ -60,7 +62,7 @@ export class ClientService {
60
62
  const store = hotMeshClient.engine.store;
61
63
  const schema: string[] = [];
62
64
  for (const [key, value] of Object.entries(search.schema)) {
63
- //prefix with a comma (avoids collisions with hotmesh reserved words)
65
+ //prefix with an underscore (avoids collisions with hotmesh reserved symbols)
64
66
  schema.push(`_${key}`);
65
67
  schema.push(value.type);
66
68
  if (value.sortable) {
@@ -94,7 +96,7 @@ export class ClientService {
94
96
  const spn = options.workflowSpan;
95
97
  //topic is concat of taskQueue and workflowName
96
98
  const workflowTopic = `${taskQueueName}-${workflowName}`;
97
- const hotMeshClient = await this.getHotMeshClient(workflowTopic);
99
+ const hotMeshClient = await this.getHotMeshClient(workflowTopic, options.namespace);
98
100
  this.configureSearchIndex(hotMeshClient, options.search)
99
101
  const payload = {
100
102
  arguments: [...options.args],
@@ -105,12 +107,13 @@ export class ClientService {
105
107
  }
106
108
  const context = { metadata: { trc, spn }, data: {}};
107
109
  const jobId = await hotMeshClient.pub(
108
- SUBSCRIBES_TOPIC,
110
+ `${options.namespace ?? APP_ID}.execute`,
109
111
  payload,
110
112
  context as JobState);
111
113
  if (jobId && options.search?.data) {
112
114
  //job successfully kicked off; there is default job data to persist
113
- const search = new Search(jobId, hotMeshClient);
115
+ const searchSessionId = `-search-0`;
116
+ const search = new Search(jobId, hotMeshClient, searchSessionId);
114
117
  for (const [key, value] of Object.entries(options.search.data)) {
115
118
  search.set(key, value);
116
119
  }
@@ -118,19 +121,41 @@ export class ClientService {
118
121
  return new WorkflowHandleService(hotMeshClient, workflowTopic, jobId);
119
122
  },
120
123
 
121
- signal: async (signalId: string, data: Record<any, any>): Promise<string> => {
122
- return await (await this.getHotMeshClient('durable.wfs.signal')).hook('durable.wfs.signal', { id: signalId, data });
124
+ /**
125
+ * send a message to a running workflow that is paused and awaiting the signal
126
+ */
127
+ signal: async (signalId: string, data: Record<any, any>, namespace?: string): Promise<string> => {
128
+ const topic = `${namespace ?? APP_ID}.wfs.signal`;
129
+ return await (await this.getHotMeshClient(topic, namespace)).hook(topic, { id: signalId, data });
123
130
  },
124
131
 
125
- getHandle: async (taskQueue: string, workflowName: string, workflowId: string): Promise<WorkflowHandleService> => {
132
+ /**
133
+ * send a message to spawn an parallel in-process thread of execution
134
+ * with the same job state as the main thread but bound to a different
135
+ * handler function. All job state will be journaled to the same hash
136
+ * as is used by the main thread.
137
+ */
138
+ hook: async (options: HookOptions): Promise<string> => {
139
+ const workflowTopic = `${options.taskQueue}-${options.workflowName}`;
140
+ const payload = {
141
+ arguments: [...options.args],
142
+ id: options.workflowId,
143
+ workflowTopic,
144
+ backoffCoefficient: options.config?.backoffCoefficient || DEFAULT_COEFFICIENT,
145
+ }
146
+ const hotMeshClient = await this.getHotMeshClient(workflowTopic, options.namespace);
147
+ return await hotMeshClient.hook(`${hotMeshClient.appId}.flow.signal`, payload, StreamStatus.PENDING, 202);
148
+ },
149
+
150
+ getHandle: async (taskQueue: string, workflowName: string, workflowId: string, namespace?: string): Promise<WorkflowHandleService> => {
126
151
  const workflowTopic = `${taskQueue}-${workflowName}`;
127
- const hotMeshClient = await this.getHotMeshClient(workflowTopic);
152
+ const hotMeshClient = await this.getHotMeshClient(workflowTopic, namespace);
128
153
  return new WorkflowHandleService(hotMeshClient, workflowTopic, workflowId);
129
154
  },
130
155
 
131
- search: async (taskQueue: string, workflowName: string, index: string, ...query: string[]): Promise<string[]> => {
156
+ search: async (taskQueue: string, workflowName: string, namespace: null | string, index: string, ...query: string[]): Promise<string[]> => {
132
157
  const workflowTopic = `${taskQueue}-${workflowName}`;
133
- const hotMeshClient = await this.getHotMeshClient(workflowTopic);
158
+ const hotMeshClient = await this.getHotMeshClient(workflowTopic, namespace);
134
159
  try {
135
160
  return await this.search(hotMeshClient, index, query);
136
161
  } catch (err) {