@hotmeshio/hotmesh 0.0.47 → 0.0.49
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 +1 -1
- package/build/package.json +1 -1
- package/build/services/durable/meshos.js +11 -6
- package/build/services/durable/search.d.ts +20 -1
- package/build/services/durable/search.js +73 -25
- package/build/services/durable/worker.js +10 -0
- package/build/services/durable/workflow.d.ts +1 -0
- package/build/services/durable/workflow.js +17 -1
- package/build/services/engine/index.d.ts +1 -0
- package/build/services/engine/index.js +1 -0
- package/build/services/quorum/index.js +4 -0
- package/build/services/router/index.d.ts +15 -0
- package/build/services/router/index.js +55 -7
- package/build/services/store/clients/redis.js +2 -0
- package/build/services/store/index.d.ts +2 -0
- package/build/services/store/index.js +44 -0
- package/build/services/worker/index.d.ts +1 -0
- package/build/services/worker/index.js +5 -0
- package/build/types/durable.d.ts +9 -0
- package/build/types/quorum.d.ts +4 -0
- package/package.json +1 -1
- package/services/durable/meshos.ts +10 -6
- package/services/durable/search.ts +73 -26
- package/services/durable/worker.ts +13 -1
- package/services/durable/workflow.ts +18 -0
- package/services/engine/index.ts +2 -0
- package/services/quorum/index.ts +4 -0
- package/services/router/index.ts +59 -9
- package/services/store/clients/redis.ts +2 -0
- package/services/store/index.ts +59 -0
- package/services/worker/index.ts +6 -0
- package/types/durable.ts +11 -0
- package/types/quorum.ts +4 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# HotMesh
|
|
2
2
|

