@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.
- package/README.md +4 -4
- package/build/modules/errors.d.ts +2 -1
- package/build/modules/errors.js +2 -1
- package/build/package.json +2 -1
- package/build/services/activities/activity.d.ts +2 -2
- package/build/services/activities/activity.js +10 -8
- package/build/services/activities/hook.d.ts +2 -1
- package/build/services/activities/hook.js +12 -9
- package/build/services/activities/signal.d.ts +4 -0
- package/build/services/activities/signal.js +16 -2
- package/build/services/durable/client.d.ts +15 -5
- package/build/services/durable/client.js +37 -14
- package/build/services/durable/factory.d.ts +2 -16
- package/build/services/durable/factory.js +276 -46
- package/build/services/durable/handle.d.ts +1 -1
- package/build/services/durable/handle.js +18 -5
- package/build/services/durable/search.d.ts +8 -1
- package/build/services/durable/search.js +34 -7
- package/build/services/durable/worker.d.ts +7 -9
- package/build/services/durable/worker.js +29 -23
- package/build/services/durable/workflow.d.ts +18 -1
- package/build/services/durable/workflow.js +99 -35
- package/build/services/engine/index.d.ts +2 -2
- package/build/services/engine/index.js +7 -12
- package/build/services/hotmesh/index.d.ts +2 -2
- package/build/services/hotmesh/index.js +2 -2
- package/build/services/signaler/store.d.ts +2 -2
- package/build/services/signaler/store.js +17 -7
- package/build/services/signaler/stream.js +1 -0
- package/build/services/store/clients/redis.js +1 -1
- package/build/services/store/index.js +3 -0
- package/build/services/telemetry/index.js +7 -1
- package/build/types/activity.d.ts +5 -3
- package/build/types/durable.d.ts +12 -1
- package/build/types/hook.d.ts +0 -1
- package/build/types/index.d.ts +1 -1
- package/modules/errors.ts +4 -2
- package/package.json +2 -1
- package/services/activities/activity.ts +10 -8
- package/services/activities/hook.ts +13 -10
- package/services/activities/signal.ts +17 -3
- package/services/durable/client.ts +40 -15
- package/services/durable/factory.ts +274 -46
- package/services/durable/handle.ts +18 -5
- package/services/durable/search.ts +36 -7
- package/services/durable/worker.ts +30 -24
- package/services/durable/workflow.ts +111 -38
- package/services/engine/index.ts +8 -12
- package/services/hotmesh/index.ts +3 -3
- package/services/signaler/store.ts +18 -8
- package/services/signaler/stream.ts +1 -0
- package/services/store/clients/redis.ts +1 -1
- package/services/store/index.ts +2 -0
- package/services/telemetry/index.ts +6 -1
- package/types/activity.ts +10 -8
- package/types/durable.ts +13 -0
- package/types/hook.ts +0 -1
- 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
|
|
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 (
|
|
35
|
-
if (WorkerService.instances.has(
|
|
36
|
-
return await WorkerService.instances.get(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(`${
|
|
261
|
+
hotMeshClient.sub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`, async (topic, message) => {
|
|
189
262
|
const response = message.data?.response;
|
|
190
|
-
hotMeshClient.unsub(`${
|
|
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(
|
|
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;
|
package/services/engine/index.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
169
|
-
return await this.engine?.hook(topic, data,
|
|
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
|
-
|
|
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
|
|
48
|
-
|
|
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('
|
|
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
|
-
//
|
|
58
|
-
const
|
|
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');
|
|
@@ -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.
|
|
89
|
+
this.logger.info(`x-group-error ${streamType} for key: ${key} and group: ${groupName}`, { error });
|
|
90
90
|
throw error;
|
|
91
91
|
}
|
|
92
92
|
}
|
package/services/store/index.ts
CHANGED
|
@@ -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';
|
|
74
|
-
subtype: 'one' | 'all';
|
|
75
|
-
topic: string;
|
|
76
|
-
key_name
|
|
77
|
-
key_value
|
|
78
|
-
scrub
|
|
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,
|