@hotmeshio/hotmesh 0.0.30 → 0.0.32
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 +69 -66
- package/build/package.json +1 -1
- package/build/services/durable/client.js +2 -2
- package/build/services/durable/workflow.js +13 -11
- package/build/services/signaler/stream.js +1 -2
- package/build/types/durable.d.ts +3 -2
- package/package.json +1 -1
- package/services/durable/client.ts +2 -2
- package/services/durable/workflow.ts +14 -12
- package/services/signaler/stream.ts +1 -2
- package/types/durable.ts +5 -4
package/README.md
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
# HotMesh
|
|
2
2
|

|
|
3
3
|
|
|
4
|
-
Elevate Redis from an in-memory data cache, and turn your unpredictable functions into unbreakable workflows.
|
|
4
|
+
Elevate Redis from an in-memory data cache, and turn your unpredictable functions into unbreakable workflows. HotMesh is a distributed orchestration engine that governs the execution of your functions, ensuring they always complete successfully.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
It's still Redis in the background, but your functions are run as *reentrant processes* and are executed in a distributed environment, with all the benefits of a distributed system, including fault tolerance, scalability, and high availability.
|
|
9
|
-
|
|
10
|
-
Write functions in your own preferred style, and let Redis govern their execution with its unmatched performance.
|
|
6
|
+
*Write functions in your own preferred style, and let Redis govern their execution.*
|
|
11
7
|
|
|
12
8
|
## Install
|
|
13
9
|
[](https://badge.fury.io/js/%40hotmeshio%2Fhotmesh)
|
|
@@ -15,82 +11,89 @@ Write functions in your own preferred style, and let Redis govern their executio
|
|
|
15
11
|
```sh
|
|
16
12
|
npm install @hotmeshio/hotmesh
|
|
17
13
|
```
|
|
18
|
-
|
|
19
14
|
## Design
|
|
20
|
-
1. Start with any ordinary class. Pay attention to unpredictable functions: those that execute slowly, cause problems at scale, or simply fail to return now and then. *Note how the `flaky` function throws an error 50% of the time. This is exactly the type of function that can be fixed using HotMesh.*
|
|
21
|
-
```javascript
|
|
22
|
-
//myworkflow.ts
|
|
23
|
-
|
|
24
|
-
export class MyWorkflow {
|
|
25
|
-
|
|
26
|
-
async run(name: string): Promise<string> {
|
|
27
|
-
const hi = await this.flaky(name);
|
|
28
|
-
const hello = await this.greet(name);
|
|
29
|
-
return `${hi} ${hello}`;
|
|
30
|
-
}
|
|
31
15
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
16
|
+
1. Start by defining **activities**. Activities can be written in any style, using any framework, and can even be legacy functions you've already written. The only requirement is that they return a Promise. *Note how the `saludar` example throws an error 50% of the time. It doesn't matter how unpredictable your functions are, HotMesh will retry as necessary until they succeed.*
|
|
17
|
+
```javascript
|
|
18
|
+
//activities.ts
|
|
19
|
+
export async function greet(name: string): Promise<string> {
|
|
20
|
+
return `Hello, ${name}!`;
|
|
21
|
+
}
|
|
39
22
|
|
|
40
|
-
|
|
41
|
-
|
|
23
|
+
export async function saludar(nombre: string): Promise<string> {
|
|
24
|
+
if (Math.random() > 0.5) throw new Error('Random error');
|
|
25
|
+
return `¡Hola, ${nombre}!`;
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
2. Define your **workflow** logic. Include conditional branching, loops, etc to control activity execution. It's vanilla code written in your own coding style. The only requirement is to use `proxyActivities`, ensuring your activities are executed with HotMesh's durability guarantee.
|
|
29
|
+
```javascript
|
|
30
|
+
//workflows.ts
|
|
31
|
+
import { Durable } from '@hotmeshio/hotmesh';
|
|
32
|
+
import * as activities from './activities';
|
|
33
|
+
|
|
34
|
+
const { greet, saludar } = Durable.workflow
|
|
35
|
+
.proxyActivities<typeof activities>({
|
|
36
|
+
activities
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export async function example(name: string, lang: string): Promise<string> {
|
|
40
|
+
if (lang === 'es') {
|
|
41
|
+
return await saludar(name);
|
|
42
|
+
} else {
|
|
43
|
+
return await greet(name);
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
```
|
|
45
|
-
|
|
47
|
+
3. Although you could call your workflow directly (it's just a vanilla function), it's only durable when invoked and orchestrated via HotMesh.
|
|
46
48
|
```javascript
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
+
//client.ts
|
|
50
|
+
import { Durable } from '@hotmeshio/hotmesh';
|
|
49
51
|
import Redis from 'ioredis'; //OR `import * as Redis from 'redis';`
|
|
50
|
-
import {
|
|
51
|
-
|
|
52
|
-
export class MyWorkflow extends MeshOS {
|
|
53
|
-
|
|
54
|
-
//configure Redis
|
|
55
|
-
redisClass = Redis;
|
|
56
|
-
redisOptions = { host: 'localhost', port: 6379 };
|
|
57
|
-
|
|
58
|
-
//list functions to run as durable workflows
|
|
59
|
-
workflowFunctions = ['run'];
|
|
60
|
-
|
|
61
|
-
//list functions to retry and cache
|
|
62
|
-
proxyFunctions = ['flaky'];
|
|
63
|
-
|
|
64
|
-
//no need to change anything else!
|
|
65
|
-
|
|
66
|
-
async run(name: string): Promise<string> {
|
|
67
|
-
const hi = await this.flaky(name);
|
|
68
|
-
const hello = await this.greet(name);
|
|
69
|
-
return `${hi} ${hello}`;
|
|
70
|
-
}
|
|
52
|
+
import { nanoid } from 'nanoid';
|
|
71
53
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
54
|
+
async function run(): Promise<string> {
|
|
55
|
+
const client = new Durable.Client({
|
|
56
|
+
connection: {
|
|
57
|
+
class: Redis,
|
|
58
|
+
options: { host: 'localhost', port: 6379 }
|
|
75
59
|
}
|
|
76
|
-
|
|
77
|
-
}
|
|
60
|
+
});
|
|
78
61
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
62
|
+
const handle = await client.workflow.start({
|
|
63
|
+
args: ['HotMesh', 'es'],
|
|
64
|
+
taskQueue: 'hello-world',
|
|
65
|
+
workflowName: 'example',
|
|
66
|
+
workflowId: nanoid()
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return await handle.result();
|
|
70
|
+
//returns '¡Hola, HotMesh!'
|
|
82
71
|
}
|
|
83
72
|
```
|
|
84
|
-
|
|
73
|
+
4. Finally, create a **worker** and link your workflow function. Workers listen for tasks on their assigned Redis stream and invoke your workflow function each time they receive an event.
|
|
85
74
|
```javascript
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
75
|
+
//worker.ts
|
|
76
|
+
import { Durable } from '@hotmeshio/hotmesh';
|
|
77
|
+
import Redis from 'ioredis';
|
|
78
|
+
import * as workflows from './workflows';
|
|
79
|
+
|
|
80
|
+
async function run() {
|
|
81
|
+
const worker = await Durable.Worker.create({
|
|
82
|
+
connection: {
|
|
83
|
+
class: Redis,
|
|
84
|
+
options: { host: 'localhost', port: 6379 },
|
|
85
|
+
},
|
|
86
|
+
taskQueue: 'hello-world',
|
|
87
|
+
workflow: workflows.example,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await worker.run();
|
|
91
|
+
}
|
|
91
92
|
```
|
|
92
93
|
|
|
93
|
-
Redis governance delivers more than just reliability. Externalizing state fundamentally changes the execution profile for your functions, allowing you to design long-running, durable workflows. The `
|
|
94
|
+
Redis governance delivers more than just reliability. Externalizing state fundamentally changes the execution profile for your functions, allowing you to design long-running, durable workflows. The `Durable.workflow` base class (shown in the examples above) provides additional methods for solving the most common state management challenges.
|
|
95
|
+
|
|
96
|
+
Include statements like `await Durable.workflow.sleep('1 month')` to pause your workflow function for a month, or `await Durable.workflow.waitForSignal('signal1', 'signal2')` to pause your function until all signals have arrived.
|
|
94
97
|
|
|
95
98
|
- `waitForSignal` | Pause your function and wait for external event(s) before continuing. The *waitForSignal* method will collate and cache the signals and only awaken your function once all signals have arrived.
|
|
96
99
|
- `signal` | Send a signal (and optional payload) to any paused function.
|
package/build/package.json
CHANGED
|
@@ -76,8 +76,8 @@ class ClientService {
|
|
|
76
76
|
};
|
|
77
77
|
this.workflow = {
|
|
78
78
|
start: async (options) => {
|
|
79
|
-
const taskQueueName = options.taskQueue;
|
|
80
|
-
const workflowName = options.workflowName;
|
|
79
|
+
const taskQueueName = options.entity ?? options.taskQueue;
|
|
80
|
+
const workflowName = options.entity ?? options.workflowName;
|
|
81
81
|
const trc = options.workflowTrace;
|
|
82
82
|
const spn = options.workflowSpan;
|
|
83
83
|
//topic is concat of taskQueue and workflowName
|
|
@@ -31,16 +31,16 @@ class WorkflowService {
|
|
|
31
31
|
const workflowSpan = store.get('workflowSpan');
|
|
32
32
|
const COUNTER = store.get('counter');
|
|
33
33
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
const childJobId = options.workflowId ?? `${
|
|
34
|
+
//NOTE: this is the hash prefix; necessary for the search index to locate the entity
|
|
35
|
+
//if the hash is a helper, a dash begins it, so it isn't indexed
|
|
36
|
+
const entityOrEmptyString = options.entity ?? '';
|
|
37
|
+
//If the workflowId is not provided, it is generated from the entity and the workflow name
|
|
38
|
+
const childJobId = options.workflowId ?? `${entityOrEmptyString}-${workflowId}-$${options.entity ?? options.workflowName}${workflowDimension}-${execIndex}`;
|
|
39
39
|
const parentWorkflowId = `${workflowId}-f`;
|
|
40
40
|
const client = new client_1.ClientService({
|
|
41
41
|
connection: await connection_1.ConnectionService.connect(worker_1.WorkerService.connection),
|
|
42
42
|
});
|
|
43
|
-
let handle = await client.workflow.getHandle(options.taskQueue, options.workflowName, childJobId, namespace);
|
|
43
|
+
let handle = await client.workflow.getHandle(options.entity ?? options.taskQueue, options.entity ?? options.workflowName, childJobId, namespace);
|
|
44
44
|
try {
|
|
45
45
|
return await handle.result(true);
|
|
46
46
|
}
|
|
@@ -73,10 +73,12 @@ class WorkflowService {
|
|
|
73
73
|
const workflowSpan = store.get('workflowSpan');
|
|
74
74
|
const COUNTER = store.get('counter');
|
|
75
75
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
76
|
-
|
|
77
|
-
const
|
|
76
|
+
//NOTE: this is the hash prefix; necessary for the search index to locate the entity
|
|
77
|
+
const entityOrEmptyString = options.entity ?? '';
|
|
78
|
+
//If the workflowId is not provided, it is generated from the entity and the workflow name
|
|
79
|
+
const childJobId = options.workflowId ?? `${entityOrEmptyString}-${workflowId}-$${options.entity ?? options.workflowName}${workflowDimension}-${execIndex}`;
|
|
78
80
|
const parentWorkflowId = `${workflowId}-f`;
|
|
79
|
-
const workflowTopic = `${options.taskQueue}-${options.workflowName}`;
|
|
81
|
+
const workflowTopic = `${options.entity ?? options.taskQueue}-${options.entity ?? options.workflowName}`;
|
|
80
82
|
try {
|
|
81
83
|
//get the status; if there is no error, return childJobId (what was spawned)
|
|
82
84
|
const hotMeshClient = await worker_1.WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
@@ -247,8 +249,8 @@ class WorkflowService {
|
|
|
247
249
|
const store = asyncLocalStorage_1.asyncLocalStorage.getStore();
|
|
248
250
|
const workflowId = options.workflowId ?? store.get('workflowId');
|
|
249
251
|
let workflowTopic = store.get('workflowTopic');
|
|
250
|
-
if (options.taskQueue && options.workflowName) {
|
|
251
|
-
workflowTopic = `${options.taskQueue}-${options.workflowName}`;
|
|
252
|
+
if (options.entity || (options.taskQueue && options.workflowName)) {
|
|
253
|
+
workflowTopic = `${options.entity ?? options.taskQueue}-${options.entity ?? options.workflowName}`;
|
|
252
254
|
} //else this is essentially recursion as the function calls itself
|
|
253
255
|
const payload = {
|
|
254
256
|
arguments: [...options.args],
|
|
@@ -220,13 +220,12 @@ class StreamSignaler {
|
|
|
220
220
|
for (const instance of [...StreamSignaler.signalers]) {
|
|
221
221
|
instance.stopConsuming();
|
|
222
222
|
}
|
|
223
|
-
await (0, utils_1.sleepFor)(BLOCK_TIME_MS);
|
|
224
223
|
}
|
|
225
224
|
async stopConsuming() {
|
|
226
225
|
this.shouldConsume = false;
|
|
227
226
|
this.logger.info(`stream-consumer-stopping`, this.topic ? { topic: this.topic } : undefined);
|
|
228
227
|
this.cancelThrottle();
|
|
229
|
-
await
|
|
228
|
+
//await sleepFor(BLOCK_TIME_MS);
|
|
230
229
|
}
|
|
231
230
|
cancelThrottle() {
|
|
232
231
|
if (this.currentTimerId !== undefined) {
|
package/build/types/durable.d.ts
CHANGED
|
@@ -46,10 +46,10 @@ type WorkflowSearchOptions = {
|
|
|
46
46
|
};
|
|
47
47
|
type WorkflowOptions = {
|
|
48
48
|
namespace?: string;
|
|
49
|
-
taskQueue
|
|
49
|
+
taskQueue?: string;
|
|
50
50
|
args: any[];
|
|
51
51
|
workflowId?: string;
|
|
52
|
-
|
|
52
|
+
entity?: string;
|
|
53
53
|
workflowName?: string;
|
|
54
54
|
parentWorkflowId?: string;
|
|
55
55
|
workflowTrace?: string;
|
|
@@ -61,6 +61,7 @@ type HookOptions = {
|
|
|
61
61
|
namespace?: string;
|
|
62
62
|
taskQueue?: string;
|
|
63
63
|
args: any[];
|
|
64
|
+
entity?: string;
|
|
64
65
|
workflowId?: string;
|
|
65
66
|
workflowName?: string;
|
|
66
67
|
search?: WorkflowSearchOptions;
|
package/package.json
CHANGED
|
@@ -92,8 +92,8 @@ export class ClientService {
|
|
|
92
92
|
|
|
93
93
|
workflow = {
|
|
94
94
|
start: async (options: WorkflowOptions): Promise<WorkflowHandleService> => {
|
|
95
|
-
const taskQueueName = options.taskQueue;
|
|
96
|
-
const workflowName = options.workflowName;
|
|
95
|
+
const taskQueueName = options.entity ?? options.taskQueue;
|
|
96
|
+
const workflowName = options.entity ?? options.workflowName;
|
|
97
97
|
const trc = options.workflowTrace;
|
|
98
98
|
const spn = options.workflowSpan;
|
|
99
99
|
//topic is concat of taskQueue and workflowName
|
|
@@ -39,11 +39,11 @@ export class WorkflowService {
|
|
|
39
39
|
const workflowSpan = store.get('workflowSpan');
|
|
40
40
|
const COUNTER = store.get('counter');
|
|
41
41
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
const childJobId = options.workflowId ?? `${
|
|
42
|
+
//NOTE: this is the hash prefix; necessary for the search index to locate the entity
|
|
43
|
+
//if the hash is a helper, a dash begins it, so it isn't indexed
|
|
44
|
+
const entityOrEmptyString = options.entity ?? '';
|
|
45
|
+
//If the workflowId is not provided, it is generated from the entity and the workflow name
|
|
46
|
+
const childJobId = options.workflowId ?? `${entityOrEmptyString}-${workflowId}-$${options.entity ?? options.workflowName}${workflowDimension}-${execIndex}`;
|
|
47
47
|
const parentWorkflowId = `${workflowId}-f`;
|
|
48
48
|
|
|
49
49
|
const client = new Client({
|
|
@@ -51,8 +51,8 @@ export class WorkflowService {
|
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
let handle = await client.workflow.getHandle(
|
|
54
|
-
options.taskQueue,
|
|
55
|
-
options.workflowName,
|
|
54
|
+
options.entity ?? options.taskQueue,
|
|
55
|
+
options.entity ?? options.workflowName,
|
|
56
56
|
childJobId,
|
|
57
57
|
namespace,
|
|
58
58
|
);
|
|
@@ -89,10 +89,12 @@ export class WorkflowService {
|
|
|
89
89
|
const workflowSpan = store.get('workflowSpan');
|
|
90
90
|
const COUNTER = store.get('counter');
|
|
91
91
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
92
|
-
|
|
93
|
-
const
|
|
92
|
+
//NOTE: this is the hash prefix; necessary for the search index to locate the entity
|
|
93
|
+
const entityOrEmptyString = options.entity ?? '';
|
|
94
|
+
//If the workflowId is not provided, it is generated from the entity and the workflow name
|
|
95
|
+
const childJobId = options.workflowId ?? `${entityOrEmptyString}-${workflowId}-$${options.entity ?? options.workflowName}${workflowDimension}-${execIndex}`;
|
|
94
96
|
const parentWorkflowId = `${workflowId}-f`;
|
|
95
|
-
const workflowTopic = `${options.taskQueue}-${options.workflowName}`;
|
|
97
|
+
const workflowTopic = `${options.entity ?? options.taskQueue}-${options.entity ?? options.workflowName}`;
|
|
96
98
|
|
|
97
99
|
try {
|
|
98
100
|
//get the status; if there is no error, return childJobId (what was spawned)
|
|
@@ -273,8 +275,8 @@ export class WorkflowService {
|
|
|
273
275
|
const store = asyncLocalStorage.getStore();
|
|
274
276
|
const workflowId = options.workflowId ?? store.get('workflowId');
|
|
275
277
|
let workflowTopic = store.get('workflowTopic');
|
|
276
|
-
if (options.taskQueue && options.workflowName) {
|
|
277
|
-
workflowTopic = `${options.taskQueue}-${options.workflowName}`;
|
|
278
|
+
if (options.entity || (options.taskQueue && options.workflowName)) {
|
|
279
|
+
workflowTopic = `${options.entity ?? options.taskQueue}-${options.entity ?? options.workflowName}`;
|
|
278
280
|
} //else this is essentially recursion as the function calls itself
|
|
279
281
|
const payload = {
|
|
280
282
|
arguments: [...options.args],
|
|
@@ -254,14 +254,13 @@ class StreamSignaler {
|
|
|
254
254
|
for (const instance of [...StreamSignaler.signalers]) {
|
|
255
255
|
instance.stopConsuming();
|
|
256
256
|
}
|
|
257
|
-
await sleepFor(BLOCK_TIME_MS);
|
|
258
257
|
}
|
|
259
258
|
|
|
260
259
|
async stopConsuming() {
|
|
261
260
|
this.shouldConsume = false;
|
|
262
261
|
this.logger.info(`stream-consumer-stopping`, this.topic ? { topic: this.topic } : undefined);
|
|
263
262
|
this.cancelThrottle();
|
|
264
|
-
await sleepFor(BLOCK_TIME_MS);
|
|
263
|
+
//await sleepFor(BLOCK_TIME_MS);
|
|
265
264
|
}
|
|
266
265
|
|
|
267
266
|
cancelThrottle() {
|
package/types/durable.ts
CHANGED
|
@@ -54,11 +54,11 @@ type WorkflowSearchOptions = {
|
|
|
54
54
|
|
|
55
55
|
type WorkflowOptions = {
|
|
56
56
|
namespace?: string; //'durable' is the default namespace if not provided; similar to setting `appid` in the YAML
|
|
57
|
-
taskQueue
|
|
57
|
+
taskQueue?: string; //optional if entity is provided
|
|
58
58
|
args: any[]; //input arguments to pass in
|
|
59
59
|
workflowId?: string; //execution id (the job id)
|
|
60
|
-
|
|
61
|
-
workflowName?: string; //the name of the user's workflow function
|
|
60
|
+
entity?: string; //If invoking a workflow, passing 'entity' will apply the value as the workflowName, taskQueue, and prefix, ensuring the FT.SEARCH index is properly scoped. This is a convenience method but limits options.
|
|
61
|
+
workflowName?: string; //the name of the user's workflow function; optional if 'entity' is provided
|
|
62
62
|
parentWorkflowId?: string; //system reserved; the id of the parent; if present the flow will not self-clean until the parent that spawned it self-cleans
|
|
63
63
|
workflowTrace?: string;
|
|
64
64
|
workflowSpan?: string;
|
|
@@ -68,8 +68,9 @@ type WorkflowOptions = {
|
|
|
68
68
|
|
|
69
69
|
type HookOptions = {
|
|
70
70
|
namespace?: string; //'durable' is the default namespace if not provided; similar to setting `appid` in the YAML
|
|
71
|
-
taskQueue?: string;
|
|
71
|
+
taskQueue?: string; //optional if 'entity' is provided
|
|
72
72
|
args: any[]; //input arguments to pass into the hook
|
|
73
|
+
entity?: string; //If invoking a hook, passing 'entity' will apply the value as the workflowName, taskQueue, and prefix, ensuring the FT.SEARCH index is properly scoped. This is a convenience method but limits options.
|
|
73
74
|
workflowId?: string; //execution id (the job id to hook into)
|
|
74
75
|
workflowName?: string; //the name of the user's hook function
|
|
75
76
|
search?: WorkflowSearchOptions //bind additional search terms immediately before hook reentry
|