@nestjs/workflows 0.0.3 → 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.
- package/dist/core/interfaces/leased-worker-options.interface.d.ts +5 -1
- package/dist/core/interfaces/result-waiter-options.interface.d.ts +6 -2
- package/dist/core/results/result-waiter.d.ts +5 -3
- package/dist/core/results/result-waiter.js +34 -10
- package/dist/core/utils/timer.util.d.ts +12 -0
- package/dist/core/utils/timer.util.js +16 -0
- package/dist/core/workers/leased-worker.d.ts +5 -2
- package/dist/core/workers/leased-worker.js +104 -12
- package/dist/cqrs/workflow-event.publisher.js +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/interfaces/workflow-client.interface.d.ts +4 -1
- package/dist/interfaces/workflow-context.interface.d.ts +2 -2
- package/dist/interfaces/workflow-store.interface.d.ts +18 -1
- package/dist/mysql/interfaces/mysql-workflow-store-options.interface.d.ts +1 -1
- package/dist/mysql/mysql-workflow.store.d.ts +5 -1
- package/dist/mysql/mysql-workflow.store.js +7 -0
- package/dist/postgres/interfaces/postgres-workflow-store-options.interface.d.ts +1 -1
- package/dist/postgres/postgres-workflow.store.d.ts +6 -1
- package/dist/postgres/postgres-workflow.store.js +8 -0
- package/dist/services/workflow-client.service.d.ts +11 -2
- package/dist/services/workflow-client.service.js +23 -5
- package/dist/services/workflow-execution.service.d.ts +3 -0
- package/dist/services/workflow-execution.service.js +31 -5
- package/dist/services/workflow-scheduler.service.d.ts +8 -0
- package/dist/services/workflow-scheduler.service.js +20 -1
- package/dist/services/workflow-schedules.service.js +2 -0
- package/dist/services/workflow-worker.service.js +3 -0
- package/dist/storage/encoded-workflow.store.d.ts +11 -0
- package/dist/storage/encoded-workflow.store.js +38 -3
- package/dist/testing/index.js +4 -4
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/new-instance.util.d.ts +3 -0
- package/dist/utils/new-instance.util.js +9 -2
- package/dist/utils/serialize-error.util.js +25 -1
- package/dist/utils/storable-text.util.d.ts +36 -0
- package/dist/utils/storable-text.util.js +96 -0
- package/package.json +37 -24
|
@@ -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. */
|
|
@@ -29,7 +29,8 @@ export type ResultOutcome<T> = {
|
|
|
29
29
|
export interface ResultWaiterOptions<T> {
|
|
30
30
|
/**
|
|
31
31
|
* Reads the outcome from the store: `null` while it hasn't ended. Called when a wait starts, then after a backoff
|
|
32
|
-
* (25 ms at first, doubling up to a second), until the wait ends; a rejection rejects the wait.
|
|
32
|
+
* (25 ms at first, doubling up to a second), until the wait ends; a rejection rejects the wait. A wait that times
|
|
33
|
+
* out, is aborted or closes doesn't wait for a read in flight: its answer or rejection is then ignored.
|
|
33
34
|
*/
|
|
34
35
|
read(id: string): Promise<ResultOutcome<T> | null>;
|
|
35
36
|
/** The error a wait rejects with past its `timeout`. Default: an `Error` that names `id`. */
|
|
@@ -45,7 +46,10 @@ export interface ResultWaiterOptions<T> {
|
|
|
45
46
|
* ```
|
|
46
47
|
*/
|
|
47
48
|
export interface ResultWaitOptions {
|
|
48
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Stop waiting after this long, in wall-clock time (not a `Clock`'s). Default: no limit. A hard deadline: a read
|
|
51
|
+
* still in flight when it passes isn't awaited, so a timeout shorter than one read (0 included) rejects.
|
|
52
|
+
*/
|
|
49
53
|
timeout?: Duration;
|
|
50
54
|
/** Stop waiting when it aborts: the wait rejects with its reason. */
|
|
51
55
|
signal?: AbortSignal;
|
|
@@ -3,7 +3,8 @@ import type { ResultOutcome, ResultWaiterOptions, ResultWaitOptions } from '../i
|
|
|
3
3
|
* Waits for the outcome of something another part of the application runs (a workflow instance, a job): at once
|
|
4
4
|
* when this process ends it and says so (`settle()`), else by reading the store with a backoff (every 25 ms at
|
|
5
5
|
* first, doubling up to every second), which sees what other processes ended. A wait rejects past its `timeout`
|
|
6
|
-
* (the work goes on), when its `signal` aborts, and as soon as the waiter closes, without reading the store again
|
|
6
|
+
* (the work goes on), when its `signal` aborts, and as soon as the waiter closes, without reading the store again
|
|
7
|
+
* nor waiting for a read in flight (it ends in the background, and its answer or failure is ignored):
|
|
7
8
|
* close it in `beforeApplicationShutdown`, which Nest runs after `onModuleDestroy` (where workers drain, so work that
|
|
8
9
|
* ends during the drain still answers) and before the HTTP server closes.
|
|
9
10
|
*
|
|
@@ -50,8 +51,9 @@ export declare class ResultWaiter<T = unknown> {
|
|
|
50
51
|
*/
|
|
51
52
|
close(): Promise<void>;
|
|
52
53
|
/**
|
|
53
|
-
* Reads `id`, unless the wait settles
|
|
54
|
-
*
|
|
54
|
+
* Reads `id`, unless the wait settles, times out or is aborted first: the read is then given up, and left to end in
|
|
55
|
+
* the background (`Promise.race` handles its failure). It races a promise of its own, not one the whole wait
|
|
56
|
+
* shares: each race leaves a reaction on its inputs until they settle, so a shared one would keep one per poll.
|
|
55
57
|
*/
|
|
56
58
|
private read;
|
|
57
59
|
private add;
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { setImmediate as nextMacrotask } from 'node:timers/promises';
|
|
2
2
|
import { parseDuration } from '../time/duration.js';
|
|
3
|
+
import { MAX_TIMER_DELAY, setTimer } from '../utils/timer.util.js';
|
|
3
4
|
/** What a read in flight gives way to: an outcome settled locally, or the waiter closing. */
|
|
4
5
|
const SETTLED = Symbol('settled');
|
|
6
|
+
/** What a read in flight gives way to when the wait's timeout passes. */
|
|
7
|
+
const EXPIRED = Symbol('expired');
|
|
5
8
|
/**
|
|
6
9
|
* Waits for the outcome of something another part of the application runs (a workflow instance, a job): at once
|
|
7
10
|
* when this process ends it and says so (`settle()`), else by reading the store with a backoff (every 25 ms at
|
|
8
11
|
* first, doubling up to every second), which sees what other processes ended. A wait rejects past its `timeout`
|
|
9
|
-
* (the work goes on), when its `signal` aborts, and as soon as the waiter closes, without reading the store again
|
|
12
|
+
* (the work goes on), when its `signal` aborts, and as soon as the waiter closes, without reading the store again
|
|
13
|
+
* nor waiting for a read in flight (it ends in the background, and its answer or failure is ignored):
|
|
10
14
|
* close it in `beforeApplicationShutdown`, which Nest runs after `onModuleDestroy` (where workers drain, so work that
|
|
11
15
|
* ends during the drain still answers) and before the HTTP server closes.
|
|
12
16
|
*
|
|
@@ -48,7 +52,10 @@ export class ResultWaiter {
|
|
|
48
52
|
const deadline = performance.now() + timeoutMs;
|
|
49
53
|
options.signal?.throwIfAborted();
|
|
50
54
|
const wait = { closed: this.closed };
|
|
51
|
-
const nudge = () =>
|
|
55
|
+
const nudge = () => {
|
|
56
|
+
wait.giveUp?.();
|
|
57
|
+
wait.wake?.();
|
|
58
|
+
};
|
|
52
59
|
const close = () => {
|
|
53
60
|
wait.closed = true;
|
|
54
61
|
wait.settle?.();
|
|
@@ -59,19 +66,19 @@ export class ResultWaiter {
|
|
|
59
66
|
options.signal?.addEventListener('abort', nudge);
|
|
60
67
|
try {
|
|
61
68
|
for (let delay = 25;; delay = Math.min(delay * 2, 1_000)) {
|
|
62
|
-
const read = wait.outcome || wait.closed ? SETTLED : await this.read(id, wait);
|
|
69
|
+
const read = wait.outcome || wait.closed ? SETTLED : await this.read(id, wait, deadline, options.signal);
|
|
63
70
|
if (wait.outcome) {
|
|
64
71
|
return unwrap(wait.outcome);
|
|
65
72
|
}
|
|
66
73
|
if (read === SETTLED) {
|
|
67
74
|
throw this.options.closedError?.(id) ?? new Error(`The waiter closed while waiting for the result of "${id}".`);
|
|
68
75
|
}
|
|
69
|
-
if (read) {
|
|
76
|
+
if (read && read !== EXPIRED) {
|
|
70
77
|
return unwrap(read);
|
|
71
78
|
}
|
|
72
79
|
options.signal?.throwIfAborted();
|
|
73
80
|
const remaining = deadline - performance.now();
|
|
74
|
-
if (remaining <= 0) {
|
|
81
|
+
if (read === EXPIRED || remaining <= 0) {
|
|
75
82
|
throw this.options.timeoutError?.(id, timeoutMs) ?? new Error(`"${id}" didn't end within ${timeoutMs}ms.`);
|
|
76
83
|
}
|
|
77
84
|
await new Promise((resolve) => {
|
|
@@ -116,16 +123,33 @@ export class ResultWaiter {
|
|
|
116
123
|
await nextMacrotask();
|
|
117
124
|
}
|
|
118
125
|
/**
|
|
119
|
-
* Reads `id`, unless the wait settles
|
|
120
|
-
*
|
|
126
|
+
* Reads `id`, unless the wait settles, times out or is aborted first: the read is then given up, and left to end in
|
|
127
|
+
* the background (`Promise.race` handles its failure). It races a promise of its own, not one the whole wait
|
|
128
|
+
* shares: each race leaves a reaction on its inputs until they settle, so a shared one would keep one per poll.
|
|
121
129
|
*/
|
|
122
|
-
async read(id, wait) {
|
|
123
|
-
|
|
130
|
+
async read(id, wait, deadline, signal) {
|
|
131
|
+
// Aborted during the backoff's sleep: no read for a caller that went away.
|
|
132
|
+
if (signal?.aborted) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
let timer;
|
|
136
|
+
const givenWay = new Promise((resolve) => {
|
|
137
|
+
wait.settle = () => resolve(SETTLED);
|
|
138
|
+
wait.giveUp = () => resolve(null);
|
|
139
|
+
const remaining = deadline - performance.now();
|
|
140
|
+
if (remaining !== Infinity) {
|
|
141
|
+
// A deadline beyond what a timer supports: the read is given up at the longest delay, and the next one waits on.
|
|
142
|
+
const expired = remaining <= MAX_TIMER_DELAY ? EXPIRED : null;
|
|
143
|
+
timer = setTimer(() => resolve(expired), Math.max(remaining, 0));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
124
146
|
try {
|
|
125
|
-
return await Promise.race([this.options.read(id),
|
|
147
|
+
return await Promise.race([this.options.read(id), givenWay]);
|
|
126
148
|
}
|
|
127
149
|
finally {
|
|
128
150
|
wait.settle = undefined;
|
|
151
|
+
wait.giveUp = undefined;
|
|
152
|
+
clearTimeout(timer);
|
|
129
153
|
}
|
|
130
154
|
}
|
|
131
155
|
add(id, wait) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The longest delay `setTimeout` and `setInterval` support: 2^31 - 1 ms, about 24.8 days. Node runs a longer one
|
|
3
|
+
* after 1 ms instead (with a `TimeoutOverflowWarning`), so a step `timeout: '30d'` would time out every attempt at
|
|
4
|
+
* once, and a `heartbeatInterval` of '30d' would renew the lease every millisecond.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export declare const MAX_TIMER_DELAY: number;
|
|
9
|
+
/** @internal `setTimeout`, with delays beyond what timers support treated as the longest one. */
|
|
10
|
+
export declare function setTimer(callback: () => void, ms: number): NodeJS.Timeout;
|
|
11
|
+
/** @internal `setInterval`, capped like `setTimer()`. */
|
|
12
|
+
export declare function setRepeatingTimer(callback: () => void, ms: number): NodeJS.Timeout;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The longest delay `setTimeout` and `setInterval` support: 2^31 - 1 ms, about 24.8 days. Node runs a longer one
|
|
3
|
+
* after 1 ms instead (with a `TimeoutOverflowWarning`), so a step `timeout: '30d'` would time out every attempt at
|
|
4
|
+
* once, and a `heartbeatInterval` of '30d' would renew the lease every millisecond.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_TIMER_DELAY = 2 ** 31 - 1;
|
|
9
|
+
/** @internal `setTimeout`, with delays beyond what timers support treated as the longest one. */
|
|
10
|
+
export function setTimer(callback, ms) {
|
|
11
|
+
return setTimeout(callback, Math.min(ms, MAX_TIMER_DELAY));
|
|
12
|
+
}
|
|
13
|
+
/** @internal `setInterval`, capped like `setTimer()`. */
|
|
14
|
+
export function setRepeatingTimer(callback, ms) {
|
|
15
|
+
return setInterval(callback, Math.min(ms, MAX_TIMER_DELAY));
|
|
16
|
+
}
|
|
@@ -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
|
|
@@ -3,11 +3,15 @@ import { hostname } from 'node:os';
|
|
|
3
3
|
import { Logger } from '@nestjs/common';
|
|
4
4
|
import { systemClock } from '../time/clock.js';
|
|
5
5
|
import { parseDuration } from '../time/duration.js';
|
|
6
|
+
import { setRepeatingTimer, setTimer } from '../utils/timer.util.js';
|
|
6
7
|
/**
|
|
7
8
|
* A claim-execute loop under leases, any number of processes sharing a store: it claims due items (as many as it has
|
|
8
9
|
* free slots), executes them, renews each one's lease every `heartbeatInterval` (a third of the lease by default), and
|
|
9
|
-
* aborts an item's `signal` when a renewal finds its lease gone.
|
|
10
|
-
*
|
|
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
|
|
11
15
|
* tests and scripts.
|
|
12
16
|
*
|
|
13
17
|
* Shutdown is graceful: call `shutdown()` from `onModuleDestroy`. It stops claiming, waits for the claims in flight
|
|
@@ -122,7 +126,7 @@ export class LeasedWorker {
|
|
|
122
126
|
let timer;
|
|
123
127
|
await Promise.race([
|
|
124
128
|
Promise.allSettled([...this.runs].map((run) => run.done)),
|
|
125
|
-
new Promise((resolve) => (timer =
|
|
129
|
+
new Promise((resolve) => (timer = setTimer(resolve, this.shutdownMs))),
|
|
126
130
|
]);
|
|
127
131
|
clearTimeout(timer);
|
|
128
132
|
for (const run of this.runs) {
|
|
@@ -185,7 +189,7 @@ export class LeasedWorker {
|
|
|
185
189
|
this.wake = undefined;
|
|
186
190
|
resolve();
|
|
187
191
|
};
|
|
188
|
-
const timer =
|
|
192
|
+
const timer = setTimer(done, this.pollMs);
|
|
189
193
|
this.wake = () => {
|
|
190
194
|
this.kicked = false;
|
|
191
195
|
done();
|
|
@@ -210,12 +214,12 @@ export class LeasedWorker {
|
|
|
210
214
|
}
|
|
211
215
|
}
|
|
212
216
|
launch(item) {
|
|
213
|
-
const run = new Run(item, () => this.renew(run));
|
|
217
|
+
const run = new Run(item, this.heartbeatMs, () => this.renew(run));
|
|
214
218
|
this.runs.add(run);
|
|
215
219
|
if (this.stopped) {
|
|
216
220
|
run.halt();
|
|
217
221
|
}
|
|
218
|
-
run.heartbeat =
|
|
222
|
+
run.heartbeat = setRepeatingTimer(() => void run.renew(), this.heartbeatMs);
|
|
219
223
|
run.heartbeat.unref();
|
|
220
224
|
let executing;
|
|
221
225
|
try {
|
|
@@ -227,7 +231,7 @@ export class LeasedWorker {
|
|
|
227
231
|
run.done = executing
|
|
228
232
|
.catch((error) => this.report(error, 'execute'))
|
|
229
233
|
.finally(() => {
|
|
230
|
-
|
|
234
|
+
run.end();
|
|
231
235
|
this.runs.delete(run);
|
|
232
236
|
});
|
|
233
237
|
return run.done;
|
|
@@ -246,12 +250,21 @@ export class LeasedWorker {
|
|
|
246
250
|
}
|
|
247
251
|
/** A claimed item's run: its signal, its heartbeat, and what became of its lease. */
|
|
248
252
|
class Run {
|
|
249
|
-
constructor(item, renewer) {
|
|
253
|
+
constructor(item, heartbeatMs, renewer) {
|
|
250
254
|
this.item = item;
|
|
255
|
+
this.heartbeatMs = heartbeatMs;
|
|
251
256
|
this.renewer = renewer;
|
|
252
257
|
this.controller = new AbortController();
|
|
253
258
|
this.lost = false;
|
|
254
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;
|
|
255
268
|
}
|
|
256
269
|
get signal() {
|
|
257
270
|
return this.controller.signal;
|
|
@@ -262,10 +275,78 @@ class Run {
|
|
|
262
275
|
get detached() {
|
|
263
276
|
return this.isDetached;
|
|
264
277
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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);
|
|
268
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) {
|
|
269
350
|
let held;
|
|
270
351
|
try {
|
|
271
352
|
held = await this.renewer();
|
|
@@ -276,8 +357,14 @@ class Run {
|
|
|
276
357
|
}
|
|
277
358
|
if (!held) {
|
|
278
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();
|
|
279
365
|
}
|
|
280
|
-
|
|
366
|
+
this.landed = Math.max(this.landed, seq);
|
|
367
|
+
return true;
|
|
281
368
|
}
|
|
282
369
|
loseLease() {
|
|
283
370
|
if (this.lost) {
|
|
@@ -298,4 +385,9 @@ class Run {
|
|
|
298
385
|
this.isDetached = true;
|
|
299
386
|
clearInterval(this.heartbeat);
|
|
300
387
|
}
|
|
388
|
+
/** Its `execute` settled: no more heartbeats, and no more renewals. */
|
|
389
|
+
end() {
|
|
390
|
+
this.ended = true;
|
|
391
|
+
clearInterval(this.heartbeat);
|
|
392
|
+
}
|
|
301
393
|
}
|
|
@@ -111,9 +111,9 @@ let WorkflowEventPublisher = class WorkflowEventPublisher {
|
|
|
111
111
|
failure.event = undefined;
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
114
|
-
// Issued back to back, not awaited in between:
|
|
115
|
-
//
|
|
116
|
-
//
|
|
114
|
+
// Issued back to back, not awaited in between: on the transaction's one connection they still
|
|
115
|
+
// run in this order. The application awaits publish() in its transaction callback, so its
|
|
116
|
+
// driver must accept an async one (not better-sqlite3's), as start() and signal() require.
|
|
117
117
|
const settled = await Promise.allSettled(writes.map((write) => write.run(transaction)));
|
|
118
118
|
const index = settled.findIndex((result) => result.status === 'rejected');
|
|
119
119
|
if (index !== -1) {
|
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, WorkflowKeyKind, 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, WorkflowTextColumn, 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';
|
|
@@ -72,7 +72,10 @@ export interface SignalWorkflowOptions {
|
|
|
72
72
|
export interface WorkflowResultOptions {
|
|
73
73
|
/**
|
|
74
74
|
* How long to wait. Past it, the call rejects with `WorkflowResultTimeoutError`, and the
|
|
75
|
-
* instance keeps running. Wall-clock time, not the module's clock. Default: no limit.
|
|
75
|
+
* instance keeps running. Wall-clock time, not the module's clock. Default: no limit. A hard
|
|
76
|
+
* deadline: a store read still in flight when it passes isn't awaited, so a timeout shorter than
|
|
77
|
+
* one round trip to the store (0 included) rejects, also for an instance that has ended. To
|
|
78
|
+
* check an instance without waiting, use `getStatus()`.
|
|
76
79
|
*/
|
|
77
80
|
timeout?: Duration;
|
|
78
81
|
/** Stops waiting once aborted (a client that went away), rejecting with the signal's reason. */
|
|
@@ -53,9 +53,9 @@ export interface WorkflowCompensationContext extends WorkflowStepContext {
|
|
|
53
53
|
export interface WorkflowStepOptions<T> {
|
|
54
54
|
/** Default: the module's `retry` (3 attempts, 1s doubling up to 5m). */
|
|
55
55
|
retry?: number | false | WorkflowRetryOptions;
|
|
56
|
-
/** Fail the attempt (retryably) after this long. */
|
|
56
|
+
/** Fail the attempt (retryably) after this long. Capped at about 24.8 days, the longest timer Node supports. */
|
|
57
57
|
timeout?: Duration;
|
|
58
|
-
/** Fail the attempt (retryably) if `heartbeat()` is not called this often. */
|
|
58
|
+
/** Fail the attempt (retryably) if `heartbeat()` is not called this often. Capped like `timeout`. */
|
|
59
59
|
heartbeatTimeout?: Duration;
|
|
60
60
|
/**
|
|
61
61
|
* Undo this step, once it completed, if the workflow later fails or is
|
|
@@ -35,7 +35,10 @@ export interface WorkflowStore {
|
|
|
35
35
|
* hands its transaction callback (a Drizzle `tx`, a TypeORM `EntityManager`...). Write only
|
|
36
36
|
* through it, so the instance commits or rolls back with the application's rows, and never
|
|
37
37
|
* catch a database error inside it (on PostgreSQL that aborts the transaction: use
|
|
38
|
-
* insert-or-ignore). Without this method, `start()` with a transaction throws.
|
|
38
|
+
* insert-or-ignore). Without this method, `start()` with a transaction throws. The
|
|
39
|
+
* application awaits `start()` in its transaction callback, so its driver must accept an
|
|
40
|
+
* async one: one whose transactions are synchronous (better-sqlite3) commits before a
|
|
41
|
+
* failure is reported.
|
|
39
42
|
*/
|
|
40
43
|
createInTransaction?(transaction: unknown, instance: NewWorkflowInstance): Promise<{
|
|
41
44
|
instance: WorkflowInstance;
|
|
@@ -213,9 +216,23 @@ export interface WorkflowStore {
|
|
|
213
216
|
* store's own check in `write()` or `create()` is all there is, and the instance is retried as after any store error.
|
|
214
217
|
*/
|
|
215
218
|
checkKey?(kind: WorkflowKeyKind, value: string): void;
|
|
219
|
+
/**
|
|
220
|
+
* Optional, for a store that can't keep some text (PostgreSQL can't keep U+0000, and neither PostgreSQL nor MySQL a
|
|
221
|
+
* lone UTF-16 surrogate in JSON): what `text` holds that the store can't keep in a `column`, such as `'U+0000'`, or
|
|
222
|
+
* `undefined`. `'json'` is text inside a JSON value (an input, a result, a payload, a journal entry, which holds its
|
|
223
|
+
* name); `'text'` is an id, a name, a key or a cancel reason in a column of its own. The engine asks before it stores
|
|
224
|
+
* what the application gives or a run makes, and refuses such text with a `TypeError` that names where it sits, so
|
|
225
|
+
* the call fails (a run fails and compensates) instead of every write that carries it. It doesn't ask about what a
|
|
226
|
+
* codec encodes, which the store sees only encoded. In an error's text, which can't be refused, U+0000 and lone
|
|
227
|
+
* surrogates are replaced with U+FFFD where the store refuses it. Without this method, nothing is refused or
|
|
228
|
+
* replaced, and the store's own failure in `write()` or `create()` is all there is.
|
|
229
|
+
*/
|
|
230
|
+
unstorableText?(text: string, column: WorkflowTextColumn): string | undefined;
|
|
216
231
|
}
|
|
217
232
|
/** The ids, names and keys `WorkflowStore.checkKey()` checks. */
|
|
218
233
|
export type WorkflowKeyKind = 'instanceId' | 'workflow' | 'concurrencyKey' | 'rateLimitKey' | 'journalName' | 'signal' | 'signalKey';
|
|
234
|
+
/** Where `WorkflowStore.unstorableText()`'s text is stored: inside a JSON value, or in a text column of its own. */
|
|
235
|
+
export type WorkflowTextColumn = 'json' | 'text';
|
|
219
236
|
/** `WorkflowStore.get()`'s result, and `WorkflowClient.getStatus()`'s. */
|
|
220
237
|
export interface WorkflowInstanceDetails extends WorkflowInstance {
|
|
221
238
|
/** Signals the instance waits for, if suspended in `waitForSignal()`. */
|
|
@@ -18,7 +18,7 @@ export interface MySqlWorkflowStoreOptions {
|
|
|
18
18
|
* Apply the store's pending migrations at startup, one statement at a time under a lock (`GET_LOCK()`), so processes
|
|
19
19
|
* that start together migrate once, and a run that stopped halfway resumes where it stopped. With `false`, startup
|
|
20
20
|
* fails with a `WorkflowSchemaError` while the schema is behind this version of the package: apply them with
|
|
21
|
-
* `npx
|
|
21
|
+
* `npx @nestjs/workflows migrate`, or with your own migration tool (`MySqlWorkflowStore.migrationSql()` or
|
|
22
22
|
* `migrationStatements()`). Default: `true`, except when `NODE_ENV` is `production`.
|
|
23
23
|
*/
|
|
24
24
|
migrate?: boolean;
|
|
@@ -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, WorkflowKeyKind, 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, WorkflowTextColumn, 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
|
/**
|
|
@@ -34,6 +34,8 @@ import type { MySqlWorkflowStoreOptions } from './interfaces/mysql-workflow-stor
|
|
|
34
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
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
36
|
* (`checkKey()`), so the run fails and compensates, as any error it throws.
|
|
37
|
+
* - JSON can't hold a lone UTF-16 surrogate (`unstorableText()`): the engine refuses one in an input, a payload, a
|
|
38
|
+
* result or a step's name with a `TypeError` where the application or the run gives it, unless a codec encodes it.
|
|
37
39
|
* - The application's transactions may be REPEATABLE READ, MySQL's default. `signal(..., { transaction })` finds the
|
|
38
40
|
* instances to wake with a locking read of their waits, which sees the latest committed ones whatever the level; the
|
|
39
41
|
* signal lock is held until the application's transaction ends, as on PostgreSQL. `start(..., { transaction })`
|
|
@@ -168,6 +170,8 @@ export declare class MySqlWorkflowStore implements WorkflowStore, OnModuleInit {
|
|
|
168
170
|
write(id: string, token: string, write: WorkflowWrite): Promise<boolean>;
|
|
169
171
|
/** 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
172
|
checkKey(kind: WorkflowKeyKind, value: string): void;
|
|
173
|
+
/** A lone surrogate in JSON: what the engine refuses before it writes (see the class). */
|
|
174
|
+
unstorableText(text: string, column: WorkflowTextColumn): string | undefined;
|
|
171
175
|
/** The store's readiness (the server and the schema), then the signal lock's row: before its own first statement. */
|
|
172
176
|
private ready;
|
|
173
177
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Logger } from '@nestjs/common';
|
|
2
2
|
import { columns, ensureLockRows, keyColumn, lockKeys, mysqlErrorCode, quoteTable, retryOnDeadlock, SqlParams, toBool, toInt, toJson, toText, } from '@nestjs/store-kit/mysql';
|
|
3
|
+
import { loneSurrogateIn } from '../utils/storable-text.util.js';
|
|
3
4
|
import { RateWindowClaim } from '../sql/rate-windows.js';
|
|
4
5
|
import { CANCELLABLE_STATUSES, INSTANCE_COLUMNS, RUNNABLE_STATUSES, SCHEDULE_COLUMNS, SIGNAL_COLUMNS, toInstance, toSchedule } from '../sql/workflow-rows.js';
|
|
5
6
|
import { checkKey, MYSQL_KEY_LIMITS as L } from './key-limits.js';
|
|
@@ -50,6 +51,8 @@ const ROWS_PER_INSERT = 1_000;
|
|
|
50
51
|
* journal entry's name 512. A longer one fails with a `RangeError` that names it, before any SQL. One a run makes up
|
|
51
52
|
* (a step's or a wait's name, `$compensate:<step>`, a child's id or keys) is thrown where the run makes it
|
|
52
53
|
* (`checkKey()`), so the run fails and compensates, as any error it throws.
|
|
54
|
+
* - JSON can't hold a lone UTF-16 surrogate (`unstorableText()`): the engine refuses one in an input, a payload, a
|
|
55
|
+
* result or a step's name with a `TypeError` where the application or the run gives it, unless a codec encodes it.
|
|
53
56
|
* - The application's transactions may be REPEATABLE READ, MySQL's default. `signal(..., { transaction })` finds the
|
|
54
57
|
* instances to wake with a locking read of their waits, which sees the latest committed ones whatever the level; the
|
|
55
58
|
* signal lock is held until the application's transaction ends, as on PostgreSQL. `start(..., { transaction })`
|
|
@@ -656,6 +659,10 @@ WHERE ${p.in('id', ids)}`, p.values);
|
|
|
656
659
|
checkKey(kind, value) {
|
|
657
660
|
checkKey(KEYS[kind], value, L[kind]);
|
|
658
661
|
}
|
|
662
|
+
/** A lone surrogate in JSON: what the engine refuses before it writes (see the class). */
|
|
663
|
+
unstorableText(text, column) {
|
|
664
|
+
return column === 'json' ? loneSurrogateIn(text) : undefined;
|
|
665
|
+
}
|
|
659
666
|
// ---------------------------------------------------------------- internals
|
|
660
667
|
/** The store's readiness (the server and the schema), then the signal lock's row: before its own first statement. */
|
|
661
668
|
async ready() {
|
|
@@ -15,7 +15,7 @@ export interface PostgresWorkflowStoreOptions {
|
|
|
15
15
|
/**
|
|
16
16
|
* Apply the store's pending migrations at startup, in one transaction under an advisory lock, so processes that
|
|
17
17
|
* start together migrate once. With `false`, startup fails with a `WorkflowSchemaError` while the schema is behind
|
|
18
|
-
* this version of the package: apply them with `npx
|
|
18
|
+
* this version of the package: apply them with `npx @nestjs/workflows migrate`, or with your own migration tool
|
|
19
19
|
* (`PostgresWorkflowStore.migrationSql()`). Default: `true`, except when `NODE_ENV` is `production`.
|
|
20
20
|
*/
|
|
21
21
|
migrate?: boolean;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type OnModuleInit } from '@nestjs/common';
|
|
2
2
|
import { type MigrationSqlOptions } from '@nestjs/store-kit/postgres';
|
|
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, WorkflowListQuery, WorkflowPurgeQuery, WorkflowPurgeResult, WorkflowReopen, WorkflowScheduleClaimRequest, WorkflowScheduleQuery, WorkflowScheduleRecord, WorkflowScheduleSave, WorkflowScheduleWrite, WorkflowSignalQuery, WorkflowSignalRecord, WorkflowSignalResult, WorkflowStore, WorkflowTextColumn, WorkflowWrite } from '../interfaces/workflow-store.interface.js';
|
|
5
5
|
import type { WorkflowStorage } from '../storage/workflow.storage.js';
|
|
6
6
|
import type { PostgresWorkflowStoreOptions } from './interfaces/postgres-workflow-store-options.interface.js';
|
|
7
7
|
/**
|
|
@@ -26,6 +26,9 @@ import type { PostgresWorkflowStoreOptions } from './interfaces/postgres-workflo
|
|
|
26
26
|
*
|
|
27
27
|
* Given `storage`, it registers itself (`storage.registerSource(this)`), as every store does. It checks its schema,
|
|
28
28
|
* or migrates it (`migrate`), in `onModuleInit` (so before the worker starts), or at its first call outside Nest.
|
|
29
|
+
*
|
|
30
|
+
* PostgreSQL keeps U+0000 in neither `text` nor `jsonb`, nor a lone UTF-16 surrogate in `jsonb` (`unstorableText()`):
|
|
31
|
+
* the engine refuses them with a `TypeError` where the application or a run gives them, unless a codec encodes them.
|
|
29
32
|
*/
|
|
30
33
|
export declare class PostgresWorkflowStore implements WorkflowStore, OnModuleInit {
|
|
31
34
|
/**
|
|
@@ -117,6 +120,8 @@ export declare class PostgresWorkflowStore implements WorkflowStore, OnModuleIni
|
|
|
117
120
|
terminateRequested: boolean;
|
|
118
121
|
} | null>;
|
|
119
122
|
write(id: string, token: string, write: WorkflowWrite): Promise<boolean>;
|
|
123
|
+
/** U+0000 anywhere, and a lone surrogate in JSON: what the engine refuses before it writes (see the class). */
|
|
124
|
+
unstorableText(text: string, column: WorkflowTextColumn): string | undefined;
|
|
120
125
|
private insertInstance;
|
|
121
126
|
private insertSignal;
|
|
122
127
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Logger } from '@nestjs/common';
|
|
2
2
|
import { advisoryLock, assertReadCommittedTransaction, columns, quoteSchema, SqlParams, toBool, toInt, toJson, toText, } from '@nestjs/store-kit/postgres';
|
|
3
|
+
import { loneSurrogateIn, nulIn } from '../utils/storable-text.util.js';
|
|
3
4
|
import { RateWindowClaim } from '../sql/rate-windows.js';
|
|
4
5
|
import { CANCELLABLE_STATUSES, INSTANCE_COLUMNS, RUNNABLE_STATUSES, SCHEDULE_COLUMNS, toInstance, toSchedule } from '../sql/workflow-rows.js';
|
|
5
6
|
import { workflowStoreSchema } from './migrations/index.js';
|
|
@@ -29,6 +30,9 @@ const CANCELLABLE = `(${CANCELLABLE_STATUSES.map((status) => `'${status}'`).join
|
|
|
29
30
|
*
|
|
30
31
|
* Given `storage`, it registers itself (`storage.registerSource(this)`), as every store does. It checks its schema,
|
|
31
32
|
* or migrates it (`migrate`), in `onModuleInit` (so before the worker starts), or at its first call outside Nest.
|
|
33
|
+
*
|
|
34
|
+
* PostgreSQL keeps U+0000 in neither `text` nor `jsonb`, nor a lone UTF-16 surrogate in `jsonb` (`unstorableText()`):
|
|
35
|
+
* the engine refuses them with a `TypeError` where the application or a run gives them, unless a codec encodes them.
|
|
32
36
|
*/
|
|
33
37
|
export class PostgresWorkflowStore {
|
|
34
38
|
/**
|
|
@@ -491,6 +495,10 @@ RETURNING cancel_requested::text AS cancel_requested, terminate_requested::text
|
|
|
491
495
|
return true;
|
|
492
496
|
}, READ_COMMITTED);
|
|
493
497
|
}
|
|
498
|
+
/** U+0000 anywhere, and a lone surrogate in JSON: what the engine refuses before it writes (see the class). */
|
|
499
|
+
unstorableText(text, column) {
|
|
500
|
+
return nulIn(text) ?? (column === 'json' ? loneSurrogateIn(text) : undefined);
|
|
501
|
+
}
|
|
494
502
|
// ---------------------------------------------------------------- internals
|
|
495
503
|
async insertInstance(db, i) {
|
|
496
504
|
// An instance deleted between the insert that met it and the read of it is created again.
|