@hotmeshio/hotmesh 0.0.33 → 0.0.35
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 +30 -18
- package/build/modules/enums.d.ts +22 -0
- package/build/modules/enums.js +29 -0
- package/build/modules/errors.d.ts +10 -2
- package/build/modules/errors.js +14 -3
- package/build/modules/key.d.ts +16 -15
- package/build/modules/key.js +18 -15
- package/build/modules/utils.d.ts +1 -0
- package/build/modules/utils.js +6 -1
- package/build/package.json +4 -1
- package/build/services/activities/activity.d.ts +5 -0
- package/build/services/activities/activity.js +27 -6
- package/build/services/activities/await.js +11 -3
- package/build/services/activities/cycle.js +10 -2
- package/build/services/activities/hook.js +8 -2
- package/build/services/activities/index.d.ts +2 -2
- package/build/services/activities/index.js +2 -2
- package/build/services/activities/interrupt.d.ts +16 -0
- package/build/services/activities/interrupt.js +129 -0
- package/build/services/activities/signal.js +9 -2
- package/build/services/activities/trigger.d.ts +4 -0
- package/build/services/activities/trigger.js +14 -4
- package/build/services/activities/worker.js +10 -2
- package/build/services/collator/index.d.ts +4 -0
- package/build/services/collator/index.js +8 -0
- package/build/services/compiler/deployer.js +1 -3
- package/build/services/connector/index.js +2 -3
- package/build/services/durable/client.js +9 -6
- package/build/services/durable/factory.js +65 -284
- package/build/services/durable/handle.d.ts +37 -0
- package/build/services/durable/handle.js +52 -9
- package/build/services/durable/index.d.ts +5 -0
- package/build/services/durable/index.js +10 -0
- package/build/services/durable/meshos.js +3 -6
- package/build/services/durable/worker.js +11 -5
- package/build/services/durable/workflow.d.ts +24 -0
- package/build/services/durable/workflow.js +56 -1
- package/build/services/engine/index.d.ts +14 -6
- package/build/services/engine/index.js +52 -27
- package/build/services/hotmesh/index.d.ts +6 -2
- package/build/services/hotmesh/index.js +23 -5
- package/build/services/quorum/index.d.ts +1 -0
- package/build/services/quorum/index.js +10 -0
- package/build/services/signaler/stream.js +25 -29
- package/build/services/store/index.d.ts +40 -4
- package/build/services/store/index.js +114 -9
- package/build/services/task/index.d.ts +5 -4
- package/build/services/task/index.js +12 -14
- package/build/types/activity.d.ts +35 -5
- package/build/types/durable.d.ts +4 -0
- package/build/types/index.d.ts +1 -1
- package/build/types/job.d.ts +18 -1
- package/build/types/quorum.d.ts +11 -7
- package/build/types/stream.d.ts +4 -1
- package/build/types/stream.js +2 -0
- package/modules/enums.ts +32 -0
- package/modules/errors.ts +24 -9
- package/modules/key.ts +4 -1
- package/modules/utils.ts +5 -0
- package/package.json +4 -1
- package/services/activities/activity.ts +34 -8
- package/services/activities/await.ts +11 -4
- package/services/activities/cycle.ts +10 -3
- package/services/activities/hook.ts +8 -3
- package/services/activities/index.ts +2 -2
- package/services/activities/interrupt.ts +159 -0
- package/services/activities/signal.ts +9 -3
- package/services/activities/trigger.ts +21 -5
- package/services/activities/worker.ts +10 -3
- package/services/collator/index.ts +10 -1
- package/services/compiler/deployer.ts +1 -3
- package/services/connector/index.ts +3 -5
- package/services/durable/client.ts +10 -7
- package/services/durable/factory.ts +65 -284
- package/services/durable/handle.ts +55 -9
- package/services/durable/index.ts +11 -0
- package/services/durable/meshos.ts +3 -7
- package/services/durable/worker.ts +11 -5
- package/services/durable/workflow.ts +66 -2
- package/services/engine/index.ts +74 -26
- package/services/hotmesh/index.ts +28 -6
- package/services/quorum/index.ts +9 -0
- package/services/signaler/stream.ts +28 -25
- package/services/store/index.ts +119 -11
- package/services/task/index.ts +18 -18
- package/types/activity.ts +38 -8
- package/types/durable.ts +8 -4
- package/types/index.ts +1 -1
- package/types/job.ts +30 -1
- package/types/quorum.ts +13 -8
- package/types/stream.ts +3 -0
- package/build/services/activities/iterate.d.ts +0 -9
- package/build/services/activities/iterate.js +0 -13
- package/services/activities/iterate.ts +0 -26
|
@@ -1,5 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BLOCK_TIME_MS,
|
|
3
|
+
MAX_RETRIES,
|
|
4
|
+
MAX_TIMEOUT_MS,
|
|
5
|
+
GRADUATED_INTERVAL_MS,
|
|
6
|
+
STATUS_CODE_UNACKED,
|
|
7
|
+
STATUS_CODE_UNKNOWN,
|
|
8
|
+
STATUS_MESSAGE_UNKNOWN,
|
|
9
|
+
XCLAIM_COUNT,
|
|
10
|
+
XCLAIM_DELAY_MS,
|
|
11
|
+
XPENDING_COUNT } from '../../modules/enums';
|
|
1
12
|
import { KeyType } from '../../modules/key';
|
|
2
|
-
import { XSleepFor, sleepFor } from '../../modules/utils';
|
|
13
|
+
import { XSleepFor, guid, sleepFor } from '../../modules/utils';
|
|
3
14
|
import { ILogger } from '../logger';
|
|
4
15
|
import { StoreService } from '../store';
|
|
5
16
|
import { StreamService } from '../stream';
|
|
@@ -16,19 +27,6 @@ import {
|
|
|
16
27
|
StreamStatus
|
|
17
28
|
} from '../../types/stream';
|
|
18
29
|
|
|
19
|
-
const MAX_RETRIES = 3; //local retry; 10, 100, 1000ms
|
|
20
|
-
const MAX_TIMEOUT_MS = 60000;
|
|
21
|
-
const GRADUATED_INTERVAL_MS = 5000;
|
|
22
|
-
const BLOCK_DURATION = 15000; //Set to `15` so SIGINT/SIGTERM can interrupt; set to `0` to BLOCK indefinitely
|
|
23
|
-
const TEST_BLOCK_DURATION = 1000; //Set to `1000` so tests can interrupt quickly
|
|
24
|
-
const BLOCK_TIME_MS = process.env.NODE_ENV === 'test' ? TEST_BLOCK_DURATION : BLOCK_DURATION;
|
|
25
|
-
const SYSTEM_STATUS_CODE = 999;
|
|
26
|
-
const UNKNOWN_STATUS_CODE = 500;
|
|
27
|
-
const UNKNOWN_STATUS_MESSAGE = 'unknown';
|
|
28
|
-
const XCLAIM_DELAY_MS = 1000 * 60; //max time a message can be unacked before it is claimed by another
|
|
29
|
-
const XCLAIM_COUNT = 3; //max number of times a message can be claimed by another before it is dead-lettered
|
|
30
|
-
const XPENDING_COUNT = 10;
|
|
31
|
-
|
|
32
30
|
class StreamSignaler {
|
|
33
31
|
static signalers: Set<StreamSignaler> = new Set();
|
|
34
32
|
appId: string;
|
|
@@ -131,7 +129,7 @@ class StreamSignaler {
|
|
|
131
129
|
telemetry.startStreamSpan(input, this.role);
|
|
132
130
|
output = await this.execStreamLeg(input, stream, id, callback.bind(this));
|
|
133
131
|
if (output?.status === StreamStatus.ERROR) {
|
|
134
|
-
telemetry.setStreamError(`Function Status Code ${ output.code ||
|
|
132
|
+
telemetry.setStreamError(`Function Status Code ${ output.code || STATUS_CODE_UNKNOWN }`);
|
|
135
133
|
}
|
|
136
134
|
this.errorCount = 0;
|
|
137
135
|
} catch (err) {
|
|
@@ -171,12 +169,17 @@ class StreamSignaler {
|
|
|
171
169
|
await sleepFor(timeout);
|
|
172
170
|
return await this.publishMessage(input.metadata.topic, {
|
|
173
171
|
data: input.data,
|
|
172
|
+
//note: retain guid (this is a retry attempt)
|
|
174
173
|
metadata: { ...input.metadata, try: (input.metadata.try || 0) + 1 },
|
|
175
174
|
policies: input.policies,
|
|
176
175
|
}) as string;
|
|
177
176
|
} else {
|
|
178
177
|
output = this.structureError(input, output);
|
|
179
178
|
}
|
|
179
|
+
} else if (typeof output.metadata !== 'object') {
|
|
180
|
+
output.metadata = { ...input.metadata, guid: guid() };
|
|
181
|
+
} else {
|
|
182
|
+
output.metadata.guid = guid();
|
|
180
183
|
}
|
|
181
184
|
output.type = StreamDataType.RESPONSE;
|
|
182
185
|
return await this.publishMessage(null, output as StreamDataResponse) as string;
|
|
@@ -203,7 +206,7 @@ class StreamSignaler {
|
|
|
203
206
|
if (typeof err.message === 'string') {
|
|
204
207
|
error.message = err.message;
|
|
205
208
|
} else {
|
|
206
|
-
error.message =
|
|
209
|
+
error.message = STATUS_MESSAGE_UNKNOWN;
|
|
207
210
|
}
|
|
208
211
|
if (typeof err.stack === 'string') {
|
|
209
212
|
error.stack = err.stack;
|
|
@@ -213,18 +216,18 @@ class StreamSignaler {
|
|
|
213
216
|
}
|
|
214
217
|
return {
|
|
215
218
|
status: 'error',
|
|
216
|
-
code:
|
|
217
|
-
metadata: { ...input.metadata },
|
|
219
|
+
code: STATUS_CODE_UNKNOWN,
|
|
220
|
+
metadata: { ...input.metadata, guid: guid() },
|
|
218
221
|
data: error as StreamError
|
|
219
222
|
} as StreamDataResponse;
|
|
220
223
|
}
|
|
221
224
|
|
|
222
225
|
structureUnacknowledgedError(input: StreamData) {
|
|
223
226
|
const message = 'stream message max delivery count exceeded';
|
|
224
|
-
const code =
|
|
227
|
+
const code = STATUS_CODE_UNACKED;
|
|
225
228
|
const data: StreamError = { message, code };
|
|
226
229
|
const output: StreamDataResponse = {
|
|
227
|
-
metadata: { ...input.metadata },
|
|
230
|
+
metadata: { ...input.metadata, guid: guid() },
|
|
228
231
|
status: StreamStatus.ERROR,
|
|
229
232
|
code,
|
|
230
233
|
data,
|
|
@@ -235,9 +238,9 @@ class StreamSignaler {
|
|
|
235
238
|
}
|
|
236
239
|
|
|
237
240
|
structureError(input: StreamData, output: StreamDataResponse): StreamDataResponse {
|
|
238
|
-
const message = output.data?.message ? output.data?.message.toString() :
|
|
241
|
+
const message = output.data?.message ? output.data?.message.toString() : STATUS_MESSAGE_UNKNOWN;
|
|
239
242
|
const statusCode = output.code || output.data?.code;
|
|
240
|
-
const code = isNaN(statusCode as number) ?
|
|
243
|
+
const code = isNaN(statusCode as number) ? STATUS_CODE_UNKNOWN : parseInt(statusCode.toString());
|
|
241
244
|
const data: StreamError = { message, code };
|
|
242
245
|
if (typeof output.data?.error === 'object') {
|
|
243
246
|
data.error = { ...output.data.error };
|
|
@@ -245,7 +248,7 @@ class StreamSignaler {
|
|
|
245
248
|
return {
|
|
246
249
|
status: StreamStatus.ERROR,
|
|
247
250
|
code,
|
|
248
|
-
metadata: { ...input.metadata },
|
|
251
|
+
metadata: { ...input.metadata, guid: guid() },
|
|
249
252
|
data
|
|
250
253
|
} as StreamDataResponse;
|
|
251
254
|
}
|
|
@@ -254,13 +257,13 @@ class StreamSignaler {
|
|
|
254
257
|
for (const instance of [...StreamSignaler.signalers]) {
|
|
255
258
|
instance.stopConsuming();
|
|
256
259
|
}
|
|
260
|
+
await sleepFor(BLOCK_TIME_MS);
|
|
257
261
|
}
|
|
258
262
|
|
|
259
263
|
async stopConsuming() {
|
|
260
264
|
this.shouldConsume = false;
|
|
261
265
|
this.logger.info(`stream-consumer-stopping`, this.topic ? { topic: this.topic } : undefined);
|
|
262
266
|
this.cancelThrottle();
|
|
263
|
-
//await sleepFor(BLOCK_TIME_MS);
|
|
264
267
|
}
|
|
265
268
|
|
|
266
269
|
cancelThrottle() {
|
|
@@ -307,7 +310,7 @@ class StreamSignaler {
|
|
|
307
310
|
// ii) corrupt hardware/network/transport/etc
|
|
308
311
|
// 3b) system error: Redis unable to accept `xadd` request
|
|
309
312
|
// 4c) system error: Redis unable to accept `xdel`/`xack` request
|
|
310
|
-
this.logger.error('stream-message-max-delivery-count-exceeded', { id, stream, group, consumer, code:
|
|
313
|
+
this.logger.error('stream-message-max-delivery-count-exceeded', { id, stream, group, consumer, code: STATUS_CODE_UNACKED, count });
|
|
311
314
|
const streamData = reclaimedMessage[0]?.[1]?.[1];
|
|
312
315
|
|
|
313
316
|
//fatal risk point 1 of 3): json is corrupt
|
package/services/store/index.ts
CHANGED
|
@@ -28,6 +28,9 @@ import {
|
|
|
28
28
|
import { Transitions } from '../../types/transition';
|
|
29
29
|
import { formatISODate, getSymKey } from '../../modules/utils';
|
|
30
30
|
import { ReclaimedMessageType } from '../../types/stream';
|
|
31
|
+
import { JobCompletionOptions, JobInterruptOptions } from '../../types/job';
|
|
32
|
+
import { STATUS_CODE_INTERRUPT } from '../../modules/enums';
|
|
33
|
+
import { GetStateError } from '../../modules/errors';
|
|
31
34
|
|
|
32
35
|
interface AbstractRedisClient {
|
|
33
36
|
exec(): any;
|
|
@@ -371,6 +374,22 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
|
|
|
371
374
|
return await this.redisClient[this.commands.hset](key, payload as any);
|
|
372
375
|
}
|
|
373
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Registers jobId with the originJobId that spawned it. In the future,
|
|
379
|
+
* when originJobId is interrupted or expired, the items in the
|
|
380
|
+
* list (added via RPUSH) are LPOPed. If origin was expired, then
|
|
381
|
+
* LPOPed items from the list are likewise expired;
|
|
382
|
+
*/
|
|
383
|
+
async setDependency(originJobId: string, topic: string, jobId: string, multi? : U): Promise<any> {
|
|
384
|
+
const privateMulti = multi || this.getMulti();
|
|
385
|
+
const depParams = { appId: this.appId, jobId: originJobId };
|
|
386
|
+
const depKey = this.mintKey(KeyType.JOB_DEPENDENTS, depParams);
|
|
387
|
+
privateMulti[this.commands.rpush](depKey, `expire::${topic}::${jobId}`);
|
|
388
|
+
if (!multi) {
|
|
389
|
+
return await privateMulti.exec();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
374
393
|
async setStats(jobKey: string, jobId: string, dateTime: string, stats: StatsType, appVersion: AppVID, multi? : U): Promise<any> {
|
|
375
394
|
const params: KeyStoreParams = { appId: appVersion.id, jobId, jobKey, dateTime };
|
|
376
395
|
const privateMulti = multi || this.getMulti();
|
|
@@ -477,8 +496,8 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
|
|
|
477
496
|
}
|
|
478
497
|
|
|
479
498
|
/**
|
|
480
|
-
*
|
|
481
|
-
* should
|
|
499
|
+
* Returns custom search fields and values.
|
|
500
|
+
* NOTE: The `fields` param should NOT prefix items with an underscore.
|
|
482
501
|
*/
|
|
483
502
|
async getQueryState(jobId: string, fields: string[]): Promise<StringAnyType> {
|
|
484
503
|
const key = this.mintKey(KeyType.JOB_STATE, { appId: this.appId, jobId });
|
|
@@ -520,7 +539,7 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
|
|
|
520
539
|
}
|
|
521
540
|
return [state, status];
|
|
522
541
|
} else {
|
|
523
|
-
throw new
|
|
542
|
+
throw new GetStateError(jobId);
|
|
524
543
|
}
|
|
525
544
|
}
|
|
526
545
|
|
|
@@ -745,7 +764,27 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
|
|
|
745
764
|
}
|
|
746
765
|
}
|
|
747
766
|
|
|
748
|
-
|
|
767
|
+
/**
|
|
768
|
+
* register the descendants of an expired origin flow to be
|
|
769
|
+
* expired at a future date; options indicate whether this
|
|
770
|
+
* is a standard `expire` or an `interrupt`
|
|
771
|
+
*/
|
|
772
|
+
async registerExpireJob(jobId: string, deletionTime: number, options: JobCompletionOptions): Promise<void> {
|
|
773
|
+
const depParams = { appId: this.appId, jobId };
|
|
774
|
+
const depKey = this.mintKey(KeyType.JOB_DEPENDENTS, depParams);
|
|
775
|
+
const context = options.interrupt ? 'INTERRUPT' : 'EXPIRE';
|
|
776
|
+
const depKeyContext = `::${context}::${depKey}`;
|
|
777
|
+
const zsetKey = this.mintKey(KeyType.TIME_RANGE, { appId: this.appId });
|
|
778
|
+
await this.zAdd(zsetKey, deletionTime.toString(), depKeyContext);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* registers a hook activity to be awakened (uses ZSET to
|
|
783
|
+
* store the 'sleep group' and LIST to store the events
|
|
784
|
+
* for the given sleep group. Sleep groups are
|
|
785
|
+
* organized into 'n'-second blocks (LISTS))
|
|
786
|
+
*/
|
|
787
|
+
async registerTimeHook(jobId: string, activityId: string, type: 'sleep'|'expire'|'interrupt', deletionTime: number, multi?: U): Promise<void> {
|
|
749
788
|
const listKey = this.mintKey(KeyType.TIME_RANGE, { appId: this.appId, timeValue: deletionTime });
|
|
750
789
|
const timeEvent = `${type}::${activityId}::${jobId}`
|
|
751
790
|
const len = await (multi || this.redisClient)[this.commands.rpush](listKey, timeEvent);
|
|
@@ -755,21 +794,90 @@ abstract class StoreService<T, U extends AbstractRedisClient> {
|
|
|
755
794
|
}
|
|
756
795
|
}
|
|
757
796
|
|
|
758
|
-
async getNextTimeJob(listKey?: string): Promise<[listKey: string, jobId: string, activityId: string] | void> {
|
|
797
|
+
async getNextTimeJob(listKey?: string): Promise<[listKey: string, jobId: string, activityId: string, type: 'sleep'|'expire'|'interrupt'] | void> {
|
|
759
798
|
const zsetKey = this.mintKey(KeyType.TIME_RANGE, { appId: this.appId });
|
|
760
|
-
|
|
761
|
-
listKey = listKey || await this.zRangeByScore(zsetKey, 0, now);
|
|
799
|
+
listKey = listKey || await this.zRangeByScore(zsetKey, 0, Date.now());
|
|
762
800
|
if (listKey) {
|
|
763
|
-
const
|
|
801
|
+
const [pType, pKey] = this.resolveKeyContext(listKey);
|
|
802
|
+
const timeEvent = await this.redisClient[this.commands.lpop](pKey);
|
|
764
803
|
if (timeEvent) {
|
|
765
|
-
//
|
|
766
|
-
const [
|
|
767
|
-
return [listKey, jobId.join('::'), activityId];
|
|
804
|
+
//there are 3 time-related event triggers: sleep, expire, interrupt
|
|
805
|
+
const [_type, activityId, ...jobId] = timeEvent.split('::');
|
|
806
|
+
return [listKey, jobId.join('::'), activityId, pType];
|
|
768
807
|
}
|
|
769
808
|
await this.redisClient[this.commands.zrem](zsetKey, listKey);
|
|
770
809
|
}
|
|
771
810
|
}
|
|
772
811
|
|
|
812
|
+
/**
|
|
813
|
+
* when processing time jobs, the target LIST ID returned
|
|
814
|
+
* from the ZSET query can be prefixed to denote what to
|
|
815
|
+
* do with the work list. (not everything is known in advance,
|
|
816
|
+
* so the ZSET key defines HOW to approach the work in the
|
|
817
|
+
* generic LIST (lists typically contain target job ids)
|
|
818
|
+
* @param {string} listKey - for example `::INTERRUPT::job123` or `job123`
|
|
819
|
+
*/
|
|
820
|
+
resolveKeyContext(listKey: string): [('sleep'|'expire'|'interrupt'), string] {
|
|
821
|
+
if (listKey.startsWith('::INTERRUPT')) {
|
|
822
|
+
return ['interrupt', listKey.split('::')[2]];
|
|
823
|
+
} else if (listKey.startsWith('::EXPIRE')) {
|
|
824
|
+
return ['expire', listKey.split('::')[2]];
|
|
825
|
+
} else {
|
|
826
|
+
return ['sleep', listKey];
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Interrupts a job and sets sets a job error (410), if 'throw'!=false.
|
|
832
|
+
* This method is called by the engine and not by an activity and is
|
|
833
|
+
* followed by a call to execute job completion/cleanup tasks
|
|
834
|
+
* associated with a job completion event.
|
|
835
|
+
*/
|
|
836
|
+
async interrupt(topic: string, jobId: string, options: JobInterruptOptions = {}): Promise<void> {
|
|
837
|
+
try {
|
|
838
|
+
//verify job exists
|
|
839
|
+
const status = await this.getStatus(jobId, this.appId);
|
|
840
|
+
if (status <= 0) {
|
|
841
|
+
//verify still active; job already completed
|
|
842
|
+
throw new Error(`Job ${jobId} already completed`);
|
|
843
|
+
}
|
|
844
|
+
//decrement job status (:) by 1bil
|
|
845
|
+
const amount = -1_000_000_000;
|
|
846
|
+
const jobKey = this.mintKey(KeyType.JOB_STATE, { appId: this.appId, jobId });
|
|
847
|
+
const result = await this.redisClient[this.commands.hincrbyfloat](jobKey, ':', amount);
|
|
848
|
+
if (result <= amount) {
|
|
849
|
+
//verify active state; job already interrupted
|
|
850
|
+
throw new Error(`Job ${jobId} already completed`);
|
|
851
|
+
}
|
|
852
|
+
//persist the error unless specifically told not to
|
|
853
|
+
if (options.throw !== false) {
|
|
854
|
+
const errKey = `metadata/err`; //job errors are stored at the path `metadata/err`
|
|
855
|
+
const symbolNames = [`$${topic}`]; //the symbol for `metadata/err` is in redis and stored using the job topic
|
|
856
|
+
const symKeys = await this.getSymbolKeys(symbolNames);
|
|
857
|
+
const symVals = await this.getSymbolValues();
|
|
858
|
+
this.serializer.resetSymbols(symKeys, symVals, {});
|
|
859
|
+
|
|
860
|
+
//persists the standard 410 error (job is `gone`)
|
|
861
|
+
const err = JSON.stringify({
|
|
862
|
+
code: STATUS_CODE_INTERRUPT,
|
|
863
|
+
message: options.reason ?? `job [${jobId}] interrupted`,
|
|
864
|
+
job_id: jobId
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
const payload = { [errKey]: amount.toString() }
|
|
868
|
+
const hashData = this.serializer.package(payload, symbolNames);
|
|
869
|
+
const errSymbol = Object.keys(hashData)[0];
|
|
870
|
+
await this.redisClient[this.commands.hset](jobKey, errSymbol, err);
|
|
871
|
+
}
|
|
872
|
+
} catch (e) {
|
|
873
|
+
if (!options.suppress) {
|
|
874
|
+
throw e;
|
|
875
|
+
} else {
|
|
876
|
+
this.logger.debug('suppressed-interrupt', { message: e.message })
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
773
881
|
async scrub(jobId: string) {
|
|
774
882
|
const jobKey = this.mintKey(KeyType.JOB_STATE, { appId: this.appId, jobId });
|
|
775
883
|
await this.redisClient[this.commands.del](jobKey);
|
package/services/task/index.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EXPIRE_DURATION,
|
|
3
|
+
FIDELITY_SECONDS } from '../../modules/enums';
|
|
4
|
+
import { XSleepFor, sleepFor } from '../../modules/utils';
|
|
1
5
|
import { ILogger } from '../logger';
|
|
2
6
|
import { StoreService } from '../store';
|
|
3
|
-
import { RedisClient, RedisMulti } from '../../types/redis';
|
|
4
7
|
import { HookInterface } from '../../types/hook';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
//system timer granularity limit (task queues organize)
|
|
8
|
-
const FIDELITY_SECONDS = 15; //note: this can be reduced using 'watch' or scout role
|
|
9
|
-
//default resolution/fidelity when expiring
|
|
10
|
-
const EXPIRATION_FIDELITY_SECONDS = 60;
|
|
8
|
+
import { JobCompletionOptions } from '../../types/job';
|
|
9
|
+
import { RedisClient, RedisMulti } from '../../types/redis';
|
|
11
10
|
|
|
12
11
|
class TaskService {
|
|
13
12
|
store: StoreService<RedisClient, RedisMulti>;
|
|
@@ -43,25 +42,26 @@ class TaskService {
|
|
|
43
42
|
await this.store.addTaskQueues(keys);
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
async registerJobForCleanup(jobId: string, inSeconds =
|
|
47
|
-
if (inSeconds >
|
|
45
|
+
async registerJobForCleanup(jobId: string, inSeconds = EXPIRE_DURATION, options: JobCompletionOptions): Promise<void> {
|
|
46
|
+
if (inSeconds > 0) {
|
|
48
47
|
await this.store.expireJob(jobId, inSeconds);
|
|
48
|
+
const expireTimeSlot = Math.floor((Date.now() + (inSeconds * 1000)) / (FIDELITY_SECONDS * 1000)) * (FIDELITY_SECONDS * 1000); //n second awaken groups
|
|
49
|
+
await this.store.registerExpireJob(jobId, expireTimeSlot, options);
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
async registerTimeHook(jobId: string, activityId: string, type: 'sleep'|'expire'|'
|
|
53
|
-
const awakenTimeSlot = Math.floor((Date.now() + inSeconds * 1000) / (FIDELITY_SECONDS * 1000)) * (FIDELITY_SECONDS * 1000); //n second awaken groups
|
|
53
|
+
async registerTimeHook(jobId: string, activityId: string, type: 'sleep'|'expire'|'interrupt', inSeconds = FIDELITY_SECONDS, multi?: RedisMulti): Promise<void> {
|
|
54
|
+
const awakenTimeSlot = Math.floor((Date.now() + (inSeconds * 1000)) / (FIDELITY_SECONDS * 1000)) * (FIDELITY_SECONDS * 1000); //n second awaken groups
|
|
54
55
|
await this.store.registerTimeHook(jobId, activityId, type, awakenTimeSlot, multi);
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
async processTimeHooks(timeEventCallback: (jobId: string, activityId: string) => Promise<void>, listKey?: string): Promise<void> {
|
|
58
|
+
async processTimeHooks(timeEventCallback: (jobId: string, activityId: string, type: 'sleep'|'expire'|'interrupt') => Promise<void>, listKey?: string): Promise<void> {
|
|
59
59
|
try {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
const [listKey, jobId, activityId] =
|
|
63
|
-
await timeEventCallback(jobId, activityId);
|
|
64
|
-
await sleepFor(0);
|
|
60
|
+
const timeJob = await this.store.getNextTimeJob(listKey);
|
|
61
|
+
if (timeJob) {
|
|
62
|
+
const [listKey, jobId, activityId, type] = timeJob;
|
|
63
|
+
await timeEventCallback(jobId, activityId, type);
|
|
64
|
+
await sleepFor(0);
|
|
65
65
|
this.processTimeHooks(timeEventCallback, listKey);
|
|
66
66
|
} else {
|
|
67
67
|
let sleep = XSleepFor(FIDELITY_SECONDS * 1000);
|
package/types/activity.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MetricTypes } from "./stats";
|
|
2
2
|
import { StreamRetryPolicy } from "./stream";
|
|
3
3
|
|
|
4
|
-
type ActivityExecutionType = 'trigger' | 'await' | 'worker' | 'activity' | 'emit' | '
|
|
4
|
+
type ActivityExecutionType = 'trigger' | 'await' | 'worker' | 'activity' | 'emit' | 'interrupt' | 'cycle' | 'signal' | 'hook';
|
|
5
5
|
|
|
6
6
|
type Consumes = Record<string, string[]>;
|
|
7
7
|
|
|
@@ -17,7 +17,7 @@ interface BaseActivity {
|
|
|
17
17
|
telemetry?: Record<string, any>;
|
|
18
18
|
emit?: boolean; //if true, the activity will emit a message to the `publishes` topic immediately before transitioning to adjacent activities
|
|
19
19
|
sleep?: number; //@pipe /in seconds
|
|
20
|
-
expire?: number; //-1 forever
|
|
20
|
+
expire?: number; //-1 forever; 0 persists the flow until the parent flow that expired it is dismissed; 15 seconds is the default
|
|
21
21
|
retry?: StreamRetryPolicy
|
|
22
22
|
cycle?: boolean; //if true, the `notary` will leave leg 2 open, so it can be re/cycled
|
|
23
23
|
collationInt?: number; //compiler
|
|
@@ -37,10 +37,30 @@ interface Measure {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
interface TriggerActivityStats {
|
|
40
|
+
/**
|
|
41
|
+
* parent job; including this allows the parent's
|
|
42
|
+
* expiration/interruption events to cascade; set
|
|
43
|
+
* `expire` in the YAML for the dependent graph
|
|
44
|
+
* to 0 and provide the parent for dependent,
|
|
45
|
+
* cascading interruption and cleanup
|
|
46
|
+
*/
|
|
47
|
+
parent?: string;
|
|
40
48
|
id?: { [key: string]: unknown } | string;
|
|
41
49
|
key?: { [key: string]: unknown } | string;
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
/**
|
|
51
|
+
* @deprecated
|
|
52
|
+
* return 'infinity' to disable; default behavior
|
|
53
|
+
* is to always segment keys by time to ensure
|
|
54
|
+
* indexes (Redis LIST) never grow unbounded
|
|
55
|
+
* as a default behavior; for now, 5m is default
|
|
56
|
+
* and infinity can be set to override
|
|
57
|
+
*/
|
|
58
|
+
granularity?: string;
|
|
59
|
+
/**
|
|
60
|
+
* @deprecated
|
|
61
|
+
* what to capture
|
|
62
|
+
*/
|
|
63
|
+
measures?: Measure[];
|
|
44
64
|
}
|
|
45
65
|
|
|
46
66
|
interface TriggerActivity extends BaseActivity {
|
|
@@ -82,11 +102,21 @@ interface SignalActivity extends BaseActivity {
|
|
|
82
102
|
code?: number; //202, 200 (default)
|
|
83
103
|
}
|
|
84
104
|
|
|
85
|
-
interface
|
|
86
|
-
type: '
|
|
105
|
+
interface InterruptActivity extends BaseActivity {
|
|
106
|
+
type: 'interrupt';
|
|
107
|
+
/** Optional Reason; will be used as the error `message` when thrown; NOTE: 410 is the error `code` */
|
|
108
|
+
reason?: string;
|
|
109
|
+
/** default is `true` (throw JobInterrupted error upon interrupting) */
|
|
110
|
+
throw?: boolean;
|
|
111
|
+
/** TODO: // default is `false` (do not interrupt child jobs) */
|
|
112
|
+
descend?: boolean;
|
|
113
|
+
/** target job id (if not present the current job will be targeted) */
|
|
114
|
+
target?: string;
|
|
115
|
+
/** topic to publish the interrupt message (if not present the current job topic will be used) */
|
|
116
|
+
topic?: string;
|
|
87
117
|
}
|
|
88
118
|
|
|
89
|
-
type ActivityType = BaseActivity | TriggerActivity | AwaitActivity | WorkerActivity |
|
|
119
|
+
type ActivityType = BaseActivity | TriggerActivity | AwaitActivity | WorkerActivity | InterruptActivity | HookActivity | SignalActivity | CycleActivity;
|
|
90
120
|
|
|
91
121
|
type ActivityData = Record<string, any>;
|
|
92
122
|
type ActivityMetadata = {
|
|
@@ -133,7 +163,7 @@ export {
|
|
|
133
163
|
HookActivity,
|
|
134
164
|
SignalActivity,
|
|
135
165
|
BaseActivity,
|
|
136
|
-
|
|
166
|
+
InterruptActivity,
|
|
137
167
|
TriggerActivity,
|
|
138
168
|
WorkerActivity
|
|
139
169
|
};
|
package/types/durable.ts
CHANGED
|
@@ -60,10 +60,12 @@ type WorkflowOptions = {
|
|
|
60
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
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
|
+
originJobId?: string; //system reserved;
|
|
63
64
|
workflowTrace?: string;
|
|
64
65
|
workflowSpan?: string;
|
|
65
66
|
search?: WorkflowSearchOptions
|
|
66
67
|
config?: WorkflowConfig;
|
|
68
|
+
expire?: number; //default is 3seconds; time before completed jobs and dependents are expired/scrubbed/removed
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
type HookOptions = {
|
|
@@ -80,8 +82,8 @@ type HookOptions = {
|
|
|
80
82
|
type SignalOptions = {
|
|
81
83
|
taskQueue: string;
|
|
82
84
|
data: Record<string, any>; //input data (any serializable object)
|
|
83
|
-
workflowId: string;
|
|
84
|
-
workflowName?: string;
|
|
85
|
+
workflowId: string; //execution id (the job id)
|
|
86
|
+
workflowName?: string; //the name of the user's workflow function
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
type ActivityWorkflowDataType = {
|
|
@@ -95,6 +97,8 @@ type WorkflowDataType = {
|
|
|
95
97
|
arguments: any[];
|
|
96
98
|
workflowId: string;
|
|
97
99
|
workflowTopic: string;
|
|
100
|
+
workflowDimension?: string; //is present if hook (not main workflow)
|
|
101
|
+
originJobId?: string; //is present if there is an originating ancestor job (should rename to originJobId)
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
type MeshOSClassConfig = {
|
|
@@ -105,8 +109,8 @@ type MeshOSClassConfig = {
|
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
type MeshOSConfig = {
|
|
108
|
-
id?: string;
|
|
109
|
-
await?: boolean;
|
|
112
|
+
id?: string; //guid for the workflow when instancing
|
|
113
|
+
await?: boolean; //default is false; must explicitly send true to await the final result
|
|
110
114
|
taskQueue?: string; //optional target queue isolate for the function
|
|
111
115
|
}
|
|
112
116
|
|
package/types/index.ts
CHANGED
package/types/job.ts
CHANGED
|
@@ -44,6 +44,19 @@ type JobState = {
|
|
|
44
44
|
};
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
type JobInterruptOptions = {
|
|
48
|
+
/** Optional reason when throwing the error */
|
|
49
|
+
reason?: string;
|
|
50
|
+
/** default is `true` when `undefined` (throw JobInterrupted/410 error) */
|
|
51
|
+
throw?: boolean;
|
|
52
|
+
/** default behavior is `false` when `undefined` (do NOT interrupt child jobs) */
|
|
53
|
+
descend?: boolean;
|
|
54
|
+
/** default is false; if true, errors related to inactivation (like overage...already inactive) are suppressed/ignored */
|
|
55
|
+
suppress?: boolean;
|
|
56
|
+
/** how long to wait in seconds before fully expiring/removing the hash from Redis; the job is inactive, but can remain in the cache indefinitely. minimum 1 second.*/
|
|
57
|
+
expire?: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
47
60
|
//format when publishing job meta/data on the wire when it completes
|
|
48
61
|
type JobOutput = {
|
|
49
62
|
metadata: JobMetadata;
|
|
@@ -56,4 +69,20 @@ type PartialJobState = {
|
|
|
56
69
|
data: JobData;
|
|
57
70
|
};
|
|
58
71
|
|
|
59
|
-
|
|
72
|
+
type JobCompletionOptions = {
|
|
73
|
+
emit?: boolean; //default false
|
|
74
|
+
interrupt?: boolean; //default undefined
|
|
75
|
+
expire?: number; // in seconds to wait before deleting/expiring job hash
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export {
|
|
79
|
+
JobCompletionOptions,
|
|
80
|
+
JobInterruptOptions,
|
|
81
|
+
JobData,
|
|
82
|
+
JobsData,
|
|
83
|
+
JobMetadata,
|
|
84
|
+
JobOutput,
|
|
85
|
+
JobState,
|
|
86
|
+
JobStatus,
|
|
87
|
+
PartialJobState,
|
|
88
|
+
};
|
package/types/quorum.ts
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import { JobOutput } from "./job";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The types in this file are used to define those messages that are sent
|
|
5
|
-
* to hotmesh client instances when a new version is about to be activated.
|
|
6
|
-
* These messages serve to coordinate the cache invalidation and switch-over
|
|
7
|
-
* to the new version without any downtime and a coordinating parent server.
|
|
8
|
-
*/
|
|
9
|
-
export type QuorumMessage = PingMessage | PongMessage | ActivateMessage | WorkMessage | JobMessage | ThrottleMessage;
|
|
10
|
-
|
|
11
3
|
//used for coordination like version activation
|
|
12
4
|
export interface PingMessage {
|
|
13
5
|
type: 'ping';
|
|
@@ -19,6 +11,11 @@ export interface WorkMessage {
|
|
|
19
11
|
originator: string; //guid
|
|
20
12
|
}
|
|
21
13
|
|
|
14
|
+
export interface CronMessage {
|
|
15
|
+
type: 'cron';
|
|
16
|
+
originator: string; //guid
|
|
17
|
+
}
|
|
18
|
+
|
|
22
19
|
//used for coordination like version activation
|
|
23
20
|
export interface PongMessage {
|
|
24
21
|
type: 'pong';
|
|
@@ -57,3 +54,11 @@ export interface SubscriptionCallback {
|
|
|
57
54
|
export interface QuorumMessageCallback {
|
|
58
55
|
(topic: string, message: QuorumMessage): void;
|
|
59
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The types in this file are used to define those messages that are sent
|
|
60
|
+
* to hotmesh client instances when a new version is about to be activated.
|
|
61
|
+
* These messages serve to coordinate the cache invalidation and switch-over
|
|
62
|
+
* to the new version without any downtime and a coordinating parent server.
|
|
63
|
+
*/
|
|
64
|
+
export type QuorumMessage = PingMessage | PongMessage | ActivateMessage | WorkMessage | JobMessage | ThrottleMessage | CronMessage;
|
package/types/stream.ts
CHANGED
|
@@ -27,10 +27,13 @@ export enum StreamDataType {
|
|
|
27
27
|
WORKER = 'worker',
|
|
28
28
|
RESPONSE = 'response', //worker response
|
|
29
29
|
TRANSITION = 'transition',
|
|
30
|
+
SIGNAL = 'signal',
|
|
31
|
+
INTERRUPT = 'interrupt',
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface StreamData {
|
|
33
35
|
metadata: {
|
|
36
|
+
guid: string; //every message is minted with a guid to distinguish retries from new messages
|
|
34
37
|
topic?: string;
|
|
35
38
|
jid?: string; //is optonal if type is WEBHOOK
|
|
36
39
|
dad?: string; //dimensional address
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { EngineService } from '../engine';
|
|
2
|
-
import { Activity, ActivityType } from './activity';
|
|
3
|
-
import { ActivityData, ActivityMetadata, IterateActivity } from '../../types/activity';
|
|
4
|
-
declare class Iterate extends Activity {
|
|
5
|
-
config: IterateActivity;
|
|
6
|
-
constructor(config: ActivityType, data: ActivityData, metadata: ActivityMetadata, hook: ActivityData | null, engine: EngineService);
|
|
7
|
-
mapInputData(): Promise<void>;
|
|
8
|
-
}
|
|
9
|
-
export { Iterate };
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Iterate = void 0;
|
|
4
|
-
const activity_1 = require("./activity");
|
|
5
|
-
class Iterate extends activity_1.Activity {
|
|
6
|
-
constructor(config, data, metadata, hook, engine) {
|
|
7
|
-
super(config, data, metadata, hook, engine);
|
|
8
|
-
}
|
|
9
|
-
async mapInputData() {
|
|
10
|
-
this.logger.info('iterate-map-input-data');
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
exports.Iterate = Iterate;
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { EngineService } from '../engine';
|
|
2
|
-
import { Activity, ActivityType } from './activity';
|
|
3
|
-
import {
|
|
4
|
-
ActivityData,
|
|
5
|
-
ActivityMetadata,
|
|
6
|
-
IterateActivity } from '../../types/activity';
|
|
7
|
-
|
|
8
|
-
class Iterate extends Activity {
|
|
9
|
-
config: IterateActivity;
|
|
10
|
-
|
|
11
|
-
constructor(
|
|
12
|
-
config: ActivityType,
|
|
13
|
-
data: ActivityData,
|
|
14
|
-
metadata: ActivityMetadata,
|
|
15
|
-
hook: ActivityData | null,
|
|
16
|
-
engine: EngineService
|
|
17
|
-
) {
|
|
18
|
-
super(config, data, metadata, hook, engine);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async mapInputData(): Promise<void> {
|
|
22
|
-
this.logger.info('iterate-map-input-data');
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export { Iterate };
|