@hotmeshio/hotmesh 0.0.19 → 0.0.21
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 +36 -10
- package/build/services/durable/worker.d.ts +7 -9
- package/build/services/durable/worker.js +29 -23
- package/build/services/durable/workflow.d.ts +23 -2
- package/build/services/durable/workflow.js +143 -37
- 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 +13 -2
- 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 +38 -10
- package/services/durable/worker.ts +30 -24
- package/services/durable/workflow.ts +158 -40
- 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 +14 -1
- 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,31 +1,43 @@
|
|
|
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
|
|
|
15
25
|
/**
|
|
16
|
-
* Spawn a child workflow. await the result.
|
|
26
|
+
* Spawn a child workflow. await and return the result.
|
|
17
27
|
*/
|
|
18
28
|
static async executeChild<T>(options: WorkflowOptions): Promise<T> {
|
|
19
29
|
const store = asyncLocalStorage.getStore();
|
|
20
|
-
|
|
21
|
-
throw new Error('durable-store-not-found');
|
|
22
|
-
}
|
|
30
|
+
const namespace = store.get('namespace');
|
|
23
31
|
const workflowId = store.get('workflowId');
|
|
32
|
+
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
24
33
|
const workflowTrace = store.get('workflowTrace');
|
|
25
34
|
const workflowSpan = store.get('workflowSpan');
|
|
26
35
|
const COUNTER = store.get('counter');
|
|
27
36
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
28
|
-
|
|
37
|
+
//this is risky but MUST be allowed. Users MAY set the workflowId,
|
|
38
|
+
//but if there is a naming collision, the data from the target entity will be used
|
|
39
|
+
//as there is know way of knowing if the item was generated via a prior run of the workflow
|
|
40
|
+
const childJobId = options.workflowId ?? `${workflowId}-$${options.workflowName}${workflowDimension}-${execIndex}`;
|
|
29
41
|
const parentWorkflowId = `${workflowId}-f`;
|
|
30
42
|
|
|
31
43
|
const client = new Client({
|
|
@@ -35,24 +47,65 @@ export class WorkflowService {
|
|
|
35
47
|
let handle = await client.workflow.getHandle(
|
|
36
48
|
options.taskQueue,
|
|
37
49
|
options.workflowName,
|
|
38
|
-
childJobId
|
|
50
|
+
childJobId,
|
|
51
|
+
namespace,
|
|
39
52
|
);
|
|
40
53
|
|
|
41
54
|
try {
|
|
42
|
-
return await handle.result() as T;
|
|
55
|
+
return await handle.result(true) as T;
|
|
43
56
|
} catch (error) {
|
|
44
57
|
handle = await client.workflow.start({
|
|
45
58
|
...options,
|
|
59
|
+
namespace,
|
|
46
60
|
workflowId: childJobId,
|
|
47
61
|
parentWorkflowId,
|
|
48
62
|
workflowTrace,
|
|
49
63
|
workflowSpan,
|
|
50
64
|
});
|
|
65
|
+
//todo: options.startToCloseTimeout
|
|
51
66
|
const result = await handle.result();
|
|
52
67
|
return result as T;
|
|
53
68
|
}
|
|
54
69
|
}
|
|
55
70
|
|
|
71
|
+
/**
|
|
72
|
+
* spawn a child workflow. return the childJobId.
|
|
73
|
+
*/
|
|
74
|
+
static async startChild<T>(options: WorkflowOptions): Promise<string> {
|
|
75
|
+
const store = asyncLocalStorage.getStore();
|
|
76
|
+
const namespace = store.get('namespace');
|
|
77
|
+
const workflowId = store.get('workflowId');
|
|
78
|
+
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
79
|
+
const workflowTrace = store.get('workflowTrace');
|
|
80
|
+
const workflowSpan = store.get('workflowSpan');
|
|
81
|
+
const COUNTER = store.get('counter');
|
|
82
|
+
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
83
|
+
const childJobId = options.workflowId ?? `${workflowId}-$${options.workflowName}${workflowDimension}-${execIndex}`;
|
|
84
|
+
const parentWorkflowId = `${workflowId}-f`;
|
|
85
|
+
const workflowTopic = `${options.taskQueue}-${options.workflowName}`;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
//get the status; if there is no error, return childJobId (what was spawned)
|
|
89
|
+
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
90
|
+
await hotMeshClient.getStatus(childJobId);
|
|
91
|
+
return childJobId;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const client = new Client({
|
|
94
|
+
connection: await Connection.connect(WorkerService.connection),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await client.workflow.start({
|
|
98
|
+
...options,
|
|
99
|
+
namespace,
|
|
100
|
+
workflowId: childJobId,
|
|
101
|
+
parentWorkflowId,
|
|
102
|
+
workflowTrace,
|
|
103
|
+
workflowSpan,
|
|
104
|
+
});
|
|
105
|
+
return childJobId;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
56
109
|
static proxyActivities<ACT>(options?: ActivityConfig): ProxyType<ACT> {
|
|
57
110
|
if (options.activities) {
|
|
58
111
|
WorkerService.registerActivities(options.activities)
|
|
@@ -71,51 +124,117 @@ export class WorkflowService {
|
|
|
71
124
|
|
|
72
125
|
static async search(): Promise<Search> {
|
|
73
126
|
const store = asyncLocalStorage.getStore();
|
|
74
|
-
if (!store) {
|
|
75
|
-
throw new Error('durable-store-not-found');
|
|
76
|
-
}
|
|
77
127
|
const workflowId = store.get('workflowId');
|
|
128
|
+
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
78
129
|
const workflowTopic = store.get('workflowTopic');
|
|
130
|
+
const namespace = store.get('namespace');
|
|
131
|
+
const COUNTER = store.get('counter');
|
|
132
|
+
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
133
|
+
//this ID is used as a item key with a hash (dash prefix ensures no collision)
|
|
134
|
+
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
135
|
+
const searchSessionId = `-search${workflowDimension}-${execIndex}`;
|
|
136
|
+
return new Search(workflowId, hotMeshClient, searchSessionId);
|
|
137
|
+
}
|
|
79
138
|
|
|
80
|
-
|
|
81
|
-
|
|
139
|
+
/**
|
|
140
|
+
* those methods that may only be called once must be protected by flagging
|
|
141
|
+
* their execution with a unique key (the key is stored in the workflow state)
|
|
142
|
+
*/
|
|
143
|
+
static async isSideEffectAllowed(hotMeshClient: HotMesh, prefix:string): Promise<boolean> {
|
|
144
|
+
const store = asyncLocalStorage.getStore();
|
|
145
|
+
const workflowId = store.get('workflowId');
|
|
146
|
+
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
147
|
+
const COUNTER = store.get('counter');
|
|
148
|
+
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
149
|
+
//this ID is used as a item key with a hash (dash prefix ensures no collision)
|
|
150
|
+
const sessionId = `-${prefix}${workflowDimension}-${execIndex}-`;
|
|
151
|
+
//this ID is used as a item key with a hash (dash prefix ensures no collision)
|
|
152
|
+
const keyParams = {
|
|
153
|
+
appId: hotMeshClient.appId,
|
|
154
|
+
jobId: ''
|
|
155
|
+
}
|
|
156
|
+
const hotMeshPrefix = KeyService.mintKey(hotMeshClient.namespace, KeyType.JOB_STATE, keyParams);
|
|
157
|
+
const workflowGuid = `${hotMeshPrefix}${workflowId}`;
|
|
158
|
+
const guidValue = Number(await hotMeshClient.engine.store.exec('HINCRBYFLOAT', workflowGuid, sessionId, '1') as string);
|
|
159
|
+
return guidValue === 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* send signal data into any other paused thread (which is paused and
|
|
164
|
+
* awaiting the signal) from within a hook-thread or the main-thread
|
|
165
|
+
*/
|
|
166
|
+
static async signal(signalId: string, data: Record<any, any>): Promise<string> {
|
|
167
|
+
const store = asyncLocalStorage.getStore();
|
|
168
|
+
const namespace = store.get('namespace');
|
|
169
|
+
const hotMeshClient = await WorkerService.getHotMesh(`${namespace}.wfs.signal`, { namespace });
|
|
170
|
+
if (await WorkflowService.isSideEffectAllowed(hotMeshClient, 'signal')) {
|
|
171
|
+
return await hotMeshClient.hook(`${namespace}.wfs.signal`, { id: signalId, data });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* spawn a hook from either the main thread or a hook thread with
|
|
177
|
+
* the provided options; worflowId/TaskQueue/Name are optional and will
|
|
178
|
+
* default to the current workflowId/WorkflowTopic if not provided
|
|
179
|
+
*/
|
|
180
|
+
static async hook(options: HookOptions): Promise<string> {
|
|
181
|
+
const store = asyncLocalStorage.getStore();
|
|
182
|
+
const namespace = store.get('namespace');
|
|
183
|
+
const hotMeshClient = await WorkerService.getHotMesh(`${namespace}.flow.signal`, { namespace });
|
|
184
|
+
if (await WorkflowService.isSideEffectAllowed(hotMeshClient, 'hook')) {
|
|
185
|
+
const store = asyncLocalStorage.getStore();
|
|
186
|
+
let workflowId: string;
|
|
187
|
+
let workflowTopic: string;
|
|
188
|
+
if (options.workflowId && options.taskQueue && options.workflowName) {
|
|
189
|
+
workflowId = options.workflowId;
|
|
190
|
+
workflowTopic = `${options.taskQueue}-${options.workflowName}`;
|
|
191
|
+
} else {
|
|
192
|
+
workflowId = store.get('workflowId');
|
|
193
|
+
workflowTopic = store.get('workflowTopic');
|
|
194
|
+
}
|
|
195
|
+
const payload = {
|
|
196
|
+
arguments: [...options.args],
|
|
197
|
+
id: workflowId,
|
|
198
|
+
workflowTopic,
|
|
199
|
+
backoffCoefficient: options.config?.backoffCoefficient || DEFAULT_COEFFICIENT,
|
|
200
|
+
}
|
|
201
|
+
return await hotMeshClient.hook(`${namespace}.flow.signal`, payload, StreamStatus.PENDING, 202);
|
|
202
|
+
}
|
|
82
203
|
}
|
|
83
204
|
|
|
84
205
|
static async sleep(duration: string): Promise<number> {
|
|
85
206
|
const seconds = ms(duration) / 1000;
|
|
86
207
|
|
|
87
208
|
const store = asyncLocalStorage.getStore();
|
|
88
|
-
if (!store) {
|
|
89
|
-
throw new Error('durable-store-not-found');
|
|
90
|
-
}
|
|
91
209
|
const COUNTER = store.get('counter');
|
|
92
210
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
93
211
|
const workflowId = store.get('workflowId');
|
|
94
212
|
const workflowTopic = store.get('workflowTopic');
|
|
95
|
-
const
|
|
213
|
+
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
214
|
+
const namespace = store.get('namespace');
|
|
215
|
+
const sleepJobId = `${workflowId}-$sleep${workflowDimension}-${execIndex}`;
|
|
96
216
|
|
|
97
217
|
try {
|
|
98
|
-
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic);
|
|
99
|
-
await hotMeshClient.getState(
|
|
218
|
+
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
219
|
+
await hotMeshClient.getState(`${hotMeshClient.appId}.sleep.execute`, sleepJobId);
|
|
100
220
|
//if no error is thrown, we've already slept, return the delay
|
|
101
221
|
return seconds;
|
|
102
222
|
} catch (e) {
|
|
103
223
|
//if an error, the sleep job was not found...rethrow error; sleep job
|
|
104
224
|
// will be automatically created according to the DAG rules (they
|
|
105
225
|
// spawn a new sleep job if error code 595 is thrown by the worker)
|
|
106
|
-
throw new DurableSleepError(workflowId, seconds, execIndex);
|
|
226
|
+
throw new DurableSleepError(workflowId, seconds, execIndex, workflowDimension);
|
|
107
227
|
}
|
|
108
228
|
}
|
|
109
229
|
|
|
110
230
|
static async waitForSignal(signals: string[], options?: Record<string, string>): Promise<Record<any, any>[]> {
|
|
111
231
|
const store = asyncLocalStorage.getStore();
|
|
112
|
-
if (!store) {
|
|
113
|
-
throw new Error('durable-store-not-found');
|
|
114
|
-
}
|
|
115
232
|
const COUNTER = store.get('counter');
|
|
116
233
|
const workflowId = store.get('workflowId');
|
|
117
234
|
const workflowTopic = store.get('workflowTopic');
|
|
118
|
-
const
|
|
235
|
+
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
236
|
+
const namespace = store.get('namespace');
|
|
237
|
+
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
119
238
|
|
|
120
239
|
//iterate the list of signals and check for done
|
|
121
240
|
let allAreComplete = true;
|
|
@@ -123,10 +242,10 @@ export class WorkflowService {
|
|
|
123
242
|
const signalResults: any[] = [];
|
|
124
243
|
for (const signal of signals) {
|
|
125
244
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
126
|
-
const wfsJobId = `${workflowId}-$wfs-${execIndex}`;
|
|
245
|
+
const wfsJobId = `${workflowId}-$wfs${workflowDimension}-${execIndex}`;
|
|
127
246
|
try {
|
|
128
247
|
if (allAreComplete) {
|
|
129
|
-
const state = await hotMeshClient.getState(
|
|
248
|
+
const state = await hotMeshClient.getState(`${hotMeshClient.appId}.wfs.execute`, wfsJobId);
|
|
130
249
|
if (state.data?.signalData) {
|
|
131
250
|
//user data is nested to isolate from the signal id; unpackage it
|
|
132
251
|
const signalData = state.data.signalData as { id: string, data: Record<any, any> };
|
|
@@ -160,23 +279,22 @@ export class WorkflowService {
|
|
|
160
279
|
static wrapActivity<T>(activityName: string, options?: ActivityConfig): T {
|
|
161
280
|
return async function() {
|
|
162
281
|
const store = asyncLocalStorage.getStore();
|
|
163
|
-
if (!store) {
|
|
164
|
-
throw new Error('durable-store-not-found');
|
|
165
|
-
}
|
|
166
282
|
const COUNTER = store.get('counter');
|
|
167
283
|
//increment by state (not value) to avoid race conditions
|
|
168
284
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
169
285
|
const workflowId = store.get('workflowId');
|
|
286
|
+
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
170
287
|
const workflowTopic = store.get('workflowTopic');
|
|
171
288
|
const trc = store.get('workflowTrace');
|
|
172
289
|
const spn = store.get('workflowSpan');
|
|
290
|
+
const namespace = store.get('namespace');
|
|
173
291
|
const activityTopic = `${workflowTopic}-activity`;
|
|
174
|
-
const activityJobId = `${workflowId}-$${activityName}-${execIndex}`;
|
|
292
|
+
const activityJobId = `${workflowId}-$${activityName}${workflowDimension}-${execIndex}`;
|
|
175
293
|
|
|
176
294
|
let activityState: JobOutput
|
|
177
295
|
try {
|
|
178
|
-
const hotMeshClient = await WorkerService.getHotMesh(activityTopic);
|
|
179
|
-
activityState = await hotMeshClient.getState(
|
|
296
|
+
const hotMeshClient = await WorkerService.getHotMesh(activityTopic, { namespace });
|
|
297
|
+
activityState = await hotMeshClient.getState(`${hotMeshClient.appId}.activity.execute`, activityJobId);
|
|
180
298
|
if (activityState.metadata.err) {
|
|
181
299
|
await hotMeshClient.scrub(activityJobId);
|
|
182
300
|
throw new Error(activityState.metadata.err);
|
|
@@ -185,9 +303,9 @@ export class WorkflowService {
|
|
|
185
303
|
}
|
|
186
304
|
//one time subscription
|
|
187
305
|
return await new Promise((resolve, reject) => {
|
|
188
|
-
hotMeshClient.sub(`${
|
|
306
|
+
hotMeshClient.sub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`, async (topic, message) => {
|
|
189
307
|
const response = message.data?.response;
|
|
190
|
-
hotMeshClient.unsub(`${
|
|
308
|
+
hotMeshClient.unsub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`);
|
|
191
309
|
// Resolve the Promise when the callback is triggered with a message
|
|
192
310
|
resolve(response);
|
|
193
311
|
});
|
|
@@ -204,9 +322,9 @@ export class WorkflowService {
|
|
|
204
322
|
activityName,
|
|
205
323
|
};
|
|
206
324
|
//start the job
|
|
207
|
-
const hotMeshClient = await WorkerService.getHotMesh(activityTopic);
|
|
325
|
+
const hotMeshClient = await WorkerService.getHotMesh(activityTopic, { namespace });
|
|
208
326
|
const context = { metadata: { trc, spn }, data: {}};
|
|
209
|
-
const jobOutput = await hotMeshClient.pubsub(
|
|
327
|
+
const jobOutput = await hotMeshClient.pubsub(`${hotMeshClient.appId}.activity.execute`, payload, context as JobState, duration);
|
|
210
328
|
return jobOutput.data.response as T;
|
|
211
329
|
}
|
|
212
330
|
} 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 {
|