@hotmeshio/hotmesh 0.0.22 → 0.0.24
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 +66 -67
- package/build/index.d.ts +2 -1
- package/build/index.js +3 -1
- package/build/package.json +2 -1
- package/build/services/durable/connection.js +0 -39
- package/build/services/durable/factory.d.ts +6 -4
- package/build/services/durable/factory.js +12 -10
- package/build/services/durable/handle.d.ts +3 -0
- package/build/services/durable/handle.js +15 -5
- package/build/services/durable/index.d.ts +2 -45
- package/build/services/durable/index.js +2 -45
- package/build/services/durable/meshos.d.ts +108 -0
- package/build/services/durable/meshos.js +289 -0
- package/build/services/durable/search.d.ts +9 -0
- package/build/services/durable/search.js +34 -2
- package/build/services/durable/worker.d.ts +2 -10
- package/build/services/durable/worker.js +10 -39
- package/build/services/durable/workflow.d.ts +12 -1
- package/build/services/durable/workflow.js +32 -16
- package/build/services/engine/index.d.ts +2 -0
- package/build/services/engine/index.js +3 -0
- package/build/services/hotmesh/index.d.ts +3 -1
- package/build/services/hotmesh/index.js +5 -2
- package/build/services/signaler/stream.js +1 -2
- package/build/services/store/clients/ioredis.js +2 -2
- package/build/services/store/clients/redis.js +1 -1
- package/build/services/store/index.d.ts +5 -0
- package/build/services/store/index.js +14 -0
- package/build/types/durable.d.ts +34 -4
- package/build/types/index.d.ts +1 -1
- package/index.ts +2 -1
- package/package.json +2 -1
- package/services/durable/connection.ts +0 -40
- package/services/durable/factory.ts +12 -10
- package/services/durable/handle.ts +18 -5
- package/services/durable/index.ts +2 -46
- package/services/durable/meshos.ts +344 -0
- package/services/durable/search.ts +35 -2
- package/services/durable/worker.ts +11 -42
- package/services/durable/workflow.ts +34 -17
- package/services/engine/index.ts +4 -1
- package/services/hotmesh/index.ts +6 -2
- package/services/signaler/stream.ts +1 -2
- package/services/store/clients/ioredis.ts +2 -3
- package/services/store/clients/redis.ts +1 -1
- package/services/store/index.ts +15 -0
- package/types/durable.ts +40 -4
- package/types/index.ts +6 -1
- package/build/services/durable/native.d.ts +0 -4
- package/build/services/durable/native.js +0 -46
- package/services/durable/native.ts +0 -45
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
|
|
3
|
+
import { ClientService as Client } from './client';
|
|
4
|
+
import { WorkflowHandleService } from './handle';
|
|
5
|
+
import { Search } from './search';
|
|
6
|
+
import { WorkerService as Worker } from './worker';
|
|
7
|
+
import { FindOptions, MeshOSActivityOptions, MeshOSOptions, WorkflowSearchOptions } from '../../types/durable';
|
|
8
|
+
import { RedisOptions, RedisClass } from '../../types/redis';
|
|
9
|
+
import { StringAnyType } from '../../types';
|
|
10
|
+
import { Durable } from '.';
|
|
11
|
+
import { asyncLocalStorage } from './asyncLocalStorage';
|
|
12
|
+
import { WorkflowService } from './workflow';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The base class for running MeshOS workflows.
|
|
16
|
+
* Extend and register subclass methods by name to
|
|
17
|
+
* execute as durable workflows, backed by Redis.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export class MeshOSService {
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The top-level Redis isolation. All workflow data is
|
|
24
|
+
* isolated within this namespace. Values should be
|
|
25
|
+
* lower-case with no spaces (e.g, 'staging', 'prod', 'test',
|
|
26
|
+
* 'routing-stagig', 'reporting-prod', etc.).
|
|
27
|
+
* 1) only url-safe values are allowed;
|
|
28
|
+
* 2) the 'a' symbol is reserved by HotMesh for indexing apps
|
|
29
|
+
*/
|
|
30
|
+
namespace = 'durable';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Data is routed to workers that specify this task queue.
|
|
34
|
+
* Setting the task queue when the worker is created will
|
|
35
|
+
* ensure that the worker only receives messages destined
|
|
36
|
+
* for the queue. Callers can specify the taskQue to when
|
|
37
|
+
* starting a job to call those workers.
|
|
38
|
+
*/
|
|
39
|
+
taskQueue = 'default';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* These methods run as durable workflows
|
|
43
|
+
*/
|
|
44
|
+
workflowFunctions: Array<MeshOSOptions | string> = [];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* These methods run as hooks (hook into a running workflow)
|
|
48
|
+
*/
|
|
49
|
+
hookFunctions: Array<MeshOSOptions | string> = [];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* These methods run as proxied activities (and are safely memoized)
|
|
53
|
+
*/
|
|
54
|
+
proxyFunctions: Array<MeshOSActivityOptions | string> = [];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The workflow GUID. Workflows will be persisted to
|
|
58
|
+
* Redis using the pattern hmsh:<namespace>:j:<id>.
|
|
59
|
+
*/
|
|
60
|
+
id: string;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The Redis connection options. NOTE: Redis and IORedis
|
|
64
|
+
* use different formats for their connection config.
|
|
65
|
+
*/
|
|
66
|
+
redisOptions: RedisOptions = {
|
|
67
|
+
host: 'localhost',
|
|
68
|
+
port: 6379,
|
|
69
|
+
password: '',
|
|
70
|
+
db: 0,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The Redis connection class.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* import Redis from 'ioredis';
|
|
78
|
+
* import * as Redis from 'redis';
|
|
79
|
+
*/
|
|
80
|
+
redisClass: RedisClass;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Optional model declaration (custom workflow state)
|
|
84
|
+
*/
|
|
85
|
+
model: StringAnyType;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Optional configuration for Redis FT search
|
|
89
|
+
*/
|
|
90
|
+
search: WorkflowSearchOptions;
|
|
91
|
+
|
|
92
|
+
static MeshOS = WorkflowService
|
|
93
|
+
|
|
94
|
+
static async getHotMeshClient (redisClass: RedisClass, redisOptions: RedisOptions, namespace: string, taskQueue: string) {
|
|
95
|
+
const client = new Client({
|
|
96
|
+
connection: {
|
|
97
|
+
class: redisClass,
|
|
98
|
+
options: redisOptions,
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
return await client.getHotMeshClient(taskQueue, namespace);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* mints a workflow ID, using the search prefix.
|
|
106
|
+
* NOTE: The prefix is necesary when indexing
|
|
107
|
+
* HASHes when FT search is enabled.
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
static mintGuid(): string {
|
|
111
|
+
const my = new this();
|
|
112
|
+
return `${my.search?.prefix?.[0]}${nanoid()}}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Creates an FT search index
|
|
117
|
+
*/
|
|
118
|
+
static async createIndex() {
|
|
119
|
+
const my = new this();
|
|
120
|
+
const hmClient = await MeshOSService.getHotMeshClient(my.redisClass, my.redisOptions, my.namespace, my.taskQueue);
|
|
121
|
+
Search.configureSearchIndex(hmClient, my.search)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Initialize the worker(s) for the entity. This is a static
|
|
126
|
+
* method that allows for optional task Queue targeting.
|
|
127
|
+
* NOTE: Allow List may be optionally used to only wrap
|
|
128
|
+
* specific methods in this class.
|
|
129
|
+
* @param {string} taskQueue
|
|
130
|
+
* @param {string[]} allowList
|
|
131
|
+
*/
|
|
132
|
+
static async startWorkers(taskQueue?: string, allowList: Array<MeshOSOptions | string> = []) {
|
|
133
|
+
const my = new this();
|
|
134
|
+
|
|
135
|
+
//helper functions
|
|
136
|
+
const resolveFunctionNames = (arr: any[]) => arr.map(item => typeof item === 'string' ? item : item.name);
|
|
137
|
+
const belongsTo = (name: string, target: Array<MeshOSOptions | MeshOSActivityOptions | string>): boolean => {
|
|
138
|
+
const isWorkflow = target.find((item: MeshOSOptions | string) => {
|
|
139
|
+
return typeof item === 'string' ? item === name : item.name === name;
|
|
140
|
+
});
|
|
141
|
+
return isWorkflow !== undefined;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// proxy registered activities
|
|
145
|
+
const proxyFunctionNames = resolveFunctionNames([...my.proxyFunctions]);
|
|
146
|
+
if (proxyFunctionNames.length) {
|
|
147
|
+
const proxyActivities = proxyFunctionNames.reduce((acc, funcName) => {
|
|
148
|
+
let originalMethod = my[funcName];
|
|
149
|
+
if (typeof originalMethod === 'function') {
|
|
150
|
+
acc[funcName] = async (...args: any[]) => {
|
|
151
|
+
return await originalMethod.apply(my, args);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return acc;
|
|
155
|
+
}, {});
|
|
156
|
+
const proxiedActivities = Durable.workflow.proxyActivities({
|
|
157
|
+
activities: proxyActivities
|
|
158
|
+
});
|
|
159
|
+
//WATCH!: unsure if this will pollute the scope; don't think
|
|
160
|
+
// so as activity functions are terminal in the chain.
|
|
161
|
+
Object.assign(my, proxiedActivities);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const functionsToIterate = allowList.length ? resolveFunctionNames(allowList) : resolveFunctionNames([...my.workflowFunctions, ...my.hookFunctions]);
|
|
165
|
+
|
|
166
|
+
// Iterating through the functions sequentially
|
|
167
|
+
for (const funcName of functionsToIterate) {
|
|
168
|
+
const originalMethod = my[funcName];
|
|
169
|
+
if (typeof originalMethod === 'function') {
|
|
170
|
+
|
|
171
|
+
//wrap the function to return
|
|
172
|
+
const wrappedFunction = {
|
|
173
|
+
[funcName]: async (...args: any[]) => {
|
|
174
|
+
const store = asyncLocalStorage.getStore();
|
|
175
|
+
const workflowId = store.get('workflowId');
|
|
176
|
+
|
|
177
|
+
//use a Proxy to wrap hook methods
|
|
178
|
+
const context = new Proxy(my, {
|
|
179
|
+
get: (target, prop, receiver) => {
|
|
180
|
+
if (prop === 'id') {
|
|
181
|
+
return workflowId;
|
|
182
|
+
} else if (typeof target[prop] === 'function') {
|
|
183
|
+
return (...args: any[]) => {
|
|
184
|
+
return new Promise(async (resolve, reject) => {
|
|
185
|
+
if (belongsTo(prop as string, my.hookFunctions)) {
|
|
186
|
+
return WorkflowService.hook({
|
|
187
|
+
namespace: my.namespace,
|
|
188
|
+
taskQueue: my.taskQueue,
|
|
189
|
+
workflowName: prop as string,
|
|
190
|
+
workflowId,
|
|
191
|
+
args,
|
|
192
|
+
}).then(resolve).catch(reject);
|
|
193
|
+
}
|
|
194
|
+
//otherwise, call the method as a standard instance method.
|
|
195
|
+
target[prop].apply(this, args).then(resolve).catch(reject);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return Reflect.get(target, prop, receiver);
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
return await originalMethod.apply(context, args);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
//start the worker
|
|
207
|
+
await Worker.create({
|
|
208
|
+
namespace: my.namespace,
|
|
209
|
+
connection: {
|
|
210
|
+
class: my.redisClass,
|
|
211
|
+
options: my.redisOptions,
|
|
212
|
+
},
|
|
213
|
+
taskQueue: taskQueue ?? my.taskQueue,
|
|
214
|
+
workflow: wrappedFunction,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* executes the redis FT search query
|
|
222
|
+
* @example '@_quantity:[89 89]'
|
|
223
|
+
* @param {any[]} args
|
|
224
|
+
* @returns {string}
|
|
225
|
+
*/
|
|
226
|
+
static async find(options: FindOptions, ...args: string[]): Promise<string[] | [number]> {
|
|
227
|
+
const my = new this();
|
|
228
|
+
const client = new Client({ connection: {
|
|
229
|
+
class: my.redisClass,
|
|
230
|
+
options: my.redisOptions
|
|
231
|
+
}});
|
|
232
|
+
//workflow name is the function name driving the workflow
|
|
233
|
+
let workflowName: string;
|
|
234
|
+
if (options?.workflowName) {
|
|
235
|
+
workflowName = options?.workflowName
|
|
236
|
+
} else if(my.workflowFunctions?.length) {
|
|
237
|
+
let target = my.workflowFunctions[0];
|
|
238
|
+
if (typeof target === 'string') {
|
|
239
|
+
workflowName = target;
|
|
240
|
+
} else {
|
|
241
|
+
workflowName = target.name;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return await client.workflow.search(
|
|
245
|
+
options?.taskQueue ?? my.taskQueue,
|
|
246
|
+
workflowName,
|
|
247
|
+
my.namespace,
|
|
248
|
+
my.search.index,
|
|
249
|
+
...args,
|
|
250
|
+
); //[count, [id, fields[], id, fields[], id, fields[], ...]]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* returns the workflow handle. The handle can then be
|
|
255
|
+
* used to query for status, state, custom state, etc.
|
|
256
|
+
* @param {string} id
|
|
257
|
+
* @returns {Promise<WorkflowHandleService>}
|
|
258
|
+
*/
|
|
259
|
+
static async get(id: string): Promise<WorkflowHandleService> {
|
|
260
|
+
const my = new this();
|
|
261
|
+
const client = new Client({ connection: {
|
|
262
|
+
class: my.redisClass,
|
|
263
|
+
options: my.redisOptions
|
|
264
|
+
}});
|
|
265
|
+
let workflowName: string;
|
|
266
|
+
let target = my.workflowFunctions[0];
|
|
267
|
+
if (typeof target === 'string') {
|
|
268
|
+
workflowName = target;
|
|
269
|
+
} else {
|
|
270
|
+
workflowName = target.name;
|
|
271
|
+
}
|
|
272
|
+
return await client.workflow.getHandle(
|
|
273
|
+
my.taskQueue,
|
|
274
|
+
workflowName,
|
|
275
|
+
id,
|
|
276
|
+
my.namespace,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Optionally include a target taskQueue to exec the
|
|
282
|
+
* workflow's call on a specific worker queue.
|
|
283
|
+
*/
|
|
284
|
+
constructor(id?: string, options?: Record<string, any>) {
|
|
285
|
+
this.id = id;
|
|
286
|
+
if (options?.taskQueue) {
|
|
287
|
+
this.taskQueue = options.taskQueue;
|
|
288
|
+
} else if (!id && !options?.taskQueue) {
|
|
289
|
+
return this;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function belongsTo(name: string, target: Array<MeshOSOptions | MeshOSActivityOptions | string>): boolean {
|
|
293
|
+
const isWorkflow = target.find((item: MeshOSOptions | string) => {
|
|
294
|
+
return typeof item === 'string' ? item === name : item.name === name;
|
|
295
|
+
});
|
|
296
|
+
return isWorkflow !== undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return new Proxy(this, {
|
|
300
|
+
get: (target, prop, receiver) => {
|
|
301
|
+
if (typeof target[prop] === 'function') {
|
|
302
|
+
return (...args: any[]) => {
|
|
303
|
+
return new Promise(async (resolve, reject) => {
|
|
304
|
+
const client = new Client({ connection: {
|
|
305
|
+
class: this.redisClass,
|
|
306
|
+
options: this.redisOptions
|
|
307
|
+
}});
|
|
308
|
+
if (belongsTo(prop as string, this.workflowFunctions)) {
|
|
309
|
+
//start a new workflow
|
|
310
|
+
const handle = await client.workflow.start({
|
|
311
|
+
namespace: this.namespace,
|
|
312
|
+
args,
|
|
313
|
+
taskQueue: this.taskQueue,
|
|
314
|
+
workflowName: prop as string,
|
|
315
|
+
workflowId: this.id,
|
|
316
|
+
});
|
|
317
|
+
if (options?.await) {
|
|
318
|
+
//wait for the workflow to complete
|
|
319
|
+
const result = await handle.result();
|
|
320
|
+
return resolve(result);
|
|
321
|
+
} else {
|
|
322
|
+
//return the workflow handle
|
|
323
|
+
return resolve(handle);
|
|
324
|
+
}
|
|
325
|
+
} else if (belongsTo(prop as string, this.hookFunctions)) {
|
|
326
|
+
//hook into a running workflow
|
|
327
|
+
return client.workflow.hook({
|
|
328
|
+
namespace: this.namespace,
|
|
329
|
+
taskQueue: this.taskQueue,
|
|
330
|
+
workflowName: prop as string,
|
|
331
|
+
workflowId: this.id,
|
|
332
|
+
args,
|
|
333
|
+
}).then(resolve).catch(reject);
|
|
334
|
+
}
|
|
335
|
+
//otherwise, call the method as a standard instance method.
|
|
336
|
+
target[prop].apply(this, args).then(resolve).catch(reject);
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return Reflect.get(target, prop, receiver);
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -2,6 +2,7 @@ import { HotMeshService as HotMesh } from '../hotmesh'
|
|
|
2
2
|
import { RedisClient, RedisMulti } from '../../types/redis';
|
|
3
3
|
import { StoreService } from '../store';
|
|
4
4
|
import { KeyService, KeyType } from '../../modules/key';
|
|
5
|
+
import { WorkflowSearchOptions } from '../../types/durable';
|
|
5
6
|
|
|
6
7
|
export class Search {
|
|
7
8
|
jobId: string;
|
|
@@ -11,11 +12,43 @@ export class Search {
|
|
|
11
12
|
store: StoreService<RedisClient, RedisMulti> | null;
|
|
12
13
|
|
|
13
14
|
safeKey(key:string): string {
|
|
14
|
-
//note: protect the execution namespace with a prefix
|
|
15
|
-
//so its design never conflicts with the hotmesh keyspace
|
|
15
|
+
//note: protect the execution namespace with a prefix
|
|
16
16
|
return `_${key}`;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* For those deployments with a redis stack backend (with the FT module),
|
|
21
|
+
* this method will configure the search index for the workflow. For all
|
|
22
|
+
* others, this method will exit/fail gracefully and not index
|
|
23
|
+
* the fields in the HASH. However, all values are still available
|
|
24
|
+
* in the HASH.
|
|
25
|
+
*/
|
|
26
|
+
static async configureSearchIndex(hotMeshClient: HotMesh, search?: WorkflowSearchOptions): Promise<void> {
|
|
27
|
+
if (search?.schema) {
|
|
28
|
+
const store = hotMeshClient.engine.store;
|
|
29
|
+
const schema: string[] = [];
|
|
30
|
+
for (const [key, value] of Object.entries(search.schema)) {
|
|
31
|
+
//prefix with a comma (avoids collisions with hotmesh reserved words)
|
|
32
|
+
schema.push(`_${key}`);
|
|
33
|
+
schema.push(value.type);
|
|
34
|
+
if (value.sortable) {
|
|
35
|
+
schema.push('SORTABLE');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const keyParams = {
|
|
40
|
+
appId: hotMeshClient.appId,
|
|
41
|
+
jobId: ''
|
|
42
|
+
}
|
|
43
|
+
const hotMeshPrefix = KeyService.mintKey(hotMeshClient.namespace, KeyType.JOB_STATE, keyParams);
|
|
44
|
+
const prefixes = search.prefix.map((prefix) => `${hotMeshPrefix}${prefix}`);
|
|
45
|
+
await store.exec('FT.CREATE', `${search.index}`, 'ON', 'HASH', 'PREFIX', prefixes.length.toString(), ...prefixes, 'SCHEMA', ...schema);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
hotMeshClient.engine.logger.info('durable-client-search-err', { err });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
19
52
|
constructor(workflowId: string, hotMeshClient: HotMesh, searchSessionId: string) {
|
|
20
53
|
const keyParams = {
|
|
21
54
|
appId: hotMeshClient.appId,
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
DurableSleepError,
|
|
7
7
|
DurableTimeoutError,
|
|
8
8
|
DurableWaitForSignalError} from '../../modules/errors';
|
|
9
|
-
import { KeyService, KeyType } from '../../modules/key';
|
|
10
9
|
import { asyncLocalStorage } from './asyncLocalStorage';
|
|
11
10
|
import { APP_ID, APP_VERSION, getWorkflowYAML } from './factory';
|
|
12
11
|
import { HotMeshService as HotMesh } from '../hotmesh';
|
|
@@ -16,9 +15,9 @@ import {
|
|
|
16
15
|
Registry,
|
|
17
16
|
WorkerConfig,
|
|
18
17
|
WorkerOptions,
|
|
19
|
-
WorkflowDataType
|
|
20
|
-
WorkflowSearchOptions} from '../../types/durable';
|
|
18
|
+
WorkflowDataType } from '../../types/durable';
|
|
21
19
|
import { RedisClass, RedisOptions } from '../../types/redis';
|
|
20
|
+
import { Search } from './search';
|
|
22
21
|
import {
|
|
23
22
|
StreamData,
|
|
24
23
|
StreamDataResponse,
|
|
@@ -73,46 +72,15 @@ export class WorkerService {
|
|
|
73
72
|
Object.keys(activities).forEach(key => {
|
|
74
73
|
if (activities[key].name && typeof WorkerService.activityRegistry[activities[key].name] !== 'function') {
|
|
75
74
|
WorkerService.activityRegistry[activities[key].name] = (activities as any)[key] as Function;
|
|
75
|
+
} else if (typeof (activities as any)[key] === 'function') {
|
|
76
|
+
WorkerService.activityRegistry[key] = (activities as any)[key] as Function;
|
|
76
77
|
}
|
|
77
78
|
});
|
|
78
79
|
}
|
|
79
80
|
return WorkerService.activityRegistry;
|
|
80
81
|
}
|
|
81
82
|
|
|
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
|
-
static async create(config: WorkerConfig) {
|
|
83
|
+
static async create(config: WorkerConfig): Promise<WorkerService> {
|
|
116
84
|
WorkerService.connection = config.connection;
|
|
117
85
|
const workflow = config.workflow;
|
|
118
86
|
const [workflowFunctionName, workflowFunction] = WorkerService.resolveWorkflowTarget(workflow);
|
|
@@ -124,21 +92,22 @@ export class WorkerService {
|
|
|
124
92
|
const worker = new WorkerService();
|
|
125
93
|
worker.activityRunner = await worker.initActivityWorker(config, activityTopic);
|
|
126
94
|
worker.workflowRunner = await worker.initWorkflowWorker(config, workflowTopic, workflowFunction);
|
|
127
|
-
|
|
95
|
+
Search.configureSearchIndex(worker.workflowRunner, config.search)
|
|
128
96
|
await WorkerService.activateWorkflow(worker.workflowRunner);
|
|
129
97
|
return worker;
|
|
130
98
|
}
|
|
131
99
|
|
|
132
|
-
static resolveWorkflowTarget(workflow: object | Function): [string, Function] {
|
|
100
|
+
static resolveWorkflowTarget(workflow: object | Function, name?: string): [string, Function] {
|
|
133
101
|
let workflowFunction: Function;
|
|
134
102
|
if (typeof workflow === 'function') {
|
|
135
103
|
workflowFunction = workflow;
|
|
104
|
+
return [workflowFunction.name ?? name, workflowFunction];
|
|
136
105
|
} else {
|
|
137
106
|
const workflowFunctionNames = Object.keys(workflow);
|
|
138
|
-
|
|
139
|
-
|
|
107
|
+
const lastFunctionName = workflowFunctionNames[workflowFunctionNames.length - 1];
|
|
108
|
+
workflowFunction = workflow[lastFunctionName];
|
|
109
|
+
return WorkerService.resolveWorkflowTarget(workflowFunction, lastFunctionName);
|
|
140
110
|
}
|
|
141
|
-
return [workflowFunction.name, workflowFunction];
|
|
142
111
|
}
|
|
143
112
|
|
|
144
113
|
async run() {
|
|
@@ -37,7 +37,7 @@ export class WorkflowService {
|
|
|
37
37
|
//this is risky but MUST be allowed. Users MAY set the workflowId,
|
|
38
38
|
//but if there is a naming collision, the data from the target entity will be used
|
|
39
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 ??
|
|
40
|
+
const childJobId = options.workflowId ?? `-${workflowId}-$${options.workflowName}${workflowDimension}-${execIndex}`;
|
|
41
41
|
const parentWorkflowId = `${workflowId}-f`;
|
|
42
42
|
|
|
43
43
|
const client = new Client({
|
|
@@ -80,7 +80,7 @@ export class WorkflowService {
|
|
|
80
80
|
const workflowSpan = store.get('workflowSpan');
|
|
81
81
|
const COUNTER = store.get('counter');
|
|
82
82
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
83
|
-
const childJobId = options.workflowId ??
|
|
83
|
+
const childJobId = options.workflowId ?? `-${workflowId}-$${options.workflowName}${workflowDimension}-${execIndex}`;
|
|
84
84
|
const parentWorkflowId = `${workflowId}-f`;
|
|
85
85
|
const workflowTopic = `${options.taskQueue}-${options.workflowName}`;
|
|
86
86
|
|
|
@@ -106,9 +106,12 @@ export class WorkflowService {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* wrap all activities in a proxy that will durably run them
|
|
111
|
+
*/
|
|
109
112
|
static proxyActivities<ACT>(options?: ActivityConfig): ProxyType<ACT> {
|
|
110
113
|
if (options.activities) {
|
|
111
|
-
WorkerService.registerActivities(options.activities)
|
|
114
|
+
WorkerService.registerActivities(options.activities);
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
const proxy: any = {};
|
|
@@ -122,6 +125,9 @@ export class WorkflowService {
|
|
|
122
125
|
return proxy;
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
/**
|
|
129
|
+
* return a search session for use when reading/writing to the workflow HASH
|
|
130
|
+
*/
|
|
125
131
|
static async search(): Promise<Search> {
|
|
126
132
|
const store = asyncLocalStorage.getStore();
|
|
127
133
|
const workflowId = store.get('workflowId');
|
|
@@ -130,15 +136,26 @@ export class WorkflowService {
|
|
|
130
136
|
const namespace = store.get('namespace');
|
|
131
137
|
const COUNTER = store.get('counter');
|
|
132
138
|
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
139
|
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
140
|
+
//this ID is used as a item key with a hash (dash prefix ensures no collision)
|
|
135
141
|
const searchSessionId = `-search${workflowDimension}-${execIndex}`;
|
|
136
142
|
return new Search(workflowId, hotMeshClient, searchSessionId);
|
|
137
143
|
}
|
|
138
144
|
|
|
145
|
+
/**
|
|
146
|
+
* return a handle to the hotmesh client currently running the workflow
|
|
147
|
+
*/
|
|
148
|
+
static async getHotMesh(): Promise<HotMesh> {
|
|
149
|
+
const store = asyncLocalStorage.getStore();
|
|
150
|
+
const workflowTopic = store.get('workflowTopic');
|
|
151
|
+
const namespace = store.get('namespace');
|
|
152
|
+
return await WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
153
|
+
}
|
|
154
|
+
|
|
139
155
|
/**
|
|
140
156
|
* 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
|
|
157
|
+
* their execution with a unique key (the key is stored in the HASH alongside
|
|
158
|
+
* process state and job state)
|
|
142
159
|
*/
|
|
143
160
|
static async isSideEffectAllowed(hotMeshClient: HotMesh, prefix:string): Promise<boolean> {
|
|
144
161
|
const store = asyncLocalStorage.getStore();
|
|
@@ -146,15 +163,12 @@ export class WorkflowService {
|
|
|
146
163
|
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
147
164
|
const COUNTER = store.get('counter');
|
|
148
165
|
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
166
|
const sessionId = `-${prefix}${workflowDimension}-${execIndex}-`;
|
|
151
|
-
//this ID is used as a item key with a hash (dash prefix ensures no collision)
|
|
152
167
|
const keyParams = {
|
|
153
168
|
appId: hotMeshClient.appId,
|
|
154
|
-
jobId:
|
|
169
|
+
jobId: workflowId
|
|
155
170
|
}
|
|
156
|
-
const
|
|
157
|
-
const workflowGuid = `${hotMeshPrefix}${workflowId}`;
|
|
171
|
+
const workflowGuid = KeyService.mintKey(hotMeshClient.namespace, KeyType.JOB_STATE, keyParams);
|
|
158
172
|
const guidValue = Number(await hotMeshClient.engine.store.exec('HINCRBYFLOAT', workflowGuid, sessionId, '1') as string);
|
|
159
173
|
return guidValue === 1;
|
|
160
174
|
}
|
|
@@ -165,8 +179,11 @@ export class WorkflowService {
|
|
|
165
179
|
*/
|
|
166
180
|
static async signal(signalId: string, data: Record<any, any>): Promise<string> {
|
|
167
181
|
const store = asyncLocalStorage.getStore();
|
|
182
|
+
const workflowTopic = store.get('workflowTopic');
|
|
168
183
|
const namespace = store.get('namespace');
|
|
169
|
-
const hotMeshClient = await WorkerService.getHotMesh(
|
|
184
|
+
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
185
|
+
//todo: this particular one is better patterned as a get/set,
|
|
186
|
+
//since the receipt is a meaningful string (the stream id)
|
|
170
187
|
if (await WorkflowService.isSideEffectAllowed(hotMeshClient, 'signal')) {
|
|
171
188
|
return await hotMeshClient.hook(`${namespace}.wfs.signal`, { id: signalId, data });
|
|
172
189
|
}
|
|
@@ -179,8 +196,9 @@ export class WorkflowService {
|
|
|
179
196
|
*/
|
|
180
197
|
static async hook(options: HookOptions): Promise<string> {
|
|
181
198
|
const store = asyncLocalStorage.getStore();
|
|
199
|
+
const workflowTopic = store.get('workflowTopic');
|
|
182
200
|
const namespace = store.get('namespace');
|
|
183
|
-
const hotMeshClient = await WorkerService.getHotMesh(
|
|
201
|
+
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
184
202
|
if (await WorkflowService.isSideEffectAllowed(hotMeshClient, 'hook')) {
|
|
185
203
|
const store = asyncLocalStorage.getStore();
|
|
186
204
|
const workflowId = options.workflowId ?? store.get('workflowId');
|
|
@@ -208,7 +226,7 @@ export class WorkflowService {
|
|
|
208
226
|
const workflowTopic = store.get('workflowTopic');
|
|
209
227
|
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
210
228
|
const namespace = store.get('namespace');
|
|
211
|
-
const sleepJobId =
|
|
229
|
+
const sleepJobId = `-${workflowId}-$sleep${workflowDimension}-${execIndex}`;
|
|
212
230
|
|
|
213
231
|
try {
|
|
214
232
|
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
@@ -216,9 +234,8 @@ export class WorkflowService {
|
|
|
216
234
|
//if no error is thrown, we've already slept, return the delay
|
|
217
235
|
return seconds;
|
|
218
236
|
} catch (e) {
|
|
219
|
-
//if an error, the sleep job was not found...rethrow error; sleep job
|
|
220
|
-
// will be automatically created according to the DAG rules (they
|
|
221
237
|
// spawn a new sleep job if error code 595 is thrown by the worker)
|
|
238
|
+
// NOTE: If this message shows up in your stack trace, you forgot to await `Durable.workflow.sleep()` in your workflow code.
|
|
222
239
|
throw new DurableSleepError(workflowId, seconds, execIndex, workflowDimension);
|
|
223
240
|
}
|
|
224
241
|
}
|
|
@@ -238,7 +255,7 @@ export class WorkflowService {
|
|
|
238
255
|
const signalResults: any[] = [];
|
|
239
256
|
for (const signal of signals) {
|
|
240
257
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
241
|
-
const wfsJobId =
|
|
258
|
+
const wfsJobId = `-${workflowId}-$wfs${workflowDimension}-${execIndex}`;
|
|
242
259
|
try {
|
|
243
260
|
if (allAreComplete) {
|
|
244
261
|
const state = await hotMeshClient.getState(`${hotMeshClient.appId}.wfs.execute`, wfsJobId);
|
|
@@ -285,7 +302,7 @@ export class WorkflowService {
|
|
|
285
302
|
const spn = store.get('workflowSpan');
|
|
286
303
|
const namespace = store.get('namespace');
|
|
287
304
|
const activityTopic = `${workflowTopic}-activity`;
|
|
288
|
-
const activityJobId =
|
|
305
|
+
const activityJobId = `-${workflowId}-$${activityName}${workflowDimension}-${execIndex}`;
|
|
289
306
|
|
|
290
307
|
let activityState: JobOutput
|
|
291
308
|
try {
|
package/services/engine/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
SubscriptionCallback } from '../../types/quorum';
|
|
54
54
|
import { RedisClient, RedisMulti } from '../../types/redis';
|
|
55
55
|
import { RedisClientType } from '../../types/redisclient';
|
|
56
|
+
import { StringAnyType, StringStringType } from '../../types/serializer';
|
|
56
57
|
import {
|
|
57
58
|
GetStatsOptions,
|
|
58
59
|
IdsResponse,
|
|
@@ -67,7 +68,6 @@ import {
|
|
|
67
68
|
StreamError,
|
|
68
69
|
StreamRole,
|
|
69
70
|
StreamStatus } from '../../types/stream';
|
|
70
|
-
import { StringStringType } from '../../types';
|
|
71
71
|
|
|
72
72
|
//wait time to see if a job is complete
|
|
73
73
|
const OTT_WAIT_TIME = 1000;
|
|
@@ -629,6 +629,9 @@ class EngineService {
|
|
|
629
629
|
}
|
|
630
630
|
return stateTree;
|
|
631
631
|
}
|
|
632
|
+
async getQueryState(jobId: string, fields: string[]): Promise<StringAnyType> {
|
|
633
|
+
return await this.store.getQueryState(jobId, fields);
|
|
634
|
+
}
|
|
632
635
|
|
|
633
636
|
async compress(terms: string[]): Promise<boolean> {
|
|
634
637
|
const existingSymbols = await this.store.getSymbolValues();
|