@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.
- package/README.md +4 -4
- package/build/modules/errors.d.ts +2 -1
- package/build/modules/errors.js +2 -1
- package/build/modules/utils.js +7 -0
- 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 +4 -3
- package/build/services/activities/hook.js +15 -12
- 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 +45 -54
- 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 +10 -7
- package/build/services/durable/worker.js +59 -49
- package/build/services/durable/workflow.d.ts +20 -2
- package/build/services/durable/workflow.js +97 -84
- 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 +17 -4
- package/build/types/hook.d.ts +0 -1
- package/build/types/index.d.ts +1 -1
- package/modules/errors.ts +4 -2
- package/modules/utils.ts +6 -0
- package/package.json +2 -1
- package/services/activities/activity.ts +10 -8
- package/services/activities/hook.ts +17 -14
- package/services/activities/signal.ts +17 -3
- package/services/durable/client.ts +48 -56
- 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 +61 -51
- package/services/durable/workflow.ts +110 -84
- 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 +18 -3
- 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';
|
|
@@ -15,44 +16,14 @@ import {
|
|
|
15
16
|
Registry,
|
|
16
17
|
WorkerConfig,
|
|
17
18
|
WorkerOptions,
|
|
18
|
-
WorkflowDataType
|
|
19
|
+
WorkflowDataType,
|
|
20
|
+
WorkflowSearchOptions} from '../../types/durable';
|
|
19
21
|
import { RedisClass, RedisOptions } from '../../types/redis';
|
|
20
22
|
import {
|
|
21
23
|
StreamData,
|
|
22
24
|
StreamDataResponse,
|
|
23
25
|
StreamStatus } from '../../types/stream';
|
|
24
26
|
|
|
25
|
-
/*
|
|
26
|
-
Here is an example of how the methods in this file are used:
|
|
27
|
-
|
|
28
|
-
./worker.ts
|
|
29
|
-
|
|
30
|
-
import { Durable } from '@hotmeshio/hotmesh';
|
|
31
|
-
import Redis from 'ioredis'; //OR `import * as Redis from 'redis';`
|
|
32
|
-
|
|
33
|
-
import * as workflows from './workflows';
|
|
34
|
-
|
|
35
|
-
async function run() {
|
|
36
|
-
const worker = await Durable.Worker.create({
|
|
37
|
-
connection: {
|
|
38
|
-
class: Redis,
|
|
39
|
-
options: {
|
|
40
|
-
host: 'localhost',
|
|
41
|
-
port: 6379,
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
taskQueue: 'hello-world',
|
|
45
|
-
workflow: workflows.example,
|
|
46
|
-
});
|
|
47
|
-
await worker.run();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
run().catch((err) => {
|
|
51
|
-
console.error(err);
|
|
52
|
-
process.exit(1);
|
|
53
|
-
});
|
|
54
|
-
*/
|
|
55
|
-
|
|
56
27
|
export class WorkerService {
|
|
57
28
|
static activityRegistry: Registry = {}; //user's activities
|
|
58
29
|
static connection: Connection;
|
|
@@ -60,25 +31,26 @@ export class WorkerService {
|
|
|
60
31
|
workflowRunner: HotMesh;
|
|
61
32
|
activityRunner: HotMesh;
|
|
62
33
|
|
|
63
|
-
static getHotMesh = async (
|
|
64
|
-
if (WorkerService.instances.has(
|
|
65
|
-
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);
|
|
66
37
|
}
|
|
67
38
|
const hotMeshClient = HotMesh.init({
|
|
68
|
-
|
|
39
|
+
logLevel: options?.logLevel as 'debug' ?? 'info',
|
|
40
|
+
appId: config.namespace ?? APP_ID,
|
|
69
41
|
engine: { redis: { ...WorkerService.connection } }
|
|
70
42
|
});
|
|
71
|
-
WorkerService.instances.set(
|
|
43
|
+
WorkerService.instances.set(workflowTopic, hotMeshClient);
|
|
72
44
|
await WorkerService.activateWorkflow(await hotMeshClient);
|
|
73
45
|
return hotMeshClient;
|
|
74
46
|
}
|
|
75
47
|
|
|
76
48
|
static async activateWorkflow(hotMesh: HotMesh) {
|
|
77
|
-
const app = await hotMesh.engine.store.getApp(
|
|
49
|
+
const app = await hotMesh.engine.store.getApp(hotMesh.engine.appId);
|
|
78
50
|
const appVersion = app?.version;
|
|
79
51
|
if(!appVersion) {
|
|
80
52
|
try {
|
|
81
|
-
await hotMesh.deploy(getWorkflowYAML(
|
|
53
|
+
await hotMesh.deploy(getWorkflowYAML(hotMesh.engine.appId, APP_VERSION));
|
|
82
54
|
await hotMesh.activate(APP_VERSION);
|
|
83
55
|
} catch (err) {
|
|
84
56
|
hotMesh.engine.logger.error('durable-worker-deploy-activate-err', err);
|
|
@@ -94,11 +66,6 @@ export class WorkerService {
|
|
|
94
66
|
}
|
|
95
67
|
}
|
|
96
68
|
|
|
97
|
-
/**
|
|
98
|
-
* NOTE: Because the worker imports the workflows dynamically AFTER
|
|
99
|
-
* the activities are loaded, there will be items in the registry,
|
|
100
|
-
* allowing proxyActivities to succeed.
|
|
101
|
-
*/
|
|
102
69
|
static registerActivities<ACT>(activities: ACT): Registry {
|
|
103
70
|
if (typeof activities === 'function' && typeof WorkerService.activityRegistry[activities.name] !== 'function') {
|
|
104
71
|
WorkerService.activityRegistry[activities.name] = activities as Function;
|
|
@@ -112,8 +79,40 @@ export class WorkerService {
|
|
|
112
79
|
return WorkerService.activityRegistry;
|
|
113
80
|
}
|
|
114
81
|
|
|
82
|
+
/**
|
|
83
|
+
* For those deployments with a redis stack backend (with the FT module),
|
|
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.
|
|
88
|
+
*/
|
|
89
|
+
static async configureSearchIndex(hotMeshClient: HotMesh, search?: WorkflowSearchOptions): Promise<void> {
|
|
90
|
+
if (search?.schema) {
|
|
91
|
+
const store = hotMeshClient.engine.store;
|
|
92
|
+
const schema: string[] = [];
|
|
93
|
+
for (const [key, value] of Object.entries(search.schema)) {
|
|
94
|
+
//prefix with a comma (avoids collisions with hotmesh reserved words)
|
|
95
|
+
schema.push(`_${key}`);
|
|
96
|
+
schema.push(value.type);
|
|
97
|
+
if (value.sortable) {
|
|
98
|
+
schema.push('SORTABLE');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const keyParams = {
|
|
103
|
+
appId: hotMeshClient.appId,
|
|
104
|
+
jobId: ''
|
|
105
|
+
}
|
|
106
|
+
const hotMeshPrefix = KeyService.mintKey(hotMeshClient.namespace, KeyType.JOB_STATE, keyParams);
|
|
107
|
+
const prefixes = search.prefix.map((prefix) => `${hotMeshPrefix}${prefix}`);
|
|
108
|
+
await store.exec('FT.CREATE', `${search.index}`, 'ON', 'HASH', 'PREFIX', prefixes.length, ...prefixes, 'SCHEMA', ...schema);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
hotMeshClient.engine.logger.info('durable-client-search-err', { err });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
115
|
static async create(config: WorkerConfig) {
|
|
116
|
-
//always call `registerActivities` before `import`
|
|
117
116
|
WorkerService.connection = config.connection;
|
|
118
117
|
const workflow = config.workflow;
|
|
119
118
|
const [workflowFunctionName, workflowFunction] = WorkerService.resolveWorkflowTarget(workflow);
|
|
@@ -125,6 +124,7 @@ export class WorkerService {
|
|
|
125
124
|
const worker = new WorkerService();
|
|
126
125
|
worker.activityRunner = await worker.initActivityWorker(config, activityTopic);
|
|
127
126
|
worker.workflowRunner = await worker.initWorkflowWorker(config, workflowTopic, workflowFunction);
|
|
127
|
+
WorkerService.configureSearchIndex(worker.workflowRunner, config.search)
|
|
128
128
|
await WorkerService.activateWorkflow(worker.workflowRunner);
|
|
129
129
|
return worker;
|
|
130
130
|
}
|
|
@@ -151,7 +151,8 @@ export class WorkerService {
|
|
|
151
151
|
options: config.connection.options as RedisOptions
|
|
152
152
|
};
|
|
153
153
|
const hotMeshWorker = await HotMesh.init({
|
|
154
|
-
|
|
154
|
+
logLevel: config.options?.logLevel as 'debug' ?? 'info',
|
|
155
|
+
appId: config.namespace ?? APP_ID,
|
|
155
156
|
engine: { redis: redisConfig },
|
|
156
157
|
workers: [
|
|
157
158
|
{ topic: activityTopic,
|
|
@@ -201,12 +202,13 @@ export class WorkerService {
|
|
|
201
202
|
options: config.connection.options as RedisOptions
|
|
202
203
|
};
|
|
203
204
|
const hotMeshWorker = await HotMesh.init({
|
|
204
|
-
|
|
205
|
+
logLevel: config.options?.logLevel as 'debug' ?? 'info',
|
|
206
|
+
appId: config.namespace ?? APP_ID,
|
|
205
207
|
engine: { redis: redisConfig },
|
|
206
208
|
workers: [{
|
|
207
209
|
topic: workflowTopic,
|
|
208
210
|
redis: redisConfig,
|
|
209
|
-
callback: this.wrapWorkflowFunction(workflowFunction, workflowTopic).bind(this)
|
|
211
|
+
callback: this.wrapWorkflowFunction(workflowFunction, workflowTopic, config).bind(this)
|
|
210
212
|
}]
|
|
211
213
|
});
|
|
212
214
|
WorkerService.instances.set(workflowTopic, hotMeshWorker);
|
|
@@ -222,15 +224,22 @@ export class WorkerService {
|
|
|
222
224
|
},
|
|
223
225
|
};
|
|
224
226
|
|
|
225
|
-
wrapWorkflowFunction(workflowFunction: Function, workflowTopic: string): Function {
|
|
227
|
+
wrapWorkflowFunction(workflowFunction: Function, workflowTopic: string, config: WorkerConfig): Function {
|
|
226
228
|
return async (data: StreamData): Promise<StreamDataResponse> => {
|
|
227
229
|
const counter = { counter: 0 };
|
|
228
230
|
try {
|
|
229
231
|
//incoming data payload has arguments and workflowId
|
|
230
232
|
const workflowInput = data.data as unknown as WorkflowDataType;
|
|
231
233
|
const context = new Map();
|
|
234
|
+
context.set('namespace', config.namespace ?? APP_ID);
|
|
232
235
|
context.set('counter', counter);
|
|
233
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
|
+
}
|
|
234
243
|
context.set('workflowTopic', workflowTopic);
|
|
235
244
|
context.set('workflowName', workflowTopic.split('-').pop());
|
|
236
245
|
context.set('workflowTrace', data.metadata.trc);
|
|
@@ -255,9 +264,10 @@ export class WorkerService {
|
|
|
255
264
|
metadata: { ...data.metadata },
|
|
256
265
|
data: {
|
|
257
266
|
code: err.code,
|
|
258
|
-
message: JSON.stringify({ duration: err.duration, index: err.index }),
|
|
267
|
+
message: JSON.stringify({ duration: err.duration, index: err.index, dimension: err.dimension }),
|
|
259
268
|
duration: err.duration,
|
|
260
|
-
index: err.index
|
|
269
|
+
index: err.index,
|
|
270
|
+
dimension: err.dimension
|
|
261
271
|
}
|
|
262
272
|
} as StreamDataResponse;
|
|
263
273
|
|
|
@@ -1,43 +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';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
./workflows.ts
|
|
22
|
-
|
|
23
|
-
import { Durable } from '@hotmeshio/hotmesh';
|
|
24
|
-
import * as activities from './activities';
|
|
25
|
-
|
|
26
|
-
const { greet } = Durable.workflow.proxyActivities<typeof activities>({
|
|
27
|
-
activities: activities,
|
|
28
|
-
startToCloseTimeout: '1 minute',
|
|
29
|
-
retryPolicy: {
|
|
30
|
-
initialInterval: '5 seconds', // Initial delay between retries
|
|
31
|
-
maximumAttempts: 3, // Max number of retry attempts
|
|
32
|
-
backoffCoefficient: 2.0, // Backoff factor for delay between retries: delay = initialInterval * (backoffCoefficient ^ retry_attempt)
|
|
33
|
-
maximumInterval: '30 seconds', // Max delay between retries
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
export async function example(name: string): Promise<string> {
|
|
38
|
-
return await greet(name);
|
|
39
|
-
}
|
|
40
|
-
*/
|
|
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';
|
|
41
22
|
|
|
42
23
|
export class WorkflowService {
|
|
43
24
|
|
|
@@ -46,15 +27,13 @@ export class WorkflowService {
|
|
|
46
27
|
*/
|
|
47
28
|
static async executeChild<T>(options: WorkflowOptions): Promise<T> {
|
|
48
29
|
const store = asyncLocalStorage.getStore();
|
|
49
|
-
if (!store) {
|
|
50
|
-
throw new Error('durable-store-not-found');
|
|
51
|
-
}
|
|
52
30
|
const workflowId = store.get('workflowId');
|
|
31
|
+
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
53
32
|
const workflowTrace = store.get('workflowTrace');
|
|
54
33
|
const workflowSpan = store.get('workflowSpan');
|
|
55
34
|
const COUNTER = store.get('counter');
|
|
56
35
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
57
|
-
const childJobId = `${workflowId}-$${options.workflowName}-${execIndex}`;
|
|
36
|
+
const childJobId = `${workflowId}-$${options.workflowName}${workflowDimension}-${execIndex}`;
|
|
58
37
|
const parentWorkflowId = `${workflowId}-f`;
|
|
59
38
|
|
|
60
39
|
const client = new Client({
|
|
@@ -68,7 +47,7 @@ export class WorkflowService {
|
|
|
68
47
|
);
|
|
69
48
|
|
|
70
49
|
try {
|
|
71
|
-
return await handle.result() as T;
|
|
50
|
+
return await handle.result(true) as T;
|
|
72
51
|
} catch (error) {
|
|
73
52
|
handle = await client.workflow.start({
|
|
74
53
|
...options,
|
|
@@ -98,33 +77,83 @@ export class WorkflowService {
|
|
|
98
77
|
return proxy;
|
|
99
78
|
}
|
|
100
79
|
|
|
101
|
-
static async
|
|
80
|
+
static async search(): Promise<Search> {
|
|
102
81
|
const store = asyncLocalStorage.getStore();
|
|
103
|
-
if (!store) {
|
|
104
|
-
throw new Error('durable-store-not-found');
|
|
105
|
-
}
|
|
106
82
|
const workflowId = store.get('workflowId');
|
|
83
|
+
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
107
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
|
+
}
|
|
108
93
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
}
|
|
116
|
+
|
|
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');
|
|
124
149
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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);
|
|
128
157
|
}
|
|
129
158
|
}
|
|
130
159
|
|
|
@@ -132,37 +161,35 @@ export class WorkflowService {
|
|
|
132
161
|
const seconds = ms(duration) / 1000;
|
|
133
162
|
|
|
134
163
|
const store = asyncLocalStorage.getStore();
|
|
135
|
-
if (!store) {
|
|
136
|
-
throw new Error('durable-store-not-found');
|
|
137
|
-
}
|
|
138
164
|
const COUNTER = store.get('counter');
|
|
139
165
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
140
166
|
const workflowId = store.get('workflowId');
|
|
141
167
|
const workflowTopic = store.get('workflowTopic');
|
|
142
|
-
const
|
|
168
|
+
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
169
|
+
const namespace = store.get('namespace');
|
|
170
|
+
const sleepJobId = `${workflowId}-$sleep${workflowDimension}-${execIndex}`;
|
|
143
171
|
|
|
144
172
|
try {
|
|
145
|
-
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic);
|
|
146
|
-
await hotMeshClient.getState(
|
|
173
|
+
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
174
|
+
await hotMeshClient.getState(`${hotMeshClient.appId}.sleep.execute`, sleepJobId);
|
|
147
175
|
//if no error is thrown, we've already slept, return the delay
|
|
148
176
|
return seconds;
|
|
149
177
|
} catch (e) {
|
|
150
178
|
//if an error, the sleep job was not found...rethrow error; sleep job
|
|
151
179
|
// will be automatically created according to the DAG rules (they
|
|
152
180
|
// spawn a new sleep job if error code 595 is thrown by the worker)
|
|
153
|
-
throw new DurableSleepError(workflowId, seconds, execIndex);
|
|
181
|
+
throw new DurableSleepError(workflowId, seconds, execIndex, workflowDimension);
|
|
154
182
|
}
|
|
155
183
|
}
|
|
156
184
|
|
|
157
185
|
static async waitForSignal(signals: string[], options?: Record<string, string>): Promise<Record<any, any>[]> {
|
|
158
186
|
const store = asyncLocalStorage.getStore();
|
|
159
|
-
if (!store) {
|
|
160
|
-
throw new Error('durable-store-not-found');
|
|
161
|
-
}
|
|
162
187
|
const COUNTER = store.get('counter');
|
|
163
188
|
const workflowId = store.get('workflowId');
|
|
164
189
|
const workflowTopic = store.get('workflowTopic');
|
|
165
|
-
const
|
|
190
|
+
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
191
|
+
const namespace = store.get('namespace');
|
|
192
|
+
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
166
193
|
|
|
167
194
|
//iterate the list of signals and check for done
|
|
168
195
|
let allAreComplete = true;
|
|
@@ -170,10 +197,10 @@ export class WorkflowService {
|
|
|
170
197
|
const signalResults: any[] = [];
|
|
171
198
|
for (const signal of signals) {
|
|
172
199
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
173
|
-
const wfsJobId = `${workflowId}-$wfs-${execIndex}`;
|
|
200
|
+
const wfsJobId = `${workflowId}-$wfs${workflowDimension}-${execIndex}`;
|
|
174
201
|
try {
|
|
175
202
|
if (allAreComplete) {
|
|
176
|
-
const state = await hotMeshClient.getState(
|
|
203
|
+
const state = await hotMeshClient.getState(`${hotMeshClient.appId}.wfs.execute`, wfsJobId);
|
|
177
204
|
if (state.data?.signalData) {
|
|
178
205
|
//user data is nested to isolate from the signal id; unpackage it
|
|
179
206
|
const signalData = state.data.signalData as { id: string, data: Record<any, any> };
|
|
@@ -207,23 +234,22 @@ export class WorkflowService {
|
|
|
207
234
|
static wrapActivity<T>(activityName: string, options?: ActivityConfig): T {
|
|
208
235
|
return async function() {
|
|
209
236
|
const store = asyncLocalStorage.getStore();
|
|
210
|
-
if (!store) {
|
|
211
|
-
throw new Error('durable-store-not-found');
|
|
212
|
-
}
|
|
213
237
|
const COUNTER = store.get('counter');
|
|
214
238
|
//increment by state (not value) to avoid race conditions
|
|
215
239
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
216
240
|
const workflowId = store.get('workflowId');
|
|
241
|
+
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
217
242
|
const workflowTopic = store.get('workflowTopic');
|
|
218
243
|
const trc = store.get('workflowTrace');
|
|
219
244
|
const spn = store.get('workflowSpan');
|
|
245
|
+
const namespace = store.get('namespace');
|
|
220
246
|
const activityTopic = `${workflowTopic}-activity`;
|
|
221
|
-
const activityJobId = `${workflowId}-$${activityName}-${execIndex}`;
|
|
247
|
+
const activityJobId = `${workflowId}-$${activityName}${workflowDimension}-${execIndex}`;
|
|
222
248
|
|
|
223
249
|
let activityState: JobOutput
|
|
224
250
|
try {
|
|
225
|
-
const hotMeshClient = await WorkerService.getHotMesh(activityTopic);
|
|
226
|
-
activityState = await hotMeshClient.getState(
|
|
251
|
+
const hotMeshClient = await WorkerService.getHotMesh(activityTopic, { namespace });
|
|
252
|
+
activityState = await hotMeshClient.getState(`${hotMeshClient.appId}.activity.execute`, activityJobId);
|
|
227
253
|
if (activityState.metadata.err) {
|
|
228
254
|
await hotMeshClient.scrub(activityJobId);
|
|
229
255
|
throw new Error(activityState.metadata.err);
|
|
@@ -232,9 +258,9 @@ export class WorkflowService {
|
|
|
232
258
|
}
|
|
233
259
|
//one time subscription
|
|
234
260
|
return await new Promise((resolve, reject) => {
|
|
235
|
-
hotMeshClient.sub(`${
|
|
261
|
+
hotMeshClient.sub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`, async (topic, message) => {
|
|
236
262
|
const response = message.data?.response;
|
|
237
|
-
hotMeshClient.unsub(`${
|
|
263
|
+
hotMeshClient.unsub(`${hotMeshClient.appId}.activity.executed.${activityJobId}`);
|
|
238
264
|
// Resolve the Promise when the callback is triggered with a message
|
|
239
265
|
resolve(response);
|
|
240
266
|
});
|
|
@@ -251,9 +277,9 @@ export class WorkflowService {
|
|
|
251
277
|
activityName,
|
|
252
278
|
};
|
|
253
279
|
//start the job
|
|
254
|
-
const hotMeshClient = await WorkerService.getHotMesh(activityTopic);
|
|
280
|
+
const hotMeshClient = await WorkerService.getHotMesh(activityTopic, { namespace });
|
|
255
281
|
const context = { metadata: { trc, spn }, data: {}};
|
|
256
|
-
const jobOutput = await hotMeshClient.pubsub(
|
|
282
|
+
const jobOutput = await hotMeshClient.pubsub(`${hotMeshClient.appId}.activity.execute`, payload, context as JobState, duration);
|
|
257
283
|
return jobOutput.data.response as T;
|
|
258
284
|
}
|
|
259
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
|
}
|