|
|
3
3
|
|
|
4
|
-
HotMesh
|
|
4
|
+
HotMesh transforms Redis into a distributed orchestration engine.
|
|
5
5
|
|
|
6
6
|
*Write functions in your own preferred style, and let Redis govern their execution, reliably and durably.*
|
|
7
7
|
|
package/build/package.json
CHANGED
|
@@ -187,13 +187,18 @@ class MeshOSService {
|
|
|
187
187
|
}
|
|
188
188
|
else {
|
|
189
189
|
//limit which hash fields to return
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
args.push('RETURN');
|
|
191
|
+
args.push(((options.return?.length ?? 0) + 1).toString());
|
|
192
|
+
args.push('$');
|
|
193
|
+
options.return?.forEach(returnField => {
|
|
194
|
+
if (returnField.startsWith('"')) {
|
|
195
|
+
//allow literal values to be requested
|
|
196
|
+
args.push(returnField.slice(1, -1));
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
194
199
|
args.push(`_${returnField}`);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
197
202
|
//paginate
|
|
198
203
|
if (options.limit) {
|
|
199
204
|
args.push('LIMIT', options.limit.start.toString(), options.limit.size.toString());
|
|
@@ -30,10 +30,29 @@ export declare class Search {
|
|
|
30
30
|
* calling any method that produces side effects (changes the value)
|
|
31
31
|
*/
|
|
32
32
|
getSearchSessionGuid(): string;
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Sets the fields listed in args. Returns the
|
|
35
|
+
* count of new fields that were set (does not
|
|
36
|
+
* count fields that were updated)
|
|
37
|
+
*/
|
|
38
|
+
set(...args: string[]): Promise<number>;
|
|
34
39
|
get(key: string): Promise<string>;
|
|
35
40
|
mget(...args: string[]): Promise<string[]>;
|
|
41
|
+
/**
|
|
42
|
+
* Deletes the fields listed in args. Returns the
|
|
43
|
+
* count of fields that were deleted.
|
|
44
|
+
*/
|
|
36
45
|
del(...args: string[]): Promise<number | void>;
|
|
46
|
+
/**
|
|
47
|
+
* Increments the value of a field by the given amount. Returns the
|
|
48
|
+
* new value of the field after the increment. Can be
|
|
49
|
+
* used to decrement the value of a field by specifying a negative.
|
|
50
|
+
*/
|
|
37
51
|
incr(key: string, val: number): Promise<number>;
|
|
52
|
+
/**
|
|
53
|
+
* Multiplies the value of a field by the given amount. Returns the
|
|
54
|
+
* new value of the field after the multiplication. NOTE:
|
|
55
|
+
* this is exponential multiplication.
|
|
56
|
+
*/
|
|
38
57
|
mult(key: string, val: number): Promise<number>;
|
|
39
58
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Search = void 0;
|
|
4
4
|
const key_1 = require("../../modules/key");
|
|
5
|
+
const storage_1 = require("../../modules/storage");
|
|
5
6
|
class Search {
|
|
6
7
|
constructor(workflowId, hotMeshClient, searchSessionId) {
|
|
7
8
|
this.searchSessionIndex = 0;
|
|
@@ -57,9 +58,15 @@ class Search {
|
|
|
57
58
|
* @returns {Promise<string[]>} - the list of search indexes
|
|
58
59
|
*/
|
|
59
60
|
static async listSearchIndexes(hotMeshClient) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
try {
|
|
62
|
+
const store = hotMeshClient.engine.store;
|
|
63
|
+
const searchIndexes = await store.exec('FT._LIST');
|
|
64
|
+
return searchIndexes;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
hotMeshClient.engine.logger.info('durable-client-search-list-err', { err });
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
63
70
|
}
|
|
64
71
|
/**
|
|
65
72
|
* increments the index to return a unique search session guid when
|
|
@@ -69,18 +76,28 @@ class Search {
|
|
|
69
76
|
//return the search session as it would exist in the search session index
|
|
70
77
|
return `${this.searchSessionId}-${this.searchSessionIndex++}-`;
|
|
71
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Sets the fields listed in args. Returns the
|
|
81
|
+
* count of new fields that were set (does not
|
|
82
|
+
* count fields that were updated)
|
|
83
|
+
*/
|
|
72
84
|
async set(...args) {
|
|
73
85
|
const ssGuid = this.getSearchSessionGuid();
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const key = this.safeKey(args[i]);
|
|
79
|
-
const value = args[i + 1].toString();
|
|
80
|
-
safeArgs.push(key, value);
|
|
81
|
-
}
|
|
82
|
-
await this.store.exec('HSET', this.jobId, ...safeArgs);
|
|
86
|
+
const store = storage_1.asyncLocalStorage.getStore();
|
|
87
|
+
const replay = store?.get('replay') ?? {};
|
|
88
|
+
if (ssGuid in replay) {
|
|
89
|
+
return Number(replay[ssGuid]);
|
|
83
90
|
}
|
|
91
|
+
const safeArgs = [];
|
|
92
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
93
|
+
const key = this.safeKey(args[i]);
|
|
94
|
+
const value = args[i + 1].toString();
|
|
95
|
+
safeArgs.push(key, value);
|
|
96
|
+
}
|
|
97
|
+
const fieldCount = await this.store.exec('HSET', this.jobId, ...safeArgs);
|
|
98
|
+
//no need to wait; set this interim value in the replay
|
|
99
|
+
this.store.exec('HSET', this.jobId, ssGuid, fieldCount.toString());
|
|
100
|
+
return Number(fieldCount);
|
|
84
101
|
}
|
|
85
102
|
async get(key) {
|
|
86
103
|
try {
|
|
@@ -104,32 +121,63 @@ class Search {
|
|
|
104
121
|
return [];
|
|
105
122
|
}
|
|
106
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Deletes the fields listed in args. Returns the
|
|
126
|
+
* count of fields that were deleted.
|
|
127
|
+
*/
|
|
107
128
|
async del(...args) {
|
|
108
129
|
const ssGuid = this.getSearchSessionGuid();
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
130
|
+
const store = storage_1.asyncLocalStorage.getStore();
|
|
131
|
+
const replay = store?.get('replay') ?? {};
|
|
132
|
+
if (ssGuid in replay) {
|
|
133
|
+
return Number(replay[ssGuid]);
|
|
134
|
+
}
|
|
135
|
+
const safeArgs = [];
|
|
136
|
+
for (let i = 0; i < args.length; i++) {
|
|
137
|
+
safeArgs.push(this.safeKey(args[i]));
|
|
117
138
|
}
|
|
139
|
+
const response = await this.store.exec('HDEL', this.jobId, ...safeArgs);
|
|
140
|
+
const formattedResponse = isNaN(response) ? 0 : Number(response);
|
|
141
|
+
//no need to wait; set this interim value in the replay
|
|
142
|
+
this.store.exec('HSET', this.jobId, ssGuid, formattedResponse.toString());
|
|
143
|
+
return formattedResponse;
|
|
118
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Increments the value of a field by the given amount. Returns the
|
|
147
|
+
* new value of the field after the increment. Can be
|
|
148
|
+
* used to decrement the value of a field by specifying a negative.
|
|
149
|
+
*/
|
|
119
150
|
async incr(key, val) {
|
|
120
151
|
const ssGuid = this.getSearchSessionGuid();
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
152
|
+
const store = storage_1.asyncLocalStorage.getStore();
|
|
153
|
+
const replay = store?.get('replay') ?? {};
|
|
154
|
+
if (ssGuid in replay) {
|
|
155
|
+
return Number(replay[ssGuid]);
|
|
124
156
|
}
|
|
157
|
+
const num = await this.store.exec('HINCRBYFLOAT', this.jobId, this.safeKey(key), val.toString());
|
|
158
|
+
//no need to wait; set this interim value in the replay
|
|
159
|
+
this.store.exec('HSET', this.jobId, ssGuid, num.toString());
|
|
160
|
+
return Number(num);
|
|
125
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Multiplies the value of a field by the given amount. Returns the
|
|
164
|
+
* new value of the field after the multiplication. NOTE:
|
|
165
|
+
* this is exponential multiplication.
|
|
166
|
+
*/
|
|
126
167
|
async mult(key, val) {
|
|
127
168
|
const ssGuid = this.getSearchSessionGuid();
|
|
169
|
+
const store = storage_1.asyncLocalStorage.getStore();
|
|
170
|
+
const replay = store?.get('replay') ?? {};
|
|
171
|
+
if (ssGuid in replay) {
|
|
172
|
+
return Math.exp(Number(replay[ssGuid]));
|
|
173
|
+
}
|
|
128
174
|
const ssGuidValue = Number(await this.store.exec('HINCRBYFLOAT', this.jobId, ssGuid, '1'));
|
|
129
175
|
if (ssGuidValue === 1) {
|
|
130
176
|
const log = Math.log(val);
|
|
131
|
-
const logTotal =
|
|
132
|
-
|
|
177
|
+
const logTotal = await this.store.exec('HINCRBYFLOAT', this.jobId, this.safeKey(key), log.toString());
|
|
178
|
+
//no need to wait; set this interim value in the replay
|
|
179
|
+
this.store.exec('HSET', this.jobId, ssGuid, logTotal.toString());
|
|
180
|
+
return Math.exp(Number(logTotal));
|
|
133
181
|
}
|
|
134
182
|
}
|
|
135
183
|
}
|
|
@@ -164,16 +164,26 @@ class WorkerService {
|
|
|
164
164
|
// garbage collect (expire) this job when originJobId is expired
|
|
165
165
|
context.set('originJobId', workflowInput.originJobId);
|
|
166
166
|
}
|
|
167
|
+
let replayQuery = '';
|
|
167
168
|
if (workflowInput.workflowDimension) {
|
|
168
169
|
//every hook function runs in an isolated dimension controlled
|
|
169
170
|
//by the index assigned when the signal was received; even if the
|
|
170
171
|
//hook function re-runs, its scope will always remain constant
|
|
171
172
|
context.set('workflowDimension', workflowInput.workflowDimension);
|
|
173
|
+
replayQuery = `-*${workflowInput.workflowDimension}-*`;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
//last letter of words like 'hook', 'sleep', 'wait', 'signal', 'search', 'start'
|
|
177
|
+
replayQuery = '-*[ehklpt]-*';
|
|
172
178
|
}
|
|
173
179
|
context.set('workflowTopic', workflowTopic);
|
|
174
180
|
context.set('workflowName', workflowTopic.split('-').pop());
|
|
175
181
|
context.set('workflowTrace', data.metadata.trc);
|
|
176
182
|
context.set('workflowSpan', data.metadata.spn);
|
|
183
|
+
const store = this.workflowRunner.engine.store;
|
|
184
|
+
const [cursor, replay] = await store.findJobFields(workflowInput.workflowId, replayQuery, 50000, 5000);
|
|
185
|
+
context.set('replay', replay);
|
|
186
|
+
context.set('cursor', cursor); // if != 0, more remain
|
|
177
187
|
const workflowResponse = await storage_1.asyncLocalStorage.run(context, async () => {
|
|
178
188
|
return await workflowFunction.apply(this, workflowInput.arguments);
|
|
179
189
|
});
|
|
@@ -76,6 +76,10 @@ class WorkflowService {
|
|
|
76
76
|
const COUNTER = store.get('counter');
|
|
77
77
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
78
78
|
const sessionId = `-start${workflowDimension}-${execIndex}-`;
|
|
79
|
+
const replay = store.get('replay');
|
|
80
|
+
if (sessionId in replay) {
|
|
81
|
+
return replay[sessionId];
|
|
82
|
+
}
|
|
79
83
|
//NOTE: this is the hash prefix; necessary for the search index to locate the entity
|
|
80
84
|
const entityOrEmptyString = options.entity ?? '';
|
|
81
85
|
//If the workflowId is not provided, it is generated from the entity and the workflow name
|
|
@@ -172,6 +176,8 @@ class WorkflowService {
|
|
|
172
176
|
static getContext() {
|
|
173
177
|
const store = storage_1.asyncLocalStorage.getStore();
|
|
174
178
|
const workflowId = store.get('workflowId');
|
|
179
|
+
const replay = store.get('replay');
|
|
180
|
+
const cursor = store.get('cursor');
|
|
175
181
|
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
176
182
|
const workflowTopic = store.get('workflowTopic');
|
|
177
183
|
const namespace = store.get('namespace');
|
|
@@ -180,7 +186,9 @@ class WorkflowService {
|
|
|
180
186
|
const COUNTER = store.get('counter');
|
|
181
187
|
return {
|
|
182
188
|
counter: COUNTER.counter,
|
|
189
|
+
cursor,
|
|
183
190
|
namespace,
|
|
191
|
+
replay,
|
|
184
192
|
workflowId,
|
|
185
193
|
workflowDimension,
|
|
186
194
|
workflowTopic,
|
|
@@ -201,6 +209,10 @@ class WorkflowService {
|
|
|
201
209
|
const COUNTER = store.get('counter');
|
|
202
210
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
203
211
|
const sessionId = `-${prefix}${workflowDimension}-${execIndex}-`;
|
|
212
|
+
const replay = store.get('replay');
|
|
213
|
+
if (sessionId in replay) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
204
216
|
const keyParams = {
|
|
205
217
|
appId: hotMeshClient.appId,
|
|
206
218
|
jobId: workflowId
|
|
@@ -274,6 +286,7 @@ class WorkflowService {
|
|
|
274
286
|
workflowTopic: store.get('workflowTopic'),
|
|
275
287
|
workflowDimension: store.get('workflowDimension') ?? '',
|
|
276
288
|
counter: store.get('counter'),
|
|
289
|
+
replay: store.get('replay'),
|
|
277
290
|
};
|
|
278
291
|
}
|
|
279
292
|
/**
|
|
@@ -284,9 +297,12 @@ class WorkflowService {
|
|
|
284
297
|
* @template T - the result type
|
|
285
298
|
*/
|
|
286
299
|
static async once(fn, ...args) {
|
|
287
|
-
const { workflowId, namespace, workflowTopic, workflowDimension, counter: COUNTER, } = WorkflowService.getLocalState();
|
|
300
|
+
const { workflowId, namespace, workflowTopic, workflowDimension, counter: COUNTER, replay, } = WorkflowService.getLocalState();
|
|
288
301
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
289
302
|
const sessionId = `-once${workflowDimension}-${execIndex}-`;
|
|
303
|
+
if (sessionId in replay) {
|
|
304
|
+
return JSON.parse(replay[sessionId]);
|
|
305
|
+
}
|
|
290
306
|
const hotMeshClient = await worker_1.WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
291
307
|
const keyParams = {
|
|
292
308
|
appId: hotMeshClient.appId,
|
|
@@ -41,6 +41,7 @@ declare class EngineService {
|
|
|
41
41
|
jobCallbacks: Record<string, JobMessageCallback>;
|
|
42
42
|
reporting: boolean;
|
|
43
43
|
jobId: number;
|
|
44
|
+
inited: string;
|
|
44
45
|
static init(namespace: string, appId: string, guid: string, config: HotMeshConfig, logger: ILogger): Promise<EngineService>;
|
|
45
46
|
verifyEngineFields(config: HotMeshConfig): void;
|
|
46
47
|
initStoreChannel(store: RedisClient): Promise<void>;
|
|
@@ -44,6 +44,7 @@ class EngineService {
|
|
|
44
44
|
instance.router.consumeMessages(instance.stream.mintKey(key_1.KeyType.STREAMS, { appId: instance.appId }), 'ENGINE', instance.guid, instance.processStreamMessage.bind(instance));
|
|
45
45
|
instance.taskService = new task_1.TaskService(instance.store, logger);
|
|
46
46
|
instance.exporter = new exporter_1.ExporterService(instance.appId, instance.store, logger);
|
|
47
|
+
instance.inited = (0, utils_1.formatISODate)(new Date());
|
|
47
48
|
return instance;
|
|
48
49
|
}
|
|
49
50
|
}
|
|
@@ -107,6 +107,10 @@ class QuorumService {
|
|
|
107
107
|
stream,
|
|
108
108
|
counts: this.engine.router.counts,
|
|
109
109
|
timestamp: (0, utils_1.formatISODate)(new Date()),
|
|
110
|
+
inited: this.engine.inited,
|
|
111
|
+
throttle: this.engine.router.throttle,
|
|
112
|
+
reclaimDelay: this.engine.router.reclaimDelay,
|
|
113
|
+
reclaimCount: this.engine.router.reclaimCount,
|
|
110
114
|
system: await (0, utils_1.getSystemHealth)(),
|
|
111
115
|
};
|
|
112
116
|
}
|
|
@@ -22,9 +22,24 @@ declare class Router {
|
|
|
22
22
|
};
|
|
23
23
|
currentTimerId: NodeJS.Timeout | null;
|
|
24
24
|
shouldConsume: boolean;
|
|
25
|
+
sleepPromiseResolve: (() => void) | null;
|
|
26
|
+
innerPromiseResolve: (() => void) | null;
|
|
27
|
+
isSleeping: boolean;
|
|
28
|
+
sleepTimout: NodeJS.Timeout | null;
|
|
25
29
|
constructor(config: StreamConfig, stream: StreamService<RedisClient, RedisMulti>, store: StoreService<RedisClient, RedisMulti>, logger: ILogger);
|
|
30
|
+
private resetThrottleState;
|
|
26
31
|
createGroup(stream: string, group: string): Promise<void>;
|
|
27
32
|
publishMessage(topic: string, streamData: StreamData | StreamDataResponse, multi?: RedisMulti): Promise<string | RedisMulti>;
|
|
33
|
+
/**
|
|
34
|
+
* An adjustable throttle that will interrupt a sleeping
|
|
35
|
+
* router if the throttle is reduced and the sleep time
|
|
36
|
+
* has elapsed. If the throttle is increased, or if
|
|
37
|
+
* the sleep time has not elapsed, the router will continue
|
|
38
|
+
* to sleep until the new termination point. This
|
|
39
|
+
* allows for dynamic, elastic throttling with smooth
|
|
40
|
+
* acceleration and deceleration.
|
|
41
|
+
*/
|
|
42
|
+
customSleep(): Promise<void>;
|
|
28
43
|
consumeMessages(stream: string, group: string, consumer: string, callback: (streamData: StreamData) => Promise<StreamDataResponse | void>): Promise<void>;
|
|
29
44
|
isStreamMessage(result: any): boolean;
|
|
30
45
|
consumeOne(stream: string, group: string, id: string, message: string[], callback: (streamData: StreamData) => Promise<StreamDataResponse | void>): Promise<void>;
|
|
@@ -12,6 +12,10 @@ class Router {
|
|
|
12
12
|
this.errorCount = 0;
|
|
13
13
|
this.counts = {};
|
|
14
14
|
this.currentTimerId = null;
|
|
15
|
+
this.sleepPromiseResolve = null;
|
|
16
|
+
this.innerPromiseResolve = null;
|
|
17
|
+
this.isSleeping = false;
|
|
18
|
+
this.sleepTimout = null;
|
|
15
19
|
this.appId = config.appId;
|
|
16
20
|
this.guid = config.guid;
|
|
17
21
|
this.role = config.role;
|
|
@@ -21,6 +25,13 @@ class Router {
|
|
|
21
25
|
this.reclaimDelay = config.reclaimDelay || enums_1.HMSH_XCLAIM_DELAY_MS;
|
|
22
26
|
this.reclaimCount = config.reclaimCount || enums_1.HMSH_XCLAIM_COUNT;
|
|
23
27
|
this.logger = logger;
|
|
28
|
+
this.resetThrottleState();
|
|
29
|
+
}
|
|
30
|
+
resetThrottleState() {
|
|
31
|
+
this.sleepPromiseResolve = null;
|
|
32
|
+
this.innerPromiseResolve = null;
|
|
33
|
+
this.isSleeping = false;
|
|
34
|
+
this.sleepTimout = null;
|
|
24
35
|
}
|
|
25
36
|
async createGroup(stream, group) {
|
|
26
37
|
try {
|
|
@@ -36,6 +47,36 @@ class Router {
|
|
|
36
47
|
const stream = this.store.mintKey(key_1.KeyType.STREAMS, { appId: this.store.appId, topic });
|
|
37
48
|
return await this.store.xadd(stream, '*', 'message', JSON.stringify(streamData), multi);
|
|
38
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* An adjustable throttle that will interrupt a sleeping
|
|
52
|
+
* router if the throttle is reduced and the sleep time
|
|
53
|
+
* has elapsed. If the throttle is increased, or if
|
|
54
|
+
* the sleep time has not elapsed, the router will continue
|
|
55
|
+
* to sleep until the new termination point. This
|
|
56
|
+
* allows for dynamic, elastic throttling with smooth
|
|
57
|
+
* acceleration and deceleration.
|
|
58
|
+
*/
|
|
59
|
+
async customSleep() {
|
|
60
|
+
if (this.throttle === 0)
|
|
61
|
+
return;
|
|
62
|
+
if (this.isSleeping)
|
|
63
|
+
return;
|
|
64
|
+
this.isSleeping = true;
|
|
65
|
+
let startTime = Date.now(); //anchor the origin
|
|
66
|
+
await new Promise(async (outerResolve) => {
|
|
67
|
+
this.sleepPromiseResolve = outerResolve;
|
|
68
|
+
let elapsedTime = Date.now() - startTime;
|
|
69
|
+
while (elapsedTime < this.throttle) {
|
|
70
|
+
await new Promise((innerResolve) => {
|
|
71
|
+
this.innerPromiseResolve = innerResolve;
|
|
72
|
+
this.sleepTimout = setTimeout(innerResolve, this.throttle - elapsedTime);
|
|
73
|
+
});
|
|
74
|
+
elapsedTime = Date.now() - startTime;
|
|
75
|
+
}
|
|
76
|
+
this.resetThrottleState();
|
|
77
|
+
outerResolve();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
39
80
|
async consumeMessages(stream, group, consumer, callback) {
|
|
40
81
|
this.logger.info(`stream-consumer-starting`, { group, consumer, stream });
|
|
41
82
|
Router.instances.add(this);
|
|
@@ -43,9 +84,7 @@ class Router {
|
|
|
43
84
|
await this.createGroup(stream, group);
|
|
44
85
|
let lastCheckedPendingMessagesAt = Date.now();
|
|
45
86
|
async function consume() {
|
|
46
|
-
|
|
47
|
-
this.currentTimerId = sleep.timerId;
|
|
48
|
-
await sleep.promise;
|
|
87
|
+
await this.customSleep();
|
|
49
88
|
if (!this.shouldConsume) {
|
|
50
89
|
this.logger.info(`stream-consumer-stopping`, { group, consumer, stream });
|
|
51
90
|
return;
|
|
@@ -229,17 +268,26 @@ class Router {
|
|
|
229
268
|
this.cancelThrottle();
|
|
230
269
|
}
|
|
231
270
|
cancelThrottle() {
|
|
232
|
-
if (this.
|
|
233
|
-
clearTimeout(this.
|
|
234
|
-
this.currentTimerId = undefined;
|
|
271
|
+
if (this.sleepTimout) {
|
|
272
|
+
clearTimeout(this.sleepTimout);
|
|
235
273
|
}
|
|
274
|
+
this.resetThrottleState();
|
|
236
275
|
}
|
|
237
276
|
setThrottle(delayInMillis) {
|
|
238
277
|
if (!Number.isInteger(delayInMillis) || delayInMillis < 0) {
|
|
239
278
|
throw new Error('Throttle must be a non-negative integer');
|
|
240
279
|
}
|
|
280
|
+
const wasDecreased = delayInMillis < this.throttle;
|
|
241
281
|
this.throttle = delayInMillis;
|
|
242
|
-
|
|
282
|
+
// If the throttle was decreased, and we're in the middle of a sleep cycle, adjust immediately
|
|
283
|
+
if (wasDecreased) {
|
|
284
|
+
if (this.sleepTimout) {
|
|
285
|
+
clearTimeout(this.sleepTimout);
|
|
286
|
+
}
|
|
287
|
+
if (this.innerPromiseResolve) {
|
|
288
|
+
this.innerPromiseResolve();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
243
291
|
}
|
|
244
292
|
async claimUnacknowledged(stream, group, consumer, idleTimeMs = this.reclaimDelay, limit = enums_1.HMSH_XPENDING_COUNT) {
|
|
245
293
|
let pendingMessages = [];
|
|
@@ -10,6 +10,7 @@ class RedisStoreService extends index_1.StoreService {
|
|
|
10
10
|
setnx: 'SETNX',
|
|
11
11
|
del: 'DEL',
|
|
12
12
|
expire: 'EXPIRE',
|
|
13
|
+
hscan: 'HSCAN',
|
|
13
14
|
hset: 'HSET',
|
|
14
15
|
hsetnx: 'HSETNX',
|
|
15
16
|
hincrby: 'HINCRBY',
|
|
@@ -29,6 +30,7 @@ class RedisStoreService extends index_1.StoreService {
|
|
|
29
30
|
lpop: 'LPOP',
|
|
30
31
|
rename: 'RENAME',
|
|
31
32
|
rpush: 'RPUSH',
|
|
33
|
+
scan: 'SCAN',
|
|
32
34
|
xack: 'XACK',
|
|
33
35
|
xdel: 'XDEL',
|
|
34
36
|
xlen: 'XLEN',
|
|
@@ -152,5 +152,7 @@ declare abstract class StoreService<T, U extends AbstractRedisClient> {
|
|
|
152
152
|
*/
|
|
153
153
|
interrupt(topic: string, jobId: string, options?: JobInterruptOptions): Promise<void>;
|
|
154
154
|
scrub(jobId: string): Promise<void>;
|
|
155
|
+
findJobs(queryString?: string, limit?: number, batchSize?: number): Promise<string[]>;
|
|
156
|
+
findJobFields(jobId: string, fieldMatchPattern?: string, limit?: number, batchSize?: number, cursor?: string): Promise<[string, StringStringType]>;
|
|
155
157
|
}
|
|
156
158
|
export { StoreService };
|
|
@@ -38,6 +38,7 @@ class StoreService {
|
|
|
38
38
|
del: 'del',
|
|
39
39
|
expire: 'expire',
|
|
40
40
|
hset: 'hset',
|
|
41
|
+
hscan: 'hscan',
|
|
41
42
|
hsetnx: 'hsetnx',
|
|
42
43
|
hincrby: 'hincrby',
|
|
43
44
|
hdel: 'hdel',
|
|
@@ -56,6 +57,7 @@ class StoreService {
|
|
|
56
57
|
lrange: 'lrange',
|
|
57
58
|
rename: 'rename',
|
|
58
59
|
rpush: 'rpush',
|
|
60
|
+
scan: 'scan',
|
|
59
61
|
xack: 'xack',
|
|
60
62
|
xdel: 'xdel',
|
|
61
63
|
};
|
|
@@ -876,5 +878,47 @@ class StoreService {
|
|
|
876
878
|
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId: this.appId, jobId });
|
|
877
879
|
await this.redisClient[this.commands.del](jobKey);
|
|
878
880
|
}
|
|
881
|
+
async findJobs(queryString = '*', limit = 1000, batchSize = 1000) {
|
|
882
|
+
const matchKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId: this.appId, jobId: queryString });
|
|
883
|
+
let cursor = '0';
|
|
884
|
+
let keys;
|
|
885
|
+
const matchingKeys = [];
|
|
886
|
+
do {
|
|
887
|
+
const output = await this.exec('SCAN', cursor, 'MATCH', matchKey, 'COUNT', batchSize.toString());
|
|
888
|
+
if (Array.isArray(output)) {
|
|
889
|
+
[cursor, keys] = output;
|
|
890
|
+
for (let key of [...keys]) {
|
|
891
|
+
matchingKeys.push(key);
|
|
892
|
+
}
|
|
893
|
+
if (matchingKeys.length >= limit) {
|
|
894
|
+
break;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
} while (cursor !== '0');
|
|
901
|
+
return matchingKeys;
|
|
902
|
+
}
|
|
903
|
+
async findJobFields(jobId, fieldMatchPattern = '*', limit = 1000, batchSize = 1000, cursor = '0') {
|
|
904
|
+
let fields = [];
|
|
905
|
+
const matchingFields = {};
|
|
906
|
+
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId: this.appId, jobId });
|
|
907
|
+
let len = 0;
|
|
908
|
+
do {
|
|
909
|
+
const output = await this.exec('HSCAN', jobKey, cursor, 'MATCH', fieldMatchPattern, 'COUNT', batchSize.toString());
|
|
910
|
+
if (Array.isArray(output)) {
|
|
911
|
+
[cursor, fields] = output;
|
|
912
|
+
for (let i = 0; i < fields.length; i += 2) {
|
|
913
|
+
len++;
|
|
914
|
+
matchingFields[fields[i]] = fields[i + 1];
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
break;
|
|
919
|
+
}
|
|
920
|
+
} while (cursor !== '0' && len < limit);
|
|
921
|
+
return [cursor, matchingFields];
|
|
922
|
+
}
|
|
879
923
|
}
|
|
880
924
|
exports.StoreService = StoreService;
|
|
@@ -18,6 +18,7 @@ declare class WorkerService {
|
|
|
18
18
|
router: Router | null;
|
|
19
19
|
logger: ILogger;
|
|
20
20
|
reporting: boolean;
|
|
21
|
+
inited: string;
|
|
21
22
|
static init(namespace: string, appId: string, guid: string, config: HotMeshConfig, logger: ILogger): Promise<WorkerService[]>;
|
|
22
23
|
verifyWorkerFields(worker: HotMeshWorker): void;
|
|
23
24
|
initStoreChannel(service: WorkerService, store: RedisClient): Promise<void>;
|
|
@@ -38,6 +38,7 @@ class WorkerService {
|
|
|
38
38
|
service.router = service.initRouter(worker, logger);
|
|
39
39
|
const key = service.stream.mintKey(key_1.KeyType.STREAMS, { appId: service.appId, topic: worker.topic });
|
|
40
40
|
await service.router.consumeMessages(key, 'WORKER', service.guid, worker.callback);
|
|
41
|
+
service.inited = (0, utils_1.formatISODate)(new Date());
|
|
41
42
|
services.push(service);
|
|
42
43
|
}
|
|
43
44
|
}
|
|
@@ -116,6 +117,10 @@ class WorkerService {
|
|
|
116
117
|
stream: this.stream.mintKey(key_1.KeyType.STREAMS, params),
|
|
117
118
|
counts: this.router.counts,
|
|
118
119
|
timestamp: (0, utils_1.formatISODate)(new Date()),
|
|
120
|
+
inited: this.inited,
|
|
121
|
+
throttle: this.router.throttle,
|
|
122
|
+
reclaimDelay: this.router.reclaimDelay,
|
|
123
|
+
reclaimCount: this.router.reclaimCount,
|
|
119
124
|
system: await (0, utils_1.getSystemHealth)(),
|
|
120
125
|
};
|
|
121
126
|
}
|
package/build/types/durable.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LogLevel } from './logger';
|
|
2
2
|
import { RedisClass, RedisOptions } from './redis';
|
|
3
|
+
import { StringStringType } from './serializer';
|
|
3
4
|
type WorkflowConfig = {
|
|
4
5
|
backoffCoefficient?: number;
|
|
5
6
|
maximumAttempts?: number;
|
|
@@ -11,6 +12,14 @@ type WorkflowContext = {
|
|
|
11
12
|
* the reentrant semaphore, incremented in real-time as idempotent statements are re-traversed upon reentry. Indicates the current semaphore count.
|
|
12
13
|
*/
|
|
13
14
|
counter: number;
|
|
15
|
+
/**
|
|
16
|
+
* number as string for the replay cursor
|
|
17
|
+
*/
|
|
18
|
+
cursor: string;
|
|
19
|
+
/**
|
|
20
|
+
* the replay hash of name/value pairs representing prior executions
|
|
21
|
+
*/
|
|
22
|
+
replay: StringStringType;
|
|
14
23
|
/**
|
|
15
24
|
* the HotMesh App namespace. `durable` is the default.
|
|
16
25
|
*/
|
package/build/types/quorum.d.ts
CHANGED
|
@@ -39,7 +39,11 @@ export interface QuorumProfile {
|
|
|
39
39
|
stream?: string;
|
|
40
40
|
stream_depth?: number;
|
|
41
41
|
counts?: Record<string, number>;
|
|
42
|
+
inited?: string;
|
|
42
43
|
timestamp?: string;
|
|
44
|
+
throttle?: number;
|
|
45
|
+
reclaimDelay?: number;
|
|
46
|
+
reclaimCount?: number;
|
|
43
47
|
system?: SystemHealth;
|
|
44
48
|
}
|
|
45
49
|
export interface PingMessage {
|
package/package.json
CHANGED
|
@@ -288,13 +288,17 @@ export class MeshOSService {
|
|
|
288
288
|
args.push('LIMIT', '0', '0');
|
|
289
289
|
} else {
|
|
290
290
|
//limit which hash fields to return
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
291
|
+
args.push('RETURN');
|
|
292
|
+
args.push(((options.return?.length ?? 0) + 1).toString());
|
|
293
|
+
args.push('$');
|
|
294
|
+
options.return?.forEach(returnField => {
|
|
295
|
+
if (returnField.startsWith('"')) {
|
|
296
|
+
//allow literal values to be requested
|
|
297
|
+
args.push(returnField.slice(1, -1));
|
|
298
|
+
} else {
|
|
295
299
|
args.push(`_${returnField}`);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
300
|
+
}
|
|
301
|
+
});
|
|
298
302
|
//paginate
|
|
299
303
|
if (options.limit) {
|
|
300
304
|
args.push('LIMIT', options.limit.start.toString(), options.limit.size.toString());
|
|
@@ -3,6 +3,7 @@ import { RedisClient, RedisMulti } from '../../types/redis';
|
|
|
3
3
|
import { StoreService } from '../store';
|
|
4
4
|
import { KeyService, KeyType } from '../../modules/key';
|
|
5
5
|
import { WorkflowSearchOptions } from '../../types/durable';
|
|
6
|
+
import { asyncLocalStorage } from '../../modules/storage';
|
|
6
7
|
|
|
7
8
|
export class Search {
|
|
8
9
|
jobId: string;
|
|
@@ -66,9 +67,14 @@ export class Search {
|
|
|
66
67
|
* @returns {Promise<string[]>} - the list of search indexes
|
|
67
68
|
*/
|
|
68
69
|
static async listSearchIndexes(hotMeshClient: HotMesh): Promise<string[]> {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
try {
|
|
71
|
+
const store = hotMeshClient.engine.store;
|
|
72
|
+
const searchIndexes = await store.exec('FT._LIST');
|
|
73
|
+
return searchIndexes as string[];
|
|
74
|
+
} catch (err) {
|
|
75
|
+
hotMeshClient.engine.logger.info('durable-client-search-list-err', { err });
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
/**
|
|
@@ -80,18 +86,28 @@ export class Search {
|
|
|
80
86
|
return `${this.searchSessionId}-${this.searchSessionIndex++}-`;
|
|
81
87
|
}
|
|
82
88
|
|
|
83
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Sets the fields listed in args. Returns the
|
|
91
|
+
* count of new fields that were set (does not
|
|
92
|
+
* count fields that were updated)
|
|
93
|
+
*/
|
|
94
|
+
async set(...args: string[]): Promise<number> {
|
|
84
95
|
const ssGuid = this.getSearchSessionGuid();
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const key = this.safeKey(args[i]);
|
|
90
|
-
const value = args[i+1].toString();
|
|
91
|
-
safeArgs.push(key, value);
|
|
92
|
-
}
|
|
93
|
-
await this.store.exec('HSET', this.jobId, ...safeArgs);
|
|
96
|
+
const store = asyncLocalStorage.getStore();
|
|
97
|
+
const replay = store?.get('replay') ?? {};
|
|
98
|
+
if (ssGuid in replay) {
|
|
99
|
+
return Number(replay[ssGuid]);
|
|
94
100
|
}
|
|
101
|
+
const safeArgs: string[] = [];
|
|
102
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
103
|
+
const key = this.safeKey(args[i]);
|
|
104
|
+
const value = args[i+1].toString();
|
|
105
|
+
safeArgs.push(key, value);
|
|
106
|
+
}
|
|
107
|
+
const fieldCount = await this.store.exec('HSET', this.jobId, ...safeArgs);
|
|
108
|
+
//no need to wait; set this interim value in the replay
|
|
109
|
+
this.store.exec('HSET', this.jobId, ssGuid, fieldCount.toString());
|
|
110
|
+
return Number(fieldCount);
|
|
95
111
|
}
|
|
96
112
|
|
|
97
113
|
async get(key: string): Promise<string> {
|
|
@@ -116,34 +132,65 @@ export class Search {
|
|
|
116
132
|
}
|
|
117
133
|
}
|
|
118
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Deletes the fields listed in args. Returns the
|
|
137
|
+
* count of fields that were deleted.
|
|
138
|
+
*/
|
|
119
139
|
async del(...args: string[]): Promise<number | void> {
|
|
120
140
|
const ssGuid = this.getSearchSessionGuid();
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
141
|
+
const store = asyncLocalStorage.getStore();
|
|
142
|
+
const replay = store?.get('replay') ?? {};
|
|
143
|
+
if (ssGuid in replay) {
|
|
144
|
+
return Number(replay[ssGuid]);
|
|
145
|
+
}
|
|
146
|
+
const safeArgs: string[] = [];
|
|
147
|
+
for (let i = 0; i < args.length; i++) {
|
|
148
|
+
safeArgs.push(this.safeKey(args[i]));
|
|
129
149
|
}
|
|
150
|
+
const response = await this.store.exec('HDEL', this.jobId, ...safeArgs);
|
|
151
|
+
const formattedResponse = isNaN(response as unknown as number) ? 0 : Number(response);
|
|
152
|
+
//no need to wait; set this interim value in the replay
|
|
153
|
+
this.store.exec('HSET', this.jobId, ssGuid, formattedResponse.toString());
|
|
154
|
+
return formattedResponse;
|
|
130
155
|
}
|
|
131
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Increments the value of a field by the given amount. Returns the
|
|
159
|
+
* new value of the field after the increment. Can be
|
|
160
|
+
* used to decrement the value of a field by specifying a negative.
|
|
161
|
+
*/
|
|
132
162
|
async incr(key: string, val: number): Promise<number> {
|
|
133
163
|
const ssGuid = this.getSearchSessionGuid();
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
164
|
+
const store = asyncLocalStorage.getStore();
|
|
165
|
+
const replay = store?.get('replay') ?? {};
|
|
166
|
+
if (ssGuid in replay) {
|
|
167
|
+
return Number(replay[ssGuid]);
|
|
137
168
|
}
|
|
169
|
+
const num = await this.store.exec('HINCRBYFLOAT', this.jobId, this.safeKey(key), val.toString()) as string;
|
|
170
|
+
//no need to wait; set this interim value in the replay
|
|
171
|
+
this.store.exec('HSET', this.jobId, ssGuid, num.toString());
|
|
172
|
+
return Number(num);
|
|
138
173
|
}
|
|
139
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Multiplies the value of a field by the given amount. Returns the
|
|
177
|
+
* new value of the field after the multiplication. NOTE:
|
|
178
|
+
* this is exponential multiplication.
|
|
179
|
+
*/
|
|
140
180
|
async mult(key: string, val: number): Promise<number> {
|
|
141
181
|
const ssGuid = this.getSearchSessionGuid();
|
|
182
|
+
const store = asyncLocalStorage.getStore();
|
|
183
|
+
const replay = store?.get('replay') ?? {};
|
|
184
|
+
if (ssGuid in replay) {
|
|
185
|
+
return Math.exp(Number(replay[ssGuid]));
|
|
186
|
+
}
|
|
142
187
|
const ssGuidValue = Number(await this.store.exec('HINCRBYFLOAT', this.jobId, ssGuid, '1') as string);
|
|
143
188
|
if (ssGuidValue === 1) {
|
|
144
189
|
const log = Math.log(val);
|
|
145
|
-
const logTotal =
|
|
146
|
-
|
|
190
|
+
const logTotal = await this.store.exec('HINCRBYFLOAT', this.jobId, this.safeKey(key), log.toString()) as string;
|
|
191
|
+
//no need to wait; set this interim value in the replay
|
|
192
|
+
this.store.exec('HSET', this.jobId, ssGuid, logTotal.toString());
|
|
193
|
+
return Math.exp(Number(logTotal));
|
|
147
194
|
}
|
|
148
195
|
}
|
|
149
196
|
}
|
|
@@ -211,16 +211,29 @@ export class WorkerService {
|
|
|
211
211
|
// garbage collect (expire) this job when originJobId is expired
|
|
212
212
|
context.set('originJobId', workflowInput.originJobId);
|
|
213
213
|
}
|
|
214
|
+
let replayQuery = '';
|
|
214
215
|
if (workflowInput.workflowDimension) {
|
|
215
216
|
//every hook function runs in an isolated dimension controlled
|
|
216
217
|
//by the index assigned when the signal was received; even if the
|
|
217
218
|
//hook function re-runs, its scope will always remain constant
|
|
218
219
|
context.set('workflowDimension', workflowInput.workflowDimension);
|
|
220
|
+
replayQuery = `-*${workflowInput.workflowDimension}-*`;
|
|
221
|
+
} else {
|
|
222
|
+
//last letter of words like 'hook', 'sleep', 'wait', 'signal', 'search', 'start'
|
|
223
|
+
replayQuery = '-*[ehklpt]-*';
|
|
219
224
|
}
|
|
220
225
|
context.set('workflowTopic', workflowTopic);
|
|
221
226
|
context.set('workflowName', workflowTopic.split('-').pop());
|
|
222
227
|
context.set('workflowTrace', data.metadata.trc);
|
|
223
228
|
context.set('workflowSpan', data.metadata.spn);
|
|
229
|
+
const store = this.workflowRunner.engine.store;
|
|
230
|
+
const [cursor, replay] = await store.findJobFields(
|
|
231
|
+
workflowInput.workflowId,
|
|
232
|
+
replayQuery,
|
|
233
|
+
50_000,
|
|
234
|
+
5_000,);
|
|
235
|
+
context.set('replay', replay);
|
|
236
|
+
context.set('cursor', cursor); // if != 0, more remain
|
|
224
237
|
const workflowResponse = await asyncLocalStorage.run(context, async () => {
|
|
225
238
|
return await workflowFunction.apply(this, workflowInput.arguments);
|
|
226
239
|
});
|
|
@@ -232,7 +245,6 @@ export class WorkerService {
|
|
|
232
245
|
data: { response: workflowResponse, done: true }
|
|
233
246
|
};
|
|
234
247
|
} catch (err) {
|
|
235
|
-
|
|
236
248
|
//not an error...just a trigger to sleep
|
|
237
249
|
if (err instanceof DurableSleepForError) {
|
|
238
250
|
return {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { JobInterruptOptions, JobOutput, JobState } from '../../types/job';
|
|
22
22
|
import { StreamStatus } from '../../types/stream';
|
|
23
23
|
import { deterministicRandom } from '../../modules/utils';
|
|
24
|
+
import { StringStringType } from '../../types';
|
|
24
25
|
|
|
25
26
|
export class WorkflowService {
|
|
26
27
|
|
|
@@ -92,6 +93,10 @@ export class WorkflowService {
|
|
|
92
93
|
const COUNTER = store.get('counter');
|
|
93
94
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
94
95
|
const sessionId = `-start${workflowDimension}-${execIndex}-`;
|
|
96
|
+
const replay = store.get('replay') as StringStringType;
|
|
97
|
+
if (sessionId in replay) {
|
|
98
|
+
return replay[sessionId];
|
|
99
|
+
}
|
|
95
100
|
//NOTE: this is the hash prefix; necessary for the search index to locate the entity
|
|
96
101
|
const entityOrEmptyString = options.entity ?? '';
|
|
97
102
|
//If the workflowId is not provided, it is generated from the entity and the workflow name
|
|
@@ -193,6 +198,8 @@ export class WorkflowService {
|
|
|
193
198
|
static getContext(): WorkflowContext {
|
|
194
199
|
const store = asyncLocalStorage.getStore();
|
|
195
200
|
const workflowId = store.get('workflowId');
|
|
201
|
+
const replay = store.get('replay');
|
|
202
|
+
const cursor = store.get('cursor');
|
|
196
203
|
const workflowDimension = store.get('workflowDimension') ?? '';
|
|
197
204
|
const workflowTopic = store.get('workflowTopic');
|
|
198
205
|
const namespace = store.get('namespace');
|
|
@@ -201,7 +208,9 @@ export class WorkflowService {
|
|
|
201
208
|
const COUNTER = store.get('counter');
|
|
202
209
|
return {
|
|
203
210
|
counter: COUNTER.counter,
|
|
211
|
+
cursor,
|
|
204
212
|
namespace,
|
|
213
|
+
replay,
|
|
205
214
|
workflowId,
|
|
206
215
|
workflowDimension,
|
|
207
216
|
workflowTopic,
|
|
@@ -223,6 +232,10 @@ export class WorkflowService {
|
|
|
223
232
|
const COUNTER = store.get('counter');
|
|
224
233
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
225
234
|
const sessionId = `-${prefix}${workflowDimension}-${execIndex}-`;
|
|
235
|
+
const replay = store.get('replay') as StringStringType;
|
|
236
|
+
if (sessionId in replay) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
226
239
|
const keyParams = {
|
|
227
240
|
appId: hotMeshClient.appId,
|
|
228
241
|
jobId: workflowId
|
|
@@ -300,6 +313,7 @@ export class WorkflowService {
|
|
|
300
313
|
workflowTopic: store.get('workflowTopic'),
|
|
301
314
|
workflowDimension: store.get('workflowDimension') ?? '',
|
|
302
315
|
counter: store.get('counter'),
|
|
316
|
+
replay: store.get('replay'),
|
|
303
317
|
}
|
|
304
318
|
}
|
|
305
319
|
|
|
@@ -317,9 +331,13 @@ export class WorkflowService {
|
|
|
317
331
|
workflowTopic,
|
|
318
332
|
workflowDimension,
|
|
319
333
|
counter: COUNTER,
|
|
334
|
+
replay,
|
|
320
335
|
} = WorkflowService.getLocalState();
|
|
321
336
|
const execIndex = COUNTER.counter = COUNTER.counter + 1;
|
|
322
337
|
const sessionId = `-once${workflowDimension}-${execIndex}-`;
|
|
338
|
+
if (sessionId in replay) {
|
|
339
|
+
return JSON.parse(replay[sessionId]);
|
|
340
|
+
}
|
|
323
341
|
const hotMeshClient = await WorkerService.getHotMesh(workflowTopic, { namespace });
|
|
324
342
|
const keyParams = {
|
|
325
343
|
appId: hotMeshClient.appId,
|
package/services/engine/index.ts
CHANGED
|
@@ -98,6 +98,7 @@ class EngineService {
|
|
|
98
98
|
jobCallbacks: Record<string, JobMessageCallback> = {};
|
|
99
99
|
reporting = false;
|
|
100
100
|
jobId = 1;
|
|
101
|
+
inited: string;
|
|
101
102
|
|
|
102
103
|
static async init(namespace: string, appId: string, guid: string, config: HotMeshConfig, logger: ILogger): Promise<EngineService> {
|
|
103
104
|
if (config.engine) {
|
|
@@ -133,6 +134,7 @@ class EngineService {
|
|
|
133
134
|
instance.store,
|
|
134
135
|
logger,
|
|
135
136
|
);
|
|
137
|
+
instance.inited = formatISODate(new Date());
|
|
136
138
|
return instance;
|
|
137
139
|
}
|
|
138
140
|
}
|
package/services/quorum/index.ts
CHANGED
|
@@ -158,6 +158,10 @@ class QuorumService {
|
|
|
158
158
|
stream,
|
|
159
159
|
counts: this.engine.router.counts,
|
|
160
160
|
timestamp: formatISODate(new Date()),
|
|
161
|
+
inited: this.engine.inited,
|
|
162
|
+
throttle: this.engine.router.throttle,
|
|
163
|
+
reclaimDelay: this.engine.router.reclaimDelay,
|
|
164
|
+
reclaimCount: this.engine.router.reclaimCount,
|
|
161
165
|
system: await getSystemHealth(),
|
|
162
166
|
};
|
|
163
167
|
}
|
package/services/router/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
HMSH_XCLAIM_DELAY_MS,
|
|
11
11
|
HMSH_XPENDING_COUNT } from '../../modules/enums';
|
|
12
12
|
import { KeyType } from '../../modules/key';
|
|
13
|
-
import {
|
|
13
|
+
import { guid, sleepFor } from '../../modules/utils';
|
|
14
14
|
import { ILogger } from '../logger';
|
|
15
15
|
import { StoreService } from '../store';
|
|
16
16
|
import { StreamService } from '../stream';
|
|
@@ -43,6 +43,10 @@ class Router {
|
|
|
43
43
|
counts: { [key: string]: number } = {};
|
|
44
44
|
currentTimerId: NodeJS.Timeout | null = null;
|
|
45
45
|
shouldConsume: boolean;
|
|
46
|
+
sleepPromiseResolve: (() => void) | null = null;
|
|
47
|
+
innerPromiseResolve: (() => void) | null = null;
|
|
48
|
+
isSleeping: boolean = false;
|
|
49
|
+
sleepTimout: NodeJS.Timeout | null = null;
|
|
46
50
|
|
|
47
51
|
constructor(config: StreamConfig, stream: StreamService<RedisClient, RedisMulti>, store: StoreService<RedisClient, RedisMulti>, logger: ILogger) {
|
|
48
52
|
this.appId = config.appId;
|
|
@@ -54,6 +58,14 @@ class Router {
|
|
|
54
58
|
this.reclaimDelay = config.reclaimDelay || HMSH_XCLAIM_DELAY_MS;
|
|
55
59
|
this.reclaimCount = config.reclaimCount || HMSH_XCLAIM_COUNT;
|
|
56
60
|
this.logger = logger;
|
|
61
|
+
this.resetThrottleState();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private resetThrottleState() {
|
|
65
|
+
this.sleepPromiseResolve = null;
|
|
66
|
+
this.innerPromiseResolve = null;
|
|
67
|
+
this.isSleeping = false;
|
|
68
|
+
this.sleepTimout = null;
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
async createGroup(stream: string, group: string) {
|
|
@@ -72,6 +84,36 @@ class Router {
|
|
|
72
84
|
return await this.store.xadd(stream, '*', 'message', JSON.stringify(streamData), multi);
|
|
73
85
|
}
|
|
74
86
|
|
|
87
|
+
/**
|
|
88
|
+
* An adjustable throttle that will interrupt a sleeping
|
|
89
|
+
* router if the throttle is reduced and the sleep time
|
|
90
|
+
* has elapsed. If the throttle is increased, or if
|
|
91
|
+
* the sleep time has not elapsed, the router will continue
|
|
92
|
+
* to sleep until the new termination point. This
|
|
93
|
+
* allows for dynamic, elastic throttling with smooth
|
|
94
|
+
* acceleration and deceleration.
|
|
95
|
+
*/
|
|
96
|
+
public async customSleep(): Promise<void> {
|
|
97
|
+
if (this.throttle === 0) return;
|
|
98
|
+
if (this.isSleeping) return;
|
|
99
|
+
this.isSleeping = true;
|
|
100
|
+
let startTime = Date.now(); //anchor the origin
|
|
101
|
+
|
|
102
|
+
await new Promise<void>(async (outerResolve) => {
|
|
103
|
+
this.sleepPromiseResolve = outerResolve;
|
|
104
|
+
let elapsedTime = Date.now() - startTime;
|
|
105
|
+
while (elapsedTime < this.throttle) {
|
|
106
|
+
await new Promise<void>((innerResolve) => {
|
|
107
|
+
this.innerPromiseResolve = innerResolve;
|
|
108
|
+
this.sleepTimout = setTimeout(innerResolve, this.throttle - elapsedTime);
|
|
109
|
+
});
|
|
110
|
+
elapsedTime = Date.now() - startTime;
|
|
111
|
+
}
|
|
112
|
+
this.resetThrottleState();
|
|
113
|
+
outerResolve();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
75
117
|
async consumeMessages(stream: string, group: string, consumer: string, callback: (streamData: StreamData) => Promise<StreamDataResponse|void>): Promise<void> {
|
|
76
118
|
this.logger.info(`stream-consumer-starting`, { group, consumer, stream });
|
|
77
119
|
Router.instances.add(this);
|
|
@@ -80,9 +122,7 @@ class Router {
|
|
|
80
122
|
let lastCheckedPendingMessagesAt = Date.now();
|
|
81
123
|
|
|
82
124
|
async function consume() {
|
|
83
|
-
|
|
84
|
-
this.currentTimerId = sleep.timerId;
|
|
85
|
-
await sleep.promise;
|
|
125
|
+
await this.customSleep();
|
|
86
126
|
if (!this.shouldConsume) {
|
|
87
127
|
this.logger.info(`stream-consumer-stopping`, { group, consumer, stream });
|
|
88
128
|
return;
|
|
@@ -273,18 +313,28 @@ class Router {
|
|
|
273
313
|
}
|
|
274
314
|
|
|
275
315
|
cancelThrottle() {
|
|
276
|
-
if (this.
|
|
277
|
-
clearTimeout(this.
|
|
278
|
-
this.currentTimerId = undefined;
|
|
316
|
+
if (this.sleepTimout) {
|
|
317
|
+
clearTimeout(this.sleepTimout);
|
|
279
318
|
}
|
|
319
|
+
this.resetThrottleState();
|
|
280
320
|
}
|
|
281
321
|
|
|
282
|
-
setThrottle(delayInMillis: number) {
|
|
322
|
+
public setThrottle(delayInMillis: number): void {
|
|
283
323
|
if (!Number.isInteger(delayInMillis) || delayInMillis < 0) {
|
|
284
324
|
throw new Error('Throttle must be a non-negative integer');
|
|
285
325
|
}
|
|
326
|
+
const wasDecreased = delayInMillis < this.throttle;
|
|
286
327
|
this.throttle = delayInMillis;
|
|
287
|
-
|
|
328
|
+
|
|
329
|
+
// If the throttle was decreased, and we're in the middle of a sleep cycle, adjust immediately
|
|
330
|
+
if (wasDecreased) {
|
|
331
|
+
if (this.sleepTimout) {
|
|
332
|
+
clearTimeout(this.sleepTimout);
|
|
333
|
+
}
|
|
334
|
+
if (this.innerPromiseResolve) {
|
|
335
|
+
this.innerPromiseResolve();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
288
338
|
}
|
|
289
339
|
|
|
290
340
|
async claimUnacknowledged(stream: string, group: string, consumer: string, idleTimeMs = this.reclaimDelay, limit = HMSH_XPENDING_COUNT): Promise<[string, [string, string]][]> {
|
|
@@ -22,6 +22,7 @@ class RedisStoreService extends StoreService<RedisClientType, RedisMultiType> {
|
|
|
22
22
|
setnx: 'SETNX',
|
|
23
23
|
del: 'DEL',
|
|
24
24
|
expire: 'EXPIRE',
|
|
25
|
+
hscan: 'HSCAN',
|
|
25
26
|
hset: 'HSET',
|
|
26
27
|
hsetnx: 'HSETNX',
|
|
27
28
|
hincrby: 'HINCRBY',
|
|
@@ -41,6 +42,7 @@ class RedisStoreService extends StoreService<RedisClientType, RedisMultiType> {
|
|
|
41
42
|
lpop: 'LPOP',
|
|
42
43
|
rename: 'RENAME',
|
|
43
44
|
rpush: 'RPUSH',
|
|
45
|
+
scan: 'SCAN',
|
|
44
46
|
xack: 'XACK',
|
|
45
47
|
xdel: 'XDEL',
|
|
46
48
|
xlen: 'XLEN',
|
package/services/store/index.ts
CHANGED
|
@@ -50,6 +50,7 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
|
|
|
50
50
|
del: 'del',
|
|
51
51
|
expire: 'expire',
|
|
52
52
|
hset: 'hset',
|
|
53
|
+
hscan: 'hscan',
|
|
53
54
|
hsetnx: 'hsetnx',
|
|
54
55
|
hincrby: 'hincrby',
|
|
55
56
|
hdel: 'hdel',
|
|
@@ -68,6 +69,7 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
|
|
|
68
69
|
lrange: 'lrange',
|
|
69
70
|
rename: 'rename',
|
|
70
71
|
rpush: 'rpush',
|
|
72
|
+
scan: 'scan',
|
|
71
73
|
xack: 'xack',
|
|
72
74
|
xdel: 'xdel',
|
|
73
75
|
};
|
|
@@ -998,6 +1000,63 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
|
|
|
998
1000
|
const jobKey = this.mintKey(KeyType.JOB_STATE, { appId: this.appId, jobId });
|
|
999
1001
|
await this.redisClient[this.commands.del](jobKey);
|
|
1000
1002
|
}
|
|
1003
|
+
|
|
1004
|
+
async findJobs(queryString: string = '*', limit: number = 1000, batchSize: number = 1000): Promise<string[]> {
|
|
1005
|
+
const matchKey = this.mintKey(KeyType.JOB_STATE, { appId: this.appId, jobId: queryString });
|
|
1006
|
+
let cursor = '0';
|
|
1007
|
+
let keys: string[];
|
|
1008
|
+
const matchingKeys: string[] = [];
|
|
1009
|
+
do {
|
|
1010
|
+
const output = await this.exec(
|
|
1011
|
+
'SCAN',
|
|
1012
|
+
cursor,
|
|
1013
|
+
'MATCH',
|
|
1014
|
+
matchKey,
|
|
1015
|
+
'COUNT',
|
|
1016
|
+
batchSize.toString(),
|
|
1017
|
+
) as unknown as [string, string[]];
|
|
1018
|
+
if (Array.isArray(output)) {
|
|
1019
|
+
[cursor, keys] = output;
|
|
1020
|
+
for (let key of [...keys]) {
|
|
1021
|
+
matchingKeys.push(key);
|
|
1022
|
+
}
|
|
1023
|
+
if (matchingKeys.length >= limit) {
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
} while (cursor !== '0');
|
|
1030
|
+
return matchingKeys;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async findJobFields(jobId: string, fieldMatchPattern: string = '*', limit: number = 1000, batchSize: number = 1000, cursor = '0'): Promise<[string, StringStringType]> {
|
|
1034
|
+
let fields: string[] = [];
|
|
1035
|
+
const matchingFields: StringStringType = {};
|
|
1036
|
+
const jobKey = this.mintKey(KeyType.JOB_STATE, { appId: this.appId, jobId });
|
|
1037
|
+
let len = 0;
|
|
1038
|
+
do {
|
|
1039
|
+
const output = await this.exec(
|
|
1040
|
+
'HSCAN',
|
|
1041
|
+
jobKey,
|
|
1042
|
+
cursor,
|
|
1043
|
+
'MATCH',
|
|
1044
|
+
fieldMatchPattern,
|
|
1045
|
+
'COUNT',
|
|
1046
|
+
batchSize.toString(),
|
|
1047
|
+
) as unknown as [string, string[]];
|
|
1048
|
+
if (Array.isArray(output)) {
|
|
1049
|
+
[cursor, fields] = output;
|
|
1050
|
+
for (let i = 0; i < fields.length; i += 2) {
|
|
1051
|
+
len++;
|
|
1052
|
+
matchingFields[fields[i]] = fields[i + 1];
|
|
1053
|
+
}
|
|
1054
|
+
} else {
|
|
1055
|
+
break;
|
|
1056
|
+
}
|
|
1057
|
+
} while (cursor !== '0' && len < limit);
|
|
1058
|
+
return [cursor, matchingFields];
|
|
1059
|
+
}
|
|
1001
1060
|
}
|
|
1002
1061
|
|
|
1003
1062
|
export { StoreService };
|
package/services/worker/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ class WorkerService {
|
|
|
34
34
|
router: Router | null;
|
|
35
35
|
logger: ILogger;
|
|
36
36
|
reporting = false;
|
|
37
|
+
inited: string;
|
|
37
38
|
|
|
38
39
|
static async init(
|
|
39
40
|
namespace: string,
|
|
@@ -76,6 +77,7 @@ class WorkerService {
|
|
|
76
77
|
service.guid,
|
|
77
78
|
worker.callback
|
|
78
79
|
);
|
|
80
|
+
service.inited = formatISODate(new Date());
|
|
79
81
|
services.push(service);
|
|
80
82
|
}
|
|
81
83
|
}
|
|
@@ -176,6 +178,10 @@ class WorkerService {
|
|
|
176
178
|
stream: this.stream.mintKey(KeyType.STREAMS, params),
|
|
177
179
|
counts: this.router.counts,
|
|
178
180
|
timestamp: formatISODate(new Date()),
|
|
181
|
+
inited: this.inited,
|
|
182
|
+
throttle: this.router.throttle,
|
|
183
|
+
reclaimDelay: this.router.reclaimDelay,
|
|
184
|
+
reclaimCount: this.router.reclaimCount,
|
|
179
185
|
system: await getSystemHealth(),
|
|
180
186
|
};
|
|
181
187
|
}
|
package/types/durable.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LogLevel } from './logger';
|
|
2
2
|
import { RedisClass, RedisOptions } from './redis';
|
|
3
|
+
import { StringStringType } from './serializer';
|
|
3
4
|
|
|
4
5
|
type WorkflowConfig = {
|
|
5
6
|
backoffCoefficient?: number; //default 10
|
|
@@ -15,6 +16,16 @@ type WorkflowContext = {
|
|
|
15
16
|
*/
|
|
16
17
|
counter: number;
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* number as string for the replay cursor
|
|
21
|
+
*/
|
|
22
|
+
cursor: string;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* the replay hash of name/value pairs representing prior executions
|
|
26
|
+
*/
|
|
27
|
+
replay: StringStringType;
|
|
28
|
+
|
|
18
29
|
/**
|
|
19
30
|
* the HotMesh App namespace. `durable` is the default.
|
|
20
31
|
*/
|
package/types/quorum.ts
CHANGED
|
@@ -44,7 +44,11 @@ export interface QuorumProfile {
|
|
|
44
44
|
stream?: string;
|
|
45
45
|
stream_depth?: number;
|
|
46
46
|
counts?: Record<string, number>;
|
|
47
|
+
inited?: string;
|
|
47
48
|
timestamp?: string;
|
|
49
|
+
throttle?: number;
|
|
50
|
+
reclaimDelay?: number;
|
|
51
|
+
reclaimCount?: number;
|
|
48
52
|
system?: SystemHealth;
|
|
49
53
|
}
|
|
50
54
|
|