@nestjs/workflows 0.0.1 → 0.0.3
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/dist/core/results/result-waiter.d.ts +5 -0
- package/dist/core/results/result-waiter.js +17 -6
- package/dist/core/workers/leased-worker.d.ts +7 -2
- package/dist/core/workers/leased-worker.js +24 -3
- package/dist/index.d.ts +1 -1
- package/dist/interfaces/workflow-store.interface.d.ts +11 -0
- package/dist/mysql/mysql-workflow.store.d.ts +6 -2
- package/dist/mysql/mysql-workflow.store.js +20 -4
- package/dist/services/workflow-execution.service.d.ts +8 -2
- package/dist/services/workflow-execution.service.js +43 -5
- package/dist/storage/encoded-workflow.store.d.ts +1 -0
- package/dist/storage/encoded-workflow.store.js +4 -0
- package/package.json +18 -18
|
@@ -49,6 +49,11 @@ export declare class ResultWaiter<T = unknown> {
|
|
|
49
49
|
* connections idle at that moment, and one that answers later stays open until its keep-alive timeout.
|
|
50
50
|
*/
|
|
51
51
|
close(): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Reads `id`, unless the wait settles first. It races a promise of its own, not one the whole wait shares: each
|
|
54
|
+
* race leaves a reaction on its inputs until they settle, so a shared one would keep one per poll.
|
|
55
|
+
*/
|
|
56
|
+
private read;
|
|
52
57
|
private add;
|
|
53
58
|
private remove;
|
|
54
59
|
}
|
|
@@ -47,13 +47,11 @@ export class ResultWaiter {
|
|
|
47
47
|
const timeoutMs = options.timeout === undefined ? Infinity : parseDuration(options.timeout);
|
|
48
48
|
const deadline = performance.now() + timeoutMs;
|
|
49
49
|
options.signal?.throwIfAborted();
|
|
50
|
-
|
|
51
|
-
const settled = new Promise((resolve) => (settle = () => resolve(SETTLED)));
|
|
52
|
-
const wait = { closed: this.closed, settle };
|
|
50
|
+
const wait = { closed: this.closed };
|
|
53
51
|
const nudge = () => wait.wake?.();
|
|
54
52
|
const close = () => {
|
|
55
53
|
wait.closed = true;
|
|
56
|
-
settle();
|
|
54
|
+
wait.settle?.();
|
|
57
55
|
nudge();
|
|
58
56
|
};
|
|
59
57
|
this.add(id, wait);
|
|
@@ -61,7 +59,7 @@ export class ResultWaiter {
|
|
|
61
59
|
options.signal?.addEventListener('abort', nudge);
|
|
62
60
|
try {
|
|
63
61
|
for (let delay = 25;; delay = Math.min(delay * 2, 1_000)) {
|
|
64
|
-
const read = wait.outcome || wait.closed ? SETTLED : await
|
|
62
|
+
const read = wait.outcome || wait.closed ? SETTLED : await this.read(id, wait);
|
|
65
63
|
if (wait.outcome) {
|
|
66
64
|
return unwrap(wait.outcome);
|
|
67
65
|
}
|
|
@@ -104,7 +102,7 @@ export class ResultWaiter {
|
|
|
104
102
|
const settled = typeof outcome === 'function' ? outcome() : outcome;
|
|
105
103
|
for (const wait of waits) {
|
|
106
104
|
wait.outcome ??= settled;
|
|
107
|
-
wait.settle();
|
|
105
|
+
wait.settle?.();
|
|
108
106
|
wait.wake?.();
|
|
109
107
|
}
|
|
110
108
|
}
|
|
@@ -117,6 +115,19 @@ export class ResultWaiter {
|
|
|
117
115
|
this.closing.abort();
|
|
118
116
|
await nextMacrotask();
|
|
119
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Reads `id`, unless the wait settles first. It races a promise of its own, not one the whole wait shares: each
|
|
120
|
+
* race leaves a reaction on its inputs until they settle, so a shared one would keep one per poll.
|
|
121
|
+
*/
|
|
122
|
+
async read(id, wait) {
|
|
123
|
+
const settled = new Promise((resolve) => (wait.settle = () => resolve(SETTLED)));
|
|
124
|
+
try {
|
|
125
|
+
return await Promise.race([this.options.read(id), settled]);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
wait.settle = undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
120
131
|
add(id, wait) {
|
|
121
132
|
let waits = this.waits.get(id);
|
|
122
133
|
if (!waits) {
|
|
@@ -49,8 +49,8 @@ export declare class LeasedWorker<T> {
|
|
|
49
49
|
/** Claims in flight: shutdown waits for them, so nothing they claim starts unobserved. */
|
|
50
50
|
private readonly claiming;
|
|
51
51
|
private stopped;
|
|
52
|
-
|
|
53
|
-
private readonly
|
|
52
|
+
/** Ends the `drain()` rounds waiting now, at shutdown. */
|
|
53
|
+
private readonly stopping;
|
|
54
54
|
private wake?;
|
|
55
55
|
private kicked;
|
|
56
56
|
private loop?;
|
|
@@ -76,6 +76,11 @@ export declare class LeasedWorker<T> {
|
|
|
76
76
|
* `shutdownTimeout` spent waiting for them before the rest are detached. Call it from `onModuleDestroy`.
|
|
77
77
|
*/
|
|
78
78
|
shutdown(): Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* Waits for `work`, or until shutdown. It races a promise of its own, not one the worker keeps: each race leaves a
|
|
81
|
+
* reaction on its inputs until they settle, so a long-lived one would keep one per round.
|
|
82
|
+
*/
|
|
83
|
+
private untilStopped;
|
|
79
84
|
private poll;
|
|
80
85
|
private idle;
|
|
81
86
|
/** Claims due items and starts executing them (before any caller sees them). */
|
|
@@ -43,7 +43,8 @@ export class LeasedWorker {
|
|
|
43
43
|
/** Claims in flight: shutdown waits for them, so nothing they claim starts unobserved. */
|
|
44
44
|
this.claiming = new Set();
|
|
45
45
|
this.stopped = false;
|
|
46
|
-
|
|
46
|
+
/** Ends the `drain()` rounds waiting now, at shutdown. */
|
|
47
|
+
this.stopping = new Set();
|
|
47
48
|
this.kicked = false;
|
|
48
49
|
/** When the loop next calls `produce()` (`performance.now()`). */
|
|
49
50
|
this.produceAt = 0;
|
|
@@ -96,7 +97,7 @@ export class LeasedWorker {
|
|
|
96
97
|
break;
|
|
97
98
|
}
|
|
98
99
|
total += executions.length;
|
|
99
|
-
await
|
|
100
|
+
await this.untilStopped(Promise.all(executions));
|
|
100
101
|
}
|
|
101
102
|
return total;
|
|
102
103
|
}
|
|
@@ -109,7 +110,9 @@ export class LeasedWorker {
|
|
|
109
110
|
return;
|
|
110
111
|
}
|
|
111
112
|
this.stopped = true;
|
|
112
|
-
this.
|
|
113
|
+
for (const stop of this.stopping) {
|
|
114
|
+
stop();
|
|
115
|
+
}
|
|
113
116
|
this.wake?.();
|
|
114
117
|
// What a claim in flight claims starts now, told to stop at once, so it is handed back before the store closes.
|
|
115
118
|
await Promise.allSettled(this.claiming);
|
|
@@ -127,6 +130,24 @@ export class LeasedWorker {
|
|
|
127
130
|
}
|
|
128
131
|
await this.loop;
|
|
129
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Waits for `work`, or until shutdown. It races a promise of its own, not one the worker keeps: each race leaves a
|
|
135
|
+
* reaction on its inputs until they settle, so a long-lived one would keep one per round.
|
|
136
|
+
*/
|
|
137
|
+
async untilStopped(work) {
|
|
138
|
+
let stop;
|
|
139
|
+
const stopped = new Promise((resolve) => (stop = resolve));
|
|
140
|
+
if (this.stopped) {
|
|
141
|
+
stop();
|
|
142
|
+
}
|
|
143
|
+
this.stopping.add(stop);
|
|
144
|
+
try {
|
|
145
|
+
await Promise.race([work, stopped]);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
this.stopping.delete(stop);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
130
151
|
async poll() {
|
|
131
152
|
while (!this.stopped) {
|
|
132
153
|
if (this.options.produce && performance.now() >= this.produceAt) {
|
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export { ChildWorkflowFailedError, isWorkflowInterrupt, NonRetryableStepError, S
|
|
|
11
11
|
export type { SerializedWorkflowError } from './interfaces/index.js';
|
|
12
12
|
export { WorkflowEvents, type WorkflowCancelledEvent, type WorkflowChildStartedEvent, type WorkflowCompensatingEvent, type WorkflowCompensationFailedEvent, type WorkflowCompletedEvent, type WorkflowCustomStatusEvent, type WorkflowDeletedEvent, type WorkflowEvent, type WorkflowFailedEvent, type WorkflowJournalLargeEvent, type WorkflowResumedEvent, type WorkflowRetriedEvent, type WorkflowScheduleSkippedEvent, type WorkflowSignalReceivedEvent, type WorkflowSignalTimedOutEvent, type WorkflowStartedEvent, type WorkflowStepCompensatedEvent, type WorkflowStepCompletedEvent, type WorkflowStepFailedEvent, type WorkflowSuspendedEvent, } from './events/index.js';
|
|
13
13
|
export { WorkflowStorage } from './storage/index.js';
|
|
14
|
-
export type { NewWorkflowInstance, NewWorkflowSignal, WorkflowCancelRequest, WorkflowClaim, WorkflowClaimRequest, WorkflowConcurrencyLimit, WorkflowInstanceDetails, WorkflowListQuery, WorkflowPurgeQuery, WorkflowPurgeResult, WorkflowRateLimitRule, WorkflowRateWindow, WorkflowRelease, WorkflowReopen, WorkflowScheduleClaimRequest, WorkflowScheduleQuery, WorkflowScheduleRecord, WorkflowScheduleSave, WorkflowScheduleWrite, WorkflowSignalQuery, WorkflowSignalRecord, WorkflowSignalResult, WorkflowStorageRegisterOptions, WorkflowStore, WorkflowWrite, } from './interfaces/index.js';
|
|
14
|
+
export type { NewWorkflowInstance, NewWorkflowSignal, WorkflowCancelRequest, WorkflowClaim, WorkflowClaimRequest, WorkflowConcurrencyLimit, WorkflowInstanceDetails, WorkflowKeyKind, WorkflowListQuery, WorkflowPurgeQuery, WorkflowPurgeResult, WorkflowRateLimitRule, WorkflowRateWindow, WorkflowRelease, WorkflowReopen, WorkflowScheduleClaimRequest, WorkflowScheduleQuery, WorkflowScheduleRecord, WorkflowScheduleSave, WorkflowScheduleWrite, WorkflowSignalQuery, WorkflowSignalRecord, WorkflowSignalResult, WorkflowStorageRegisterOptions, WorkflowStore, WorkflowWrite, } from './interfaces/index.js';
|
|
15
15
|
export * from './stores/index.js';
|
|
16
16
|
export { AesGcmPayloadCodec, type AesGcmPayloadCodecOptions } from './core/codecs/index.js';
|
|
17
17
|
export type { WorkflowPayloadCodec, WorkflowPayloadContext } from './interfaces/index.js';
|
|
@@ -204,7 +204,18 @@ export interface WorkflowStore {
|
|
|
204
204
|
* See `WorkflowWrite` for what each field does.
|
|
205
205
|
*/
|
|
206
206
|
write(id: string, token: string, write: WorkflowWrite): Promise<boolean>;
|
|
207
|
+
/**
|
|
208
|
+
* Optional, for a store whose ids, names or keys are bounded (columns of a fixed length): throws, synchronously, an
|
|
209
|
+
* error that names the key (a `RangeError`) when the store can't hold `value` as a `kind`. The engine calls it for
|
|
210
|
+
* what a run makes up as it goes (a step's or a wait's name, `$compensate:<step>`, a child's id and keys, a wait's
|
|
211
|
+
* signal and key) before it journals or starts anything with it, so the error fails the run at the call that made
|
|
212
|
+
* the key, and the instance compensates, instead of failing every write that carries it. Without this method, the
|
|
213
|
+
* store's own check in `write()` or `create()` is all there is, and the instance is retried as after any store error.
|
|
214
|
+
*/
|
|
215
|
+
checkKey?(kind: WorkflowKeyKind, value: string): void;
|
|
207
216
|
}
|
|
217
|
+
/** The ids, names and keys `WorkflowStore.checkKey()` checks. */
|
|
218
|
+
export type WorkflowKeyKind = 'instanceId' | 'workflow' | 'concurrencyKey' | 'rateLimitKey' | 'journalName' | 'signal' | 'signalKey';
|
|
208
219
|
/** `WorkflowStore.get()`'s result, and `WorkflowClient.getStatus()`'s. */
|
|
209
220
|
export interface WorkflowInstanceDetails extends WorkflowInstance {
|
|
210
221
|
/** Signals the instance waits for, if suspended in `waitForSignal()`. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type OnModuleInit } from '@nestjs/common';
|
|
2
2
|
import { type MigrationSqlOptions, type MigrationStatementsOptions } from '@nestjs/store-kit/mysql';
|
|
3
3
|
import type { WorkflowInstance, WorkflowStatus } from '../interfaces/workflow-instance.interface.js';
|
|
4
|
-
import type { NewWorkflowInstance, NewWorkflowSignal, WorkflowCancelRequest, WorkflowClaim, WorkflowClaimRequest, WorkflowInstanceDetails, WorkflowListQuery, WorkflowPurgeQuery, WorkflowPurgeResult, WorkflowReopen, WorkflowScheduleClaimRequest, WorkflowScheduleQuery, WorkflowScheduleRecord, WorkflowScheduleSave, WorkflowScheduleWrite, WorkflowSignalQuery, WorkflowSignalRecord, WorkflowSignalResult, WorkflowStore, WorkflowWrite } from '../interfaces/workflow-store.interface.js';
|
|
4
|
+
import type { NewWorkflowInstance, NewWorkflowSignal, WorkflowCancelRequest, WorkflowClaim, WorkflowClaimRequest, WorkflowInstanceDetails, WorkflowKeyKind, WorkflowListQuery, WorkflowPurgeQuery, WorkflowPurgeResult, WorkflowReopen, WorkflowScheduleClaimRequest, WorkflowScheduleQuery, WorkflowScheduleRecord, WorkflowScheduleSave, WorkflowScheduleWrite, WorkflowSignalQuery, WorkflowSignalRecord, WorkflowSignalResult, WorkflowStore, WorkflowWrite } from '../interfaces/workflow-store.interface.js';
|
|
5
5
|
import type { WorkflowStorage } from '../storage/workflow.storage.js';
|
|
6
6
|
import type { MySqlWorkflowStoreOptions } from './interfaces/mysql-workflow-store-options.interface.js';
|
|
7
7
|
/**
|
|
@@ -31,7 +31,9 @@ import type { MySqlWorkflowStoreOptions } from './interfaces/mysql-workflow-stor
|
|
|
31
31
|
* On MySQL:
|
|
32
32
|
* - Ids, names and keys are case- and accent-sensitive, and bounded: an instance id, a workflow's or a signal's name,
|
|
33
33
|
* a signal's key or id, a concurrency or rate-limit key hold at most 255 characters, a schedule's id 230, a
|
|
34
|
-
* journal entry's name 512. A longer one fails with a `RangeError` that names it, before any SQL.
|
|
34
|
+
* journal entry's name 512. A longer one fails with a `RangeError` that names it, before any SQL. One a run makes up
|
|
35
|
+
* (a step's or a wait's name, `$compensate:<step>`, a child's id or keys) is thrown where the run makes it
|
|
36
|
+
* (`checkKey()`), so the run fails and compensates, as any error it throws.
|
|
35
37
|
* - The application's transactions may be REPEATABLE READ, MySQL's default. `signal(..., { transaction })` finds the
|
|
36
38
|
* instances to wake with a locking read of their waits, which sees the latest committed ones whatever the level; the
|
|
37
39
|
* signal lock is held until the application's transaction ends, as on PostgreSQL. `start(..., { transaction })`
|
|
@@ -164,6 +166,8 @@ export declare class MySqlWorkflowStore implements WorkflowStore, OnModuleInit {
|
|
|
164
166
|
terminateRequested: boolean;
|
|
165
167
|
} | null>;
|
|
166
168
|
write(id: string, token: string, write: WorkflowWrite): Promise<boolean>;
|
|
169
|
+
/** What a run makes up (a step's name, a child's id...), checked by the engine before it writes or starts anything with it. */
|
|
170
|
+
checkKey(kind: WorkflowKeyKind, value: string): void;
|
|
167
171
|
/** The store's readiness (the server and the schema), then the signal lock's row: before its own first statement. */
|
|
168
172
|
private ready;
|
|
169
173
|
/**
|
|
@@ -47,7 +47,9 @@ const ROWS_PER_INSERT = 1_000;
|
|
|
47
47
|
* On MySQL:
|
|
48
48
|
* - Ids, names and keys are case- and accent-sensitive, and bounded: an instance id, a workflow's or a signal's name,
|
|
49
49
|
* a signal's key or id, a concurrency or rate-limit key hold at most 255 characters, a schedule's id 230, a
|
|
50
|
-
* journal entry's name 512. A longer one fails with a `RangeError` that names it, before any SQL.
|
|
50
|
+
* journal entry's name 512. A longer one fails with a `RangeError` that names it, before any SQL. One a run makes up
|
|
51
|
+
* (a step's or a wait's name, `$compensate:<step>`, a child's id or keys) is thrown where the run makes it
|
|
52
|
+
* (`checkKey()`), so the run fails and compensates, as any error it throws.
|
|
51
53
|
* - The application's transactions may be REPEATABLE READ, MySQL's default. `signal(..., { transaction })` finds the
|
|
52
54
|
* instances to wake with a locking read of their waits, which sees the latest committed ones whatever the level; the
|
|
53
55
|
* signal lock is held until the application's transaction ends, as on PostgreSQL. `start(..., { transaction })`
|
|
@@ -604,8 +606,8 @@ WHERE ${p.in('id', ids)}`, p.values);
|
|
|
604
606
|
checkSignal(write.signal);
|
|
605
607
|
}
|
|
606
608
|
for (const wait of write.release?.waits ?? []) {
|
|
607
|
-
checkKey(
|
|
608
|
-
checkKey(
|
|
609
|
+
checkKey(KEYS.signal, wait.signal, L.signal);
|
|
610
|
+
checkKey(KEYS.signalKey, wait.key, L.signalKey);
|
|
609
611
|
}
|
|
610
612
|
return retryOnDeadlock(this.executor, async (tx) => {
|
|
611
613
|
const release = write.release;
|
|
@@ -650,6 +652,10 @@ WHERE ${p.in('id', ids)}`, p.values);
|
|
|
650
652
|
return true;
|
|
651
653
|
});
|
|
652
654
|
}
|
|
655
|
+
/** What a run makes up (a step's name, a child's id...), checked by the engine before it writes or starts anything with it. */
|
|
656
|
+
checkKey(kind, value) {
|
|
657
|
+
checkKey(KEYS[kind], value, L[kind]);
|
|
658
|
+
}
|
|
653
659
|
// ---------------------------------------------------------------- internals
|
|
654
660
|
/** The store's readiness (the server and the schema), then the signal lock's row: before its own first statement. */
|
|
655
661
|
async ready() {
|
|
@@ -823,6 +829,16 @@ function checkSignal(s) {
|
|
|
823
829
|
}
|
|
824
830
|
function checkEntries(entries) {
|
|
825
831
|
for (const entry of entries) {
|
|
826
|
-
checkKey(
|
|
832
|
+
checkKey(KEYS.journalName, entry.name, L.journalName);
|
|
827
833
|
}
|
|
828
834
|
}
|
|
835
|
+
/** How the errors of `checkKey()`, and of `write()`'s own checks, name each key. */
|
|
836
|
+
const KEYS = {
|
|
837
|
+
instanceId: 'an instance id',
|
|
838
|
+
workflow: "a workflow's name",
|
|
839
|
+
concurrencyKey: 'a concurrency key',
|
|
840
|
+
rateLimitKey: 'a rate-limit key',
|
|
841
|
+
journalName: "a journal entry's name (a step's, a wait's, or the engine's, such as $child: and a child's id)",
|
|
842
|
+
signal: "a wait's signal name",
|
|
843
|
+
signalKey: "a wait's signal key",
|
|
844
|
+
};
|
|
@@ -151,8 +151,6 @@ export declare class WorkflowExecution {
|
|
|
151
151
|
private readonly childCounters;
|
|
152
152
|
/** The first interrupt that stops this execution (not a suspension). */
|
|
153
153
|
private stoppedBy;
|
|
154
|
-
/** The step whose function is running, in that function's async context. */
|
|
155
|
-
private readonly insideStep;
|
|
156
154
|
/** Each journal entry's size in bytes, by name, and their sum. */
|
|
157
155
|
private readonly entrySizes;
|
|
158
156
|
private journalBytes;
|
|
@@ -255,6 +253,14 @@ export declare class WorkflowExecution {
|
|
|
255
253
|
* compensates (compensations may still record their entries) and fails.
|
|
256
254
|
*/
|
|
257
255
|
private assertJournalRoom;
|
|
256
|
+
/**
|
|
257
|
+
* Throws the store's error for a key it can't hold (`WorkflowStore.checkKey()`), where the run makes the key: the
|
|
258
|
+
* run fails with it and compensates. Reaching the store, it would fail every write that carries it, and the instance
|
|
259
|
+
* would be retried at every lease, forever.
|
|
260
|
+
*/
|
|
261
|
+
private checkKey;
|
|
262
|
+
/** A wait's signal and key, registered when the instance suspends. */
|
|
263
|
+
private checkWait;
|
|
258
264
|
/** Fenced write of staged entries plus `entries`. */
|
|
259
265
|
private write;
|
|
260
266
|
private loseLease;
|
|
@@ -132,8 +132,6 @@ export class WorkflowExecution {
|
|
|
132
132
|
this.childCounters = new Map();
|
|
133
133
|
/** The first interrupt that stops this execution (not a suspension). */
|
|
134
134
|
this.stoppedBy = null;
|
|
135
|
-
/** The step whose function is running, in that function's async context. */
|
|
136
|
-
this.insideStep = new AsyncLocalStorage();
|
|
137
135
|
/** Each journal entry's size in bytes, by name, and their sum. */
|
|
138
136
|
this.entrySizes = new Map();
|
|
139
137
|
this.journalBytes = 0;
|
|
@@ -256,6 +254,7 @@ export class WorkflowExecution {
|
|
|
256
254
|
return { state: 'failed', error: entry.error };
|
|
257
255
|
}
|
|
258
256
|
try {
|
|
257
|
+
this.checkKey('journalName', name);
|
|
259
258
|
const attempt = await this.attempt(name, 'compensation',
|
|
260
259
|
// Whatever the undo returns (a provider's response, say) is discarded: nothing
|
|
261
260
|
// reads it, so it can't fail the undo by being unserializable.
|
|
@@ -299,6 +298,10 @@ export class WorkflowExecution {
|
|
|
299
298
|
throw this.interrupt('halt');
|
|
300
299
|
}
|
|
301
300
|
this.assertCanStart();
|
|
301
|
+
if (options.compensate) {
|
|
302
|
+
// Checked before the step runs: a side effect whose undo the store couldn't journal never happens.
|
|
303
|
+
this.checkKey('journalName', `${COMPENSATE}${name}`);
|
|
304
|
+
}
|
|
302
305
|
const plan = this.plan(name, options);
|
|
303
306
|
// Reserved in call order, so parallel steps compensate in the same order
|
|
304
307
|
// on the first run and on a replay, whichever finished first.
|
|
@@ -343,6 +346,7 @@ export class WorkflowExecution {
|
|
|
343
346
|
throw this.interrupt('halt');
|
|
344
347
|
}
|
|
345
348
|
this.assertAlive();
|
|
349
|
+
this.checkWait(wait);
|
|
346
350
|
const deadline = entry
|
|
347
351
|
? (entry.wakeAt ?? null)
|
|
348
352
|
: options.timeout !== undefined
|
|
@@ -426,6 +430,10 @@ export class WorkflowExecution {
|
|
|
426
430
|
parentId: this.instance.id,
|
|
427
431
|
parentClose,
|
|
428
432
|
});
|
|
433
|
+
this.checkKey('instanceId', child.id);
|
|
434
|
+
this.checkKey('workflow', child.workflow);
|
|
435
|
+
this.checkKey('concurrencyKey', child.concurrencyKey);
|
|
436
|
+
this.checkKey('rateLimitKey', child.rateLimitKey);
|
|
429
437
|
await this.reachFrontier(name);
|
|
430
438
|
this.assertCanStart();
|
|
431
439
|
// Recorded before the child exists, so a parent that crashes right after creating it still
|
|
@@ -477,6 +485,11 @@ export class WorkflowExecution {
|
|
|
477
485
|
throw this.interrupt('halt');
|
|
478
486
|
}
|
|
479
487
|
this.assertAlive();
|
|
488
|
+
for (const branch of branches.values()) {
|
|
489
|
+
if (branch.wait) {
|
|
490
|
+
this.checkWait(branch.wait);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
480
493
|
// Timers count from when the wait was first reached: a replay keeps their deadlines.
|
|
481
494
|
const now = this.deps.clock.now();
|
|
482
495
|
const reached = entry?.data;
|
|
@@ -776,7 +789,7 @@ export class WorkflowExecution {
|
|
|
776
789
|
await this.write([current]);
|
|
777
790
|
},
|
|
778
791
|
};
|
|
779
|
-
const run = Promise.resolve().then(() => runInStepScope(ctx.idempotencyKey, () =>
|
|
792
|
+
const run = Promise.resolve().then(() => runInStepScope(ctx.idempotencyKey, () => insideStep.run({ execution: this, name, outer: insideStep.getStore() }, () => fn(ctx))));
|
|
780
793
|
return Promise.race([run, watchdog]).finally(() => {
|
|
781
794
|
settled = true;
|
|
782
795
|
clearTimeout(overall);
|
|
@@ -805,10 +818,14 @@ export class WorkflowExecution {
|
|
|
805
818
|
* replay, so it is a definition error, reported where it happens.
|
|
806
819
|
*/
|
|
807
820
|
assertNotInStep(call, advice = 'Call ctx methods from run() and pass the values the step needs into it.') {
|
|
808
|
-
|
|
809
|
-
|
|
821
|
+
let frame = insideStep.getStore();
|
|
822
|
+
while (frame && frame.execution !== this) {
|
|
823
|
+
frame = frame.outer;
|
|
824
|
+
}
|
|
825
|
+
if (!frame) {
|
|
810
826
|
return;
|
|
811
827
|
}
|
|
828
|
+
const step = frame.name;
|
|
812
829
|
const where = step.startsWith(COMPENSATE) ? `the compensation of step "${step.slice(COMPENSATE.length)}"` : `step "${step}"`;
|
|
813
830
|
throw this.setFatal(new WorkflowDefinitionError(`${this.describe()}: ${where} called ctx.${call}. A step's function runs once and replays return its ` +
|
|
814
831
|
`result, so it can't use ctx. ${advice}`));
|
|
@@ -829,6 +846,7 @@ export class WorkflowExecution {
|
|
|
829
846
|
const entry = this.journal.get(name);
|
|
830
847
|
if (!entry && this.mode === 'run') {
|
|
831
848
|
this.assertJournalRoom(name);
|
|
849
|
+
this.checkKey('journalName', name);
|
|
832
850
|
}
|
|
833
851
|
if (entry && entry.kind !== kind) {
|
|
834
852
|
throw this.setFatal(new WorkflowNonDeterminismError(`${this.describe()} does not match its journal: "${name}" was recorded as a ${entry.kind}, but the code now calls it as a ${kind}. ${ADVICE}`));
|
|
@@ -956,6 +974,21 @@ export class WorkflowExecution {
|
|
|
956
974
|
};
|
|
957
975
|
throw this.interrupt('journal-limit');
|
|
958
976
|
}
|
|
977
|
+
/**
|
|
978
|
+
* Throws the store's error for a key it can't hold (`WorkflowStore.checkKey()`), where the run makes the key: the
|
|
979
|
+
* run fails with it and compensates. Reaching the store, it would fail every write that carries it, and the instance
|
|
980
|
+
* would be retried at every lease, forever.
|
|
981
|
+
*/
|
|
982
|
+
checkKey(kind, value) {
|
|
983
|
+
if (typeof value === 'string') {
|
|
984
|
+
this.deps.store.checkKey?.(kind, value);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
/** A wait's signal and key, registered when the instance suspends. */
|
|
988
|
+
checkWait(wait) {
|
|
989
|
+
this.checkKey('signal', wait.signal);
|
|
990
|
+
this.checkKey('signalKey', wait.key);
|
|
991
|
+
}
|
|
959
992
|
/** Fenced write of staged entries plus `entries`. */
|
|
960
993
|
async write(entries) {
|
|
961
994
|
if (this.detached) {
|
|
@@ -1069,6 +1102,11 @@ function conditionsOf(name, conditions) {
|
|
|
1069
1102
|
return branches;
|
|
1070
1103
|
}
|
|
1071
1104
|
const COMPENSATE = '$compensate:';
|
|
1105
|
+
/**
|
|
1106
|
+
* The steps whose functions are running, in their async context. One store for every execution, not one each: on
|
|
1107
|
+
* Node 20 and 22, a store that ran stays in a process-wide list every async resource walks, until `disable()`.
|
|
1108
|
+
*/
|
|
1109
|
+
const insideStep = new AsyncLocalStorage();
|
|
1072
1110
|
const PARENT_CLOSE = ['cancel', 'terminate', 'abandon'];
|
|
1073
1111
|
/** The most a custom status (`ctx.setStatus()`) may take as JSON. */
|
|
1074
1112
|
const MAX_CUSTOM_STATUS_BYTES = 16_384;
|
|
@@ -18,6 +18,7 @@ export declare class EncodedWorkflowStore implements WorkflowInstanceStore {
|
|
|
18
18
|
private readonly unreadable;
|
|
19
19
|
readonly createInTransaction?: WorkflowStore['createInTransaction'];
|
|
20
20
|
readonly signalInTransaction?: WorkflowStore['signalInTransaction'];
|
|
21
|
+
readonly checkKey?: WorkflowStore['checkKey'];
|
|
21
22
|
constructor(
|
|
22
23
|
/** The store itself: what it holds, undecoded. */
|
|
23
24
|
inner: WorkflowStore, codecs: PayloadCodecs<WorkflowPayloadContext>);
|
|
@@ -21,6 +21,10 @@ export class EncodedWorkflowStore {
|
|
|
21
21
|
if (typeof inner.signalInTransaction === 'function') {
|
|
22
22
|
this.signalInTransaction = (transaction, signal) => toPromise(andThen(this.encodeSignal(signal), (encoded) => inner.signalInTransaction(transaction, encoded)));
|
|
23
23
|
}
|
|
24
|
+
// Keys aren't encoded: the store checks them as they are.
|
|
25
|
+
if (typeof inner.checkKey === 'function') {
|
|
26
|
+
this.checkKey = (kind, value) => inner.checkKey(kind, value);
|
|
27
|
+
}
|
|
24
28
|
}
|
|
25
29
|
/** Whether a codec encodes what is written. */
|
|
26
30
|
get encodes() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestjs/workflows",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Nest - modern, fast, powerful node.js web framework (@workflows)",
|
|
5
5
|
"author": "Kamil Mysliwiec",
|
|
6
6
|
"license": "MIT",
|
|
@@ -69,37 +69,37 @@
|
|
|
69
69
|
"@commitlint/cli": "21.2.3",
|
|
70
70
|
"@commitlint/config-angular": "21.2.3",
|
|
71
71
|
"@electric-sql/pglite": "0.5.8",
|
|
72
|
-
"@nestjs/common": "12.1.
|
|
73
|
-
"@nestjs/core": "12.1.
|
|
72
|
+
"@nestjs/common": "12.1.2",
|
|
73
|
+
"@nestjs/core": "12.1.2",
|
|
74
74
|
"@nestjs/cqrs": "12.1.0",
|
|
75
75
|
"@nestjs/drizzle": "0.0.1",
|
|
76
|
-
"@nestjs/locks": "0.0.
|
|
76
|
+
"@nestjs/locks": "0.0.2",
|
|
77
77
|
"@nestjs/mail": "0.0.1",
|
|
78
|
-
"@nestjs/outbox": "0.
|
|
79
|
-
"@nestjs/platform-express": "12.1.
|
|
80
|
-
"@nestjs/platform-fastify": "12.1.
|
|
81
|
-
"@nestjs/testing": "12.1.
|
|
78
|
+
"@nestjs/outbox": "0.1.1",
|
|
79
|
+
"@nestjs/platform-express": "12.1.2",
|
|
80
|
+
"@nestjs/platform-fastify": "12.1.2",
|
|
81
|
+
"@nestjs/testing": "12.1.2",
|
|
82
82
|
"@prisma/adapter-mariadb": "7.10.0",
|
|
83
83
|
"@prisma/adapter-pg": "7.10.0",
|
|
84
84
|
"@prisma/client": "7.10.0",
|
|
85
|
-
"@types/node": "24.
|
|
85
|
+
"@types/node": "24.19.2",
|
|
86
86
|
"@types/pg": "8.23.1",
|
|
87
|
-
"drizzle-orm": "0.45.
|
|
87
|
+
"drizzle-orm": "0.45.4",
|
|
88
88
|
"husky": "9.1.7",
|
|
89
89
|
"kysely": "0.29.6",
|
|
90
|
-
"lint-staged": "17.
|
|
90
|
+
"lint-staged": "17.6.0",
|
|
91
91
|
"mysql2": "3.24.5",
|
|
92
|
-
"oxlint": "1.
|
|
93
|
-
"pg": "8.23.
|
|
94
|
-
"prettier": "3.9.
|
|
92
|
+
"oxlint": "1.87.0",
|
|
93
|
+
"pg": "8.23.1",
|
|
94
|
+
"prettier": "3.9.10",
|
|
95
95
|
"prisma": "7.10.0",
|
|
96
96
|
"reflect-metadata": "0.2.2",
|
|
97
|
-
"release-it": "21.1.
|
|
97
|
+
"release-it": "21.1.1",
|
|
98
98
|
"rxjs": "7.8.2",
|
|
99
|
-
"typeorm": "1.1.
|
|
99
|
+
"typeorm": "1.1.2",
|
|
100
100
|
"typescript": "7.0.2",
|
|
101
|
-
"vite": "8.3.
|
|
102
|
-
"vitest": "5.0.
|
|
101
|
+
"vite": "8.3.4",
|
|
102
|
+
"vitest": "5.0.3"
|
|
103
103
|
},
|
|
104
104
|
"peerDependencies": {
|
|
105
105
|
"@nestjs/common": "^11.0.0 || ^12.0.0",
|