@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. It looks for work every `pollInterval`, and at once
6
- * after `kick()` (a local write made something due) or when an item ends. `drain()` runs due items on demand, for
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. It looks for work every `pollInterval`, and at once
11
- * after `kick()` (a local write made something due) or when an item ends. `drain()` runs due items on demand, for
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
- clearInterval(run.heartbeat);
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
- async renew() {
267
- if (this.lost || this.isDetached) {
268
- return !this.lost;
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
- return held;
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
  }
@@ -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",
4
- "description": "Nest - modern, fast, powerful node.js web framework (@workflows)",
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.12",
73
- "@nestjs/common": "12.2.0",
74
- "@nestjs/core": "12.2.0",
75
- "@nestjs/cqrs": "12.1.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.4",
89
+ "@nestjs/locks": "0.0.5",
78
90
  "@nestjs/mail": "0.0.1",
79
- "@nestjs/outbox": "0.1.3",
80
- "@nestjs/platform-express": "12.2.0",
81
- "@nestjs/platform-fastify": "12.2.0",
82
- "@nestjs/testing": "12.2.0",
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.7",
102
+ "kysely": "0.29.8",
91
103
  "lint-staged": "17.6.0",
92
104
  "mysql2": "3.24.6",
93
- "oxlint": "1.89.0",
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.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.5",
103
- "vitest": "5.0.5"
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",