@nestjs/workflows 0.0.4 → 0.0.5
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.
|
@@ -40,7 +40,11 @@ export interface LeasedRun {
|
|
|
40
40
|
readonly detached: boolean;
|
|
41
41
|
/**
|
|
42
42
|
* Renews the lease now, as the heartbeat does (a long step that proves it's alive): `false` once it's lost. A renewal
|
|
43
|
-
* that fails (the store is unreachable) keeps the lease, which the next heartbeat renews before it runs out.
|
|
43
|
+
* that fails (the store is unreachable) keeps the lease, which the next heartbeat renews before it runs out. One
|
|
44
|
+
* renewal is in flight at a time: called while one is (a heartbeat's held up by the store), it waits for that one, then
|
|
45
|
+
* for one more that goes out as soon as it answered, or beside it once it went a heartbeat interval unanswered, and
|
|
46
|
+
* resolves with that one's answer. None goes once the lease was lost, the run ended or was detached, before the call
|
|
47
|
+
* or meanwhile: it then resolves `false` if the lease was lost, else `true`.
|
|
44
48
|
*/
|
|
45
49
|
renew(): Promise<boolean>;
|
|
46
50
|
/** Marks the lease lost, when a fenced write found it gone: aborts `signal` and stops renewing. */
|
|
@@ -2,8 +2,11 @@ import type { LeasedWorkerOptions } from '../interfaces/leased-worker-options.in
|
|
|
2
2
|
/**
|
|
3
3
|
* A claim-execute loop under leases, any number of processes sharing a store: it claims due items (as many as it has
|
|
4
4
|
* free slots), executes them, renews each one's lease every `heartbeatInterval` (a third of the lease by default), and
|
|
5
|
-
* aborts an item's `signal` when a renewal finds its lease gone.
|
|
6
|
-
*
|
|
5
|
+
* aborts an item's `signal` when a renewal finds its lease gone. An item has one renewal in flight at a time, so a store
|
|
6
|
+
* that holds a renewal up (a lock its row waits on) gets no more of them meanwhile: one asked for in the meantime goes
|
|
7
|
+
* out once that one answered, or beside it once it went a heartbeat interval unanswered (a connection that died without
|
|
8
|
+
* an error never answers), and none more while those two are in flight. It looks for work every `pollInterval`, and at
|
|
9
|
+
* once after `kick()` (a local write made something due) or when an item ends. `drain()` runs due items on demand, for
|
|
7
10
|
* tests and scripts.
|
|
8
11
|
*
|
|
9
12
|
* Shutdown is graceful: call `shutdown()` from `onModuleDestroy`. It stops claiming, waits for the claims in flight
|
|
@@ -7,8 +7,11 @@ import { setRepeatingTimer, setTimer } from '../utils/timer.util.js';
|
|
|
7
7
|
/**
|
|
8
8
|
* A claim-execute loop under leases, any number of processes sharing a store: it claims due items (as many as it has
|
|
9
9
|
* free slots), executes them, renews each one's lease every `heartbeatInterval` (a third of the lease by default), and
|
|
10
|
-
* aborts an item's `signal` when a renewal finds its lease gone.
|
|
11
|
-
*
|
|
10
|
+
* aborts an item's `signal` when a renewal finds its lease gone. An item has one renewal in flight at a time, so a store
|
|
11
|
+
* that holds a renewal up (a lock its row waits on) gets no more of them meanwhile: one asked for in the meantime goes
|
|
12
|
+
* out once that one answered, or beside it once it went a heartbeat interval unanswered (a connection that died without
|
|
13
|
+
* an error never answers), and none more while those two are in flight. It looks for work every `pollInterval`, and at
|
|
14
|
+
* once after `kick()` (a local write made something due) or when an item ends. `drain()` runs due items on demand, for
|
|
12
15
|
* tests and scripts.
|
|
13
16
|
*
|
|
14
17
|
* Shutdown is graceful: call `shutdown()` from `onModuleDestroy`. It stops claiming, waits for the claims in flight
|
|
@@ -211,7 +214,7 @@ export class LeasedWorker {
|
|
|
211
214
|
}
|
|
212
215
|
}
|
|
213
216
|
launch(item) {
|
|
214
|
-
const run = new Run(item, () => this.renew(run));
|
|
217
|
+
const run = new Run(item, this.heartbeatMs, () => this.renew(run));
|
|
215
218
|
this.runs.add(run);
|
|
216
219
|
if (this.stopped) {
|
|
217
220
|
run.halt();
|
|
@@ -228,7 +231,7 @@ export class LeasedWorker {
|
|
|
228
231
|
run.done = executing
|
|
229
232
|
.catch((error) => this.report(error, 'execute'))
|
|
230
233
|
.finally(() => {
|
|
231
|
-
|
|
234
|
+
run.end();
|
|
232
235
|
this.runs.delete(run);
|
|
233
236
|
});
|
|
234
237
|
return run.done;
|
|
@@ -247,12 +250,21 @@ export class LeasedWorker {
|
|
|
247
250
|
}
|
|
248
251
|
/** A claimed item's run: its signal, its heartbeat, and what became of its lease. */
|
|
249
252
|
class Run {
|
|
250
|
-
constructor(item, renewer) {
|
|
253
|
+
constructor(item, heartbeatMs, renewer) {
|
|
251
254
|
this.item = item;
|
|
255
|
+
this.heartbeatMs = heartbeatMs;
|
|
252
256
|
this.renewer = renewer;
|
|
253
257
|
this.controller = new AbortController();
|
|
254
258
|
this.lost = false;
|
|
255
259
|
this.isDetached = false;
|
|
260
|
+
/** Its `execute` settled: no more renewals. */
|
|
261
|
+
this.ended = false;
|
|
262
|
+
/** The renewals in flight: one, or two once the older went a heartbeat interval unanswered. */
|
|
263
|
+
this.inFlight = new Set();
|
|
264
|
+
/** How many renewals went out: the next one's `seq` follows. */
|
|
265
|
+
this.sent = 0;
|
|
266
|
+
/** The `seq` of the latest renewal that landed (the store wrote its `until`). */
|
|
267
|
+
this.landed = 0;
|
|
256
268
|
}
|
|
257
269
|
get signal() {
|
|
258
270
|
return this.controller.signal;
|
|
@@ -263,10 +275,78 @@ class Run {
|
|
|
263
275
|
get detached() {
|
|
264
276
|
return this.isDetached;
|
|
265
277
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Renews the lease (a heartbeat's tick, or the item's own call), one renewal at a time: a store that holds one up
|
|
280
|
+
* would otherwise get another at each tick, each holding a connection, and an older one could land after a newer
|
|
281
|
+
* one, shortening the lease. One asked for while another is in flight sends nothing then: it goes out once that one
|
|
282
|
+
* answered, its `until` read then (so after a renewal that was held up, the lease's deadline counts from when it
|
|
283
|
+
* answered), those asked for meanwhile sharing it, and resolves with its answer. A renewal a heartbeat interval
|
|
284
|
+
* unanswered no longer holds the next one back, as it may never answer (a connection that died without an error):
|
|
285
|
+
* the next goes out beside it, and no third while both are in flight. One that lands after a later one did leaves
|
|
286
|
+
* its older `until`, so one more follows it. None goes once the lease is lost (an answer, or `loseLease()`), the run
|
|
287
|
+
* ended, or it was detached: those asked for meanwhile resolve as `renew()` would then.
|
|
288
|
+
*/
|
|
289
|
+
renew() {
|
|
290
|
+
if (this.lost || this.isDetached || this.ended) {
|
|
291
|
+
return Promise.resolve(!this.lost);
|
|
292
|
+
}
|
|
293
|
+
return this.canSend() ? this.send() : this.ask();
|
|
294
|
+
}
|
|
295
|
+
/** Whether a renewal can go out now: none is in flight, or one went a heartbeat interval unanswered; never a third. */
|
|
296
|
+
canSend() {
|
|
297
|
+
const [oldest, next] = this.inFlight;
|
|
298
|
+
return !oldest || (!next && oldest.late);
|
|
299
|
+
}
|
|
300
|
+
/** Asks for the renewal that goes out as soon as one can (`flush()`): its callers' answer. */
|
|
301
|
+
ask() {
|
|
302
|
+
if (!this.again) {
|
|
303
|
+
let resolve;
|
|
304
|
+
const promise = new Promise((resolved) => (resolve = resolved));
|
|
305
|
+
this.again = { promise, resolve };
|
|
306
|
+
}
|
|
307
|
+
return this.again.promise;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Sends the renewal asked for meanwhile if one can go out now (a renewal answered, or went a heartbeat interval
|
|
311
|
+
* unanswered). None goes once the lease is lost, the run ended or was detached: its callers hear what `renew()` would
|
|
312
|
+
* say then.
|
|
313
|
+
*/
|
|
314
|
+
flush() {
|
|
315
|
+
const again = this.again;
|
|
316
|
+
if (!again) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (this.lost || this.isDetached || this.ended) {
|
|
320
|
+
this.again = undefined;
|
|
321
|
+
again.resolve(!this.lost);
|
|
322
|
+
}
|
|
323
|
+
else if (this.canSend()) {
|
|
324
|
+
this.again = undefined;
|
|
325
|
+
void this.send().then(again.resolve);
|
|
269
326
|
}
|
|
327
|
+
}
|
|
328
|
+
/** Sends a renewal, and once it answered, before its callers hear it, the one asked for meanwhile if it can go. */
|
|
329
|
+
send() {
|
|
330
|
+
const renewal = {
|
|
331
|
+
seq: ++this.sent,
|
|
332
|
+
late: false,
|
|
333
|
+
// Unanswered for a heartbeat interval, it no longer holds the next one back.
|
|
334
|
+
timer: setTimer(() => {
|
|
335
|
+
renewal.late = true;
|
|
336
|
+
this.flush();
|
|
337
|
+
}, this.heartbeatMs).unref(),
|
|
338
|
+
};
|
|
339
|
+
this.inFlight.add(renewal);
|
|
340
|
+
const answer = this.renewOnce(renewal.seq);
|
|
341
|
+
void answer.then(() => {
|
|
342
|
+
clearTimeout(renewal.timer);
|
|
343
|
+
this.inFlight.delete(renewal);
|
|
344
|
+
this.flush();
|
|
345
|
+
});
|
|
346
|
+
return answer;
|
|
347
|
+
}
|
|
348
|
+
/** One renewal: `false` once it found the lease gone; one that fails keeps the lease. */
|
|
349
|
+
async renewOnce(seq) {
|
|
270
350
|
let held;
|
|
271
351
|
try {
|
|
272
352
|
held = await this.renewer();
|
|
@@ -277,8 +357,14 @@ class Run {
|
|
|
277
357
|
}
|
|
278
358
|
if (!held) {
|
|
279
359
|
this.loseLease();
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
// It landed after a later renewal did (on a slower connection): the store keeps its older `until` now.
|
|
363
|
+
if (seq < this.landed) {
|
|
364
|
+
void this.ask();
|
|
280
365
|
}
|
|
281
|
-
|
|
366
|
+
this.landed = Math.max(this.landed, seq);
|
|
367
|
+
return true;
|
|
282
368
|
}
|
|
283
369
|
loseLease() {
|
|
284
370
|
if (this.lost) {
|
|
@@ -299,4 +385,9 @@ class Run {
|
|
|
299
385
|
this.isDetached = true;
|
|
300
386
|
clearInterval(this.heartbeat);
|
|
301
387
|
}
|
|
388
|
+
/** Its `execute` settled: no more heartbeats, and no more renewals. */
|
|
389
|
+
end() {
|
|
390
|
+
this.ended = true;
|
|
391
|
+
clearInterval(this.heartbeat);
|
|
392
|
+
}
|
|
302
393
|
}
|
package/dist/testing/index.js
CHANGED
|
@@ -426,11 +426,11 @@ export function workflowStoreContract(createStore, options = {}) {
|
|
|
426
426
|
add('write() with a signal records it as signal() does, only when the write lands', async (t) => {
|
|
427
427
|
await t.create('parent', 0);
|
|
428
428
|
await t.create('child', 1);
|
|
429
|
-
await t.claim(1, { token: 'p', limit: 1 });
|
|
429
|
+
equal((await t.claim(1, { token: 'p', limit: 1 })).instances.map((i) => i.id), ['parent'], 'the parent, claimed');
|
|
430
430
|
const parked = { wakeAt: FAR, waits: [{ signal: 'child.ended', key: 'child' }], signalCursor: 0 };
|
|
431
|
-
await t.store.write('parent', 'p', { now: 2, entries: [], status: 'suspended', release: parked });
|
|
432
|
-
await t.claim(3, { token: 'stale', leaseUntil: 4 });
|
|
433
|
-
await t.claim(5, { token: 'c' });
|
|
431
|
+
equal(await t.store.write('parent', 'p', { now: 2, entries: [], status: 'suspended', release: parked }), true, 'the parent, parked');
|
|
432
|
+
equal((await t.claim(3, { token: 'stale', leaseUntil: 4 })).instances.map((i) => i.id), ['child'], 'the child, claimed under a lease that ends at 4');
|
|
433
|
+
equal((await t.claim(5, { token: 'c' })).instances.map((i) => i.id), ['child'], 'the child, claimed again once that lease ended');
|
|
434
434
|
const ended = { name: 'child.ended', key: 'child', dedupeId: 'child', payload: { status: 'completed', output: { label: 'LBL-1' } }, now: 6 };
|
|
435
435
|
const finish = (token, now) => t.store.write('child', token, { now, entries: [], status: 'completed', output: 1, error: null, release: { wakeAt: null, waits: [], signalCursor: 0 }, signal: { ...ended, now } });
|
|
436
436
|
equal(await finish('stale', 6), false, 'a stale write');
|
package/package.json
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestjs/workflows",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.5",
|
|
4
|
+
"description": "Durable workflows for NestJS: long-running processes written as plain async code that survive restarts, with no workflow server to run.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"nestjs",
|
|
7
|
+
"nest",
|
|
8
|
+
"workflow",
|
|
9
|
+
"workflows",
|
|
10
|
+
"durable-execution",
|
|
11
|
+
"durable-workflows",
|
|
12
|
+
"saga",
|
|
13
|
+
"orchestration",
|
|
14
|
+
"long-running",
|
|
15
|
+
"compensation"
|
|
16
|
+
],
|
|
5
17
|
"author": "Kamil Mysliwiec",
|
|
6
18
|
"license": "MIT",
|
|
7
19
|
"url": "https://github.com/nestjs/workflows#readme",
|
|
@@ -69,17 +81,17 @@
|
|
|
69
81
|
"devDependencies": {
|
|
70
82
|
"@commitlint/cli": "21.2.4",
|
|
71
83
|
"@commitlint/config-angular": "21.2.4",
|
|
72
|
-
"@electric-sql/pglite": "0.5.
|
|
73
|
-
"@nestjs/common": "12.
|
|
74
|
-
"@nestjs/core": "12.
|
|
75
|
-
"@nestjs/cqrs": "12.1.
|
|
84
|
+
"@electric-sql/pglite": "0.5.15",
|
|
85
|
+
"@nestjs/common": "12.3.3",
|
|
86
|
+
"@nestjs/core": "12.3.3",
|
|
87
|
+
"@nestjs/cqrs": "12.1.2",
|
|
76
88
|
"@nestjs/drizzle": "0.0.2",
|
|
77
|
-
"@nestjs/locks": "0.0.
|
|
89
|
+
"@nestjs/locks": "0.0.5",
|
|
78
90
|
"@nestjs/mail": "0.0.1",
|
|
79
|
-
"@nestjs/outbox": "0.1.
|
|
80
|
-
"@nestjs/platform-express": "12.
|
|
81
|
-
"@nestjs/platform-fastify": "12.
|
|
82
|
-
"@nestjs/testing": "12.
|
|
91
|
+
"@nestjs/outbox": "0.1.4",
|
|
92
|
+
"@nestjs/platform-express": "12.3.3",
|
|
93
|
+
"@nestjs/platform-fastify": "12.3.3",
|
|
94
|
+
"@nestjs/testing": "12.3.3",
|
|
83
95
|
"@prisma/adapter-mariadb": "7.10.1",
|
|
84
96
|
"@prisma/adapter-pg": "7.10.1",
|
|
85
97
|
"@prisma/client": "7.10.1",
|
|
@@ -87,20 +99,20 @@
|
|
|
87
99
|
"@types/pg": "8.23.1",
|
|
88
100
|
"drizzle-orm": "0.45.4",
|
|
89
101
|
"husky": "9.1.7",
|
|
90
|
-
"kysely": "0.29.
|
|
102
|
+
"kysely": "0.29.8",
|
|
91
103
|
"lint-staged": "17.6.0",
|
|
92
104
|
"mysql2": "3.24.6",
|
|
93
|
-
"oxlint": "1.
|
|
105
|
+
"oxlint": "1.91.0",
|
|
94
106
|
"pg": "8.23.1",
|
|
95
107
|
"prettier": "3.9.10",
|
|
96
108
|
"prisma": "7.10.1",
|
|
97
109
|
"reflect-metadata": "0.2.2",
|
|
98
|
-
"release-it": "21.1.
|
|
110
|
+
"release-it": "21.1.2",
|
|
99
111
|
"rxjs": "7.8.2",
|
|
100
112
|
"typeorm": "1.1.2",
|
|
101
113
|
"typescript": "7.0.2",
|
|
102
|
-
"vite": "8.3.
|
|
103
|
-
"vitest": "5.0
|
|
114
|
+
"vite": "8.3.6",
|
|
115
|
+
"vitest": "5.1.0"
|
|
104
116
|
},
|
|
105
117
|
"peerDependencies": {
|
|
106
118
|
"@nestjs/common": "^11.0.0 || ^12.0.0",
|