@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
@@ -11,7 +11,7 @@ class StoreSignaler {
11
11
  const rules = await this.store.getHookRules();
12
12
  return rules?.[topic]?.[0];
13
13
  }
14
- async registerWebHook(topic, context, multi) {
14
+ async registerWebHook(topic, context, dad, multi) {
15
15
  const hookRule = await this.getHookRule(topic);
16
16
  if (hookRule) {
17
17
  const mapExpression = hookRule.conditions.match[0].expected;
@@ -20,7 +20,8 @@ class StoreSignaler {
20
20
  const hook = {
21
21
  topic,
22
22
  resolved,
23
- jobId,
23
+ //hookSignalId is composed of `<dad>::<jid>`
24
+ jobId: `${dad}::${jobId}`,
24
25
  };
25
26
  await this.store.setHookSignal(hook, multi);
26
27
  return jobId;
@@ -36,18 +37,27 @@ class StoreSignaler {
36
37
  const context = { $self: { hook: { data } }, $hook: { data } };
37
38
  const mapExpression = hookRule.conditions.match[0].actual;
38
39
  const resolved = pipe_1.Pipe.resolve(mapExpression, context);
39
- const jobId = await this.store.getHookSignal(topic, resolved);
40
- return jobId;
40
+ const hookSignalId = await this.store.getHookSignal(topic, resolved);
41
+ if (!hookSignalId) {
42
+ //messages can be double-processed; not an issue; return undefined
43
+ //users can also provide a bogus topic; not an issue; return undefined
44
+ return undefined;
45
+ }
46
+ const [dad, jid] = hookSignalId.split('::');
47
+ //return [jid, aid, dad]
48
+ return [jid, hookRule.to, dad];
41
49
  }
42
50
  else {
43
- throw new Error('signaler.process:error: hook rule not found');
51
+ throw new Error('signal-not-found');
44
52
  }
45
53
  }
46
54
  async deleteWebHookSignal(topic, data) {
47
55
  const hookRule = await this.getHookRule(topic);
48
56
  if (hookRule) {
49
- //todo: use the rule to generate `resolved`
50
- const resolved = data.id;
57
+ //NOTE: both formats are supported: $self.hook.data OR $hook.data
58
+ const context = { $self: { hook: { data } }, $hook: { data } };
59
+ const mapExpression = hookRule.conditions.match[0].actual;
60
+ const resolved = pipe_1.Pipe.resolve(mapExpression, context);
51
61
  return await this.store.deleteHookSignal(topic, resolved);
52
62
  }
53
63
  else {
@@ -121,6 +121,7 @@ class StreamSignaler {
121
121
  output = await callback(input);
122
122
  }
123
123
  catch (error) {
124
+ console.error(error);
124
125
  this.logger.error(`stream-call-function-error`, { error });
125
126
  output = this.structureUnhandledError(input, error);
126
127
  }
@@ -68,7 +68,7 @@ class RedisStoreService extends index_1.StoreService {
68
68
  }
69
69
  catch (error) {
70
70
  const streamType = mkStream === 'MKSTREAM' ? 'with MKSTREAM' : 'without MKSTREAM';
71
- this.logger.warn(`x-group-error ${streamType} for key: ${key} and group: ${groupName}`, { error });
71
+ this.logger.info(`x-group-error ${streamType} for key: ${key} and group: ${groupName}`, { error });
72
72
  throw error;
73
73
  }
74
74
  }
@@ -422,6 +422,9 @@ class StoreService {
422
422
  }
423
423
  return [state, status];
424
424
  }
425
+ else {
426
+ throw new Error(`Job ${jobId} not found`);
427
+ }
425
428
  }
426
429
  async collate(jobId, activityId, amount, dIds, multi) {
427
430
  const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId: this.appId, jobId });
@@ -207,11 +207,17 @@ class TelemetryService {
207
207
  }
208
208
  static bindActivityTelemetryToState(state, config, metadata, context, leg) {
209
209
  if (config.type === 'trigger') {
210
+ //trigger activities run non-duplexed and only have a single leg (2)
210
211
  state[`${metadata.aid}/output/metadata/l1s`] = context['$self'].output.metadata.l1s;
211
212
  state[`${metadata.aid}/output/metadata/l2s`] = context['$self'].output.metadata.l2s;
212
213
  }
213
214
  else if (utils_1.polyfill.resolveActivityType(config.type) === 'hook' && leg === 1) {
214
- //activities run non-duplexed and only have a single leg
215
+ //hook activities run non-duplexed and only have a single leg (1)
216
+ state[`${metadata.aid}/output/metadata/l1s`] = context['$self'].output.metadata.l1s;
217
+ state[`${metadata.aid}/output/metadata/l2s`] = context['$self'].output.metadata.l1s;
218
+ }
219
+ else if (config.type === 'signal' && leg === 1) {
220
+ //signal activities run non-duplexed and only have a single leg (1)
215
221
  state[`${metadata.aid}/output/metadata/l1s`] = context['$self'].output.metadata.l1s;
216
222
  state[`${metadata.aid}/output/metadata/l2s`] = context['$self'].output.metadata.l1s;
217
223
  }
@@ -66,11 +66,13 @@ interface SignalActivity extends BaseActivity {
66
66
  type: 'signal';
67
67
  subtype: 'one' | 'all';
68
68
  topic: string;
69
- key_name: string;
70
- key_value: string;
71
- scrub: boolean;
69
+ key_name?: string;
70
+ key_value?: string;
71
+ scrub?: boolean;
72
72
  signal?: Record<string, any>;
73
73
  resolver?: Record<string, any>;
74
+ status?: string;
75
+ code?: number;
74
76
  }
75
77
  interface IterateActivity extends BaseActivity {
76
78
  type: 'iterate';
@@ -6,14 +6,16 @@ type WorkflowConfig = {
6
6
  initialInterval?: string;
7
7
  };
8
8
  type WorkflowSearchOptions = {
9
- index: string;
10
- prefix: string[];
11
- schema: Record<string, {
9
+ index?: string;
10
+ prefix?: string[];
11
+ schema?: Record<string, {
12
12
  type: 'TEXT' | 'NUMERIC' | 'TAG';
13
13
  sortable: boolean;
14
14
  }>;
15
+ data?: Record<string, string>;
15
16
  };
16
17
  type WorkflowOptions = {
18
+ namespace?: string;
17
19
  taskQueue: string;
18
20
  args: any[];
19
21
  workflowId: string;
@@ -24,6 +26,15 @@ type WorkflowOptions = {
24
26
  search?: WorkflowSearchOptions;
25
27
  config?: WorkflowConfig;
26
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
+ };
27
38
  type SignalOptions = {
28
39
  taskQueue: string;
29
40
  data: Record<string, any>;
@@ -59,8 +70,10 @@ type WorkerConfig = {
59
70
  taskQueue: string;
60
71
  workflow: Function;
61
72
  options?: WorkerOptions;
73
+ search?: WorkflowSearchOptions;
62
74
  };
63
75
  type WorkerOptions = {
76
+ logLevel?: string;
64
77
  maxSystemRetries?: number;
65
78
  backoffCoefficient?: number;
66
79
  };
@@ -82,4 +95,4 @@ type ActivityConfig = {
82
95
  maximumInterval: string;
83
96
  };
84
97
  };
85
- 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/modules/utils.ts CHANGED
@@ -9,6 +9,12 @@ export async function sleepFor(ms: number) {
9
9
  }
10
10
 
11
11
  export function identifyRedisType(redisInstance: any): 'redis' | 'ioredis' | null {
12
+ const prototype = Object.getPrototypeOf(redisInstance);
13
+ if ('defineCommand' in prototype || Object.keys(prototype).includes('multi')) {
14
+ return 'ioredis';
15
+ } else if (Object.keys(prototype).includes('Multi')) {
16
+ return 'redis';
17
+ }
12
18
  if (redisInstance.constructor) {
13
19
  if (redisInstance.constructor.name === 'Redis' || redisInstance.constructor.name === 'EventEmitter') {
14
20
  if ('hset' in redisInstance) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.0.18",
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);
@@ -1,4 +1,5 @@
1
- import { CollationError, GetStateError } from '../../modules/errors';
1
+ import { GetStateError } from '../../modules/errors';
2
+ import { Activity } from './activity';
2
3
  import { CollatorService } from '../collator';
3
4
  import { EngineService } from '../engine';
4
5
  import { Pipe } from '../pipe';
@@ -8,15 +9,14 @@ import {
8
9
  ActivityData,
9
10
  ActivityMetadata,
10
11
  ActivityType,
11
- HookActivity} from '../../types/activity';
12
+ HookActivity } from '../../types/activity';
13
+ import { HookRule } from '../../types/hook';
12
14
  import { JobState, JobStatus } from '../../types/job';
13
15
  import {
14
16
  MultiResponseFlags,
15
17
  RedisMulti } from '../../types/redis';
16
18
  import { StringScalarType } from '../../types/serializer';
17
- import { HookRule } from '../../types/hook';
18
- import { Activity } from './activity';
19
- import { StreamStatus } from '../../types';
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,55 +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
- /*
14
- Here is an example of how the methods in this file are used:
15
-
16
- ./client.ts
17
-
18
- import { Durable } from '@hotmeshio/hotmesh';
19
- import Redis from 'ioredis';
20
- import { example } from './workflows';
21
- import { nanoid } from 'nanoid';
22
-
23
- async function run() {
24
- const connection = await Durable.Connection.connect({
25
- class: Redis,
26
- options: {
27
- host: 'localhost',
28
- port: 6379,
29
- },
30
- });
31
-
32
- const client = new Durable.Client({
33
- connection,
34
- });
35
-
36
- const handle = await client.workflow.start({
37
- args: ['HotMesh'],
38
- taskQueue: 'hello-world',
39
- workflowName: 'example',
40
- workflowId: 'workflow-' + nanoid(),
41
- });
42
-
43
- console.log(`Started workflow ${handle.workflowId}`);
44
- console.log(await handle.result());
45
- }
46
-
47
- run().catch((err) => {
48
- console.error(err);
49
- process.exit(1);
50
- });
51
-
52
- */
13
+ import { Search } from './search';
14
+ import { StreamStatus } from '../../types';
53
15
 
54
16
  export class ClientService {
55
17
 
@@ -61,14 +23,14 @@ export class ClientService {
61
23
  this.connection = config.connection;
62
24
  }
63
25
 
64
- getHotMeshClient = async (worflowTopic: string) => {
26
+ getHotMeshClient = async (worflowTopic: string, namespace?: string) => {
65
27
  //NOTE: every unique topic inits a new engine
66
28
  if (ClientService.instances.has(worflowTopic)) {
67
29
  return await ClientService.instances.get(worflowTopic);
68
30
  }
69
31
 
70
32
  const hotMeshClient = HotMesh.init({
71
- appId: APP_ID,
33
+ appId: namespace ?? APP_ID,
72
34
  engine: {
73
35
  redis: {
74
36
  class: this.connection.class,
@@ -80,14 +42,14 @@ export class ClientService {
80
42
 
81
43
  //since the YAML topic is dynamic, it MUST be manually created before use
82
44
  const store = (await hotMeshClient).engine.store;
83
- const params = { appId: APP_ID, topic: worflowTopic };
45
+ const params = { appId: namespace ?? APP_ID, topic: worflowTopic };
84
46
  const streamKey = store.mintKey(KeyType.STREAMS, params);
85
47
  try {
86
48
  await store.xgroup('CREATE', streamKey, 'WORKER', '$', 'MKSTREAM');
87
49
  } catch (err) {
88
50
  //ignore if already exists
89
51
  }
90
- await this.activateWorkflow(await hotMeshClient);
52
+ await this.activateWorkflow(await hotMeshClient, namespace ?? APP_ID);
91
53
  return hotMeshClient;
92
54
  }
93
55
 
@@ -96,11 +58,11 @@ export class ClientService {
96
58
  * this method will configure the search index for the workflow.
97
59
  */
98
60
  configureSearchIndex = async (hotMeshClient: HotMesh, search?: WorkflowSearchOptions): Promise<void> => {
99
- if (search) {
61
+ if (search?.schema) {
100
62
  const store = hotMeshClient.engine.store;
101
63
  const schema: string[] = [];
102
64
  for (const [key, value] of Object.entries(search.schema)) {
103
- //prefix with a comma (avoids collisions with hotmesh reserved words)
65
+ //prefix with an underscore (avoids collisions with hotmesh reserved symbols)
104
66
  schema.push(`_${key}`);
105
67
  schema.push(value.type);
106
68
  if (value.sortable) {
@@ -134,7 +96,7 @@ export class ClientService {
134
96
  const spn = options.workflowSpan;
135
97
  //topic is concat of taskQueue and workflowName
136
98
  const workflowTopic = `${taskQueueName}-${workflowName}`;
137
- const hotMeshClient = await this.getHotMeshClient(workflowTopic);
99
+ const hotMeshClient = await this.getHotMeshClient(workflowTopic, options.namespace);
138
100
  this.configureSearchIndex(hotMeshClient, options.search)
139
101
  const payload = {
140
102
  arguments: [...options.args],
@@ -145,25 +107,55 @@ export class ClientService {
145
107
  }
146
108
  const context = { metadata: { trc, spn }, data: {}};
147
109
  const jobId = await hotMeshClient.pub(
148
- SUBSCRIBES_TOPIC,
110
+ `${options.namespace ?? APP_ID}.execute`,
149
111
  payload,
150
112
  context as JobState);
113
+ if (jobId && options.search?.data) {
114
+ //job successfully kicked off; there is default job data to persist
115
+ const searchSessionId = `-search-0`;
116
+ const search = new Search(jobId, hotMeshClient, searchSessionId);
117
+ for (const [key, value] of Object.entries(options.search.data)) {
118
+ search.set(key, value);
119
+ }
120
+ }
151
121
  return new WorkflowHandleService(hotMeshClient, workflowTopic, jobId);
152
122
  },
153
123
 
154
- signal: async (signalId: string, data: Record<any, any>): Promise<string> => {
155
- 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 });
130
+ },
131
+
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);
156
148
  },
157
149
 
158
- getHandle: async (taskQueue: string, workflowName: string, workflowId: string): Promise<WorkflowHandleService> => {
150
+ getHandle: async (taskQueue: string, workflowName: string, workflowId: string, namespace?: string): Promise<WorkflowHandleService> => {
159
151
  const workflowTopic = `${taskQueue}-${workflowName}`;
160
- const hotMeshClient = await this.getHotMeshClient(workflowTopic);
152
+ const hotMeshClient = await this.getHotMeshClient(workflowTopic, namespace);
161
153
  return new WorkflowHandleService(hotMeshClient, workflowTopic, workflowId);
162
154
  },
163
155
 
164
- 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[]> => {
165
157
  const workflowTopic = `${taskQueue}-${workflowName}`;
166
- const hotMeshClient = await this.getHotMeshClient(workflowTopic);
158
+ const hotMeshClient = await this.getHotMeshClient(workflowTopic, namespace);
167
159
  try {
168
160
  return await this.search(hotMeshClient, index, query);
169
161
  } catch (err) {