@rivetkit/workflow-engine 2.1.6 → 2.1.8
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/tsup/{chunk-JTLDEP6X.js → chunk-4ME2JBMC.js} +542 -210
- package/dist/tsup/chunk-4ME2JBMC.js.map +1 -0
- package/dist/tsup/{chunk-KQO2TD7T.cjs → chunk-OYYWSC77.cjs} +539 -207
- package/dist/tsup/chunk-OYYWSC77.cjs.map +1 -0
- package/dist/tsup/index.cjs +2 -2
- package/dist/tsup/index.cjs.map +1 -1
- package/dist/tsup/index.d.cts +93 -25
- package/dist/tsup/index.d.ts +93 -25
- package/dist/tsup/index.js +7 -7
- package/dist/tsup/testing.cjs +35 -23
- package/dist/tsup/testing.cjs.map +1 -1
- package/dist/tsup/testing.d.cts +2 -1
- package/dist/tsup/testing.d.ts +2 -1
- package/dist/tsup/testing.js +21 -9
- package/dist/tsup/testing.js.map +1 -1
- package/package.json +1 -1
- package/src/context.ts +289 -113
- package/src/driver.ts +5 -0
- package/src/error-utils.ts +87 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +214 -55
- package/src/keys.ts +26 -0
- package/src/location.ts +4 -1
- package/src/storage.ts +73 -20
- package/src/testing.ts +25 -4
- package/src/types.ts +48 -11
- package/dist/tsup/chunk-JTLDEP6X.js.map +0 -1
- package/dist/tsup/chunk-KQO2TD7T.cjs.map +0 -1
package/src/context.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Logger } from "pino";
|
|
2
2
|
import type { EngineDriver } from "./driver.js";
|
|
3
|
+
import {
|
|
4
|
+
extractErrorInfo,
|
|
5
|
+
getErrorEventTag,
|
|
6
|
+
markErrorReported,
|
|
7
|
+
} from "./error-utils.js";
|
|
3
8
|
import {
|
|
4
9
|
CancelledError,
|
|
5
10
|
CriticalError,
|
|
@@ -16,10 +21,12 @@ import {
|
|
|
16
21
|
StepExhaustedError,
|
|
17
22
|
StepFailedError,
|
|
18
23
|
} from "./errors.js";
|
|
24
|
+
import { buildLoopIterationRange, buildEntryMetadataKey } from "./keys.js";
|
|
19
25
|
import {
|
|
20
26
|
appendLoopIteration,
|
|
21
27
|
appendName,
|
|
22
28
|
emptyLocation,
|
|
29
|
+
isLocationPrefix,
|
|
23
30
|
locationToKey,
|
|
24
31
|
registerName,
|
|
25
32
|
} from "./location.js";
|
|
@@ -30,6 +37,7 @@ import {
|
|
|
30
37
|
getOrCreateMetadata,
|
|
31
38
|
loadMetadata,
|
|
32
39
|
setEntry,
|
|
40
|
+
type PendingDeletions,
|
|
33
41
|
} from "./storage.js";
|
|
34
42
|
import type {
|
|
35
43
|
BranchConfig,
|
|
@@ -47,6 +55,8 @@ import type {
|
|
|
47
55
|
StepConfig,
|
|
48
56
|
Storage,
|
|
49
57
|
WorkflowContextInterface,
|
|
58
|
+
WorkflowErrorEvent,
|
|
59
|
+
WorkflowErrorHandler,
|
|
50
60
|
WorkflowQueue,
|
|
51
61
|
WorkflowQueueMessage,
|
|
52
62
|
WorkflowQueueNextBatchOptions,
|
|
@@ -62,9 +72,7 @@ import { sleep } from "./utils.js";
|
|
|
62
72
|
export const DEFAULT_MAX_RETRIES = 3;
|
|
63
73
|
export const DEFAULT_RETRY_BACKOFF_BASE = 100;
|
|
64
74
|
export const DEFAULT_RETRY_BACKOFF_MAX = 30000;
|
|
65
|
-
export const
|
|
66
|
-
export const DEFAULT_LOOP_HISTORY_EVERY = 20;
|
|
67
|
-
export const DEFAULT_LOOP_HISTORY_KEEP = 20;
|
|
75
|
+
export const DEFAULT_LOOP_HISTORY_PRUNE_INTERVAL = 20;
|
|
68
76
|
export const DEFAULT_STEP_TIMEOUT = 30000; // 30 seconds
|
|
69
77
|
|
|
70
78
|
const QUEUE_HISTORY_MESSAGE_MARKER = "__rivetWorkflowQueueMessage";
|
|
@@ -108,7 +116,7 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
108
116
|
private entryInProgress = false;
|
|
109
117
|
private abortController: AbortController;
|
|
110
118
|
private currentLocation: Location;
|
|
111
|
-
private visitedKeys
|
|
119
|
+
private visitedKeys: Set<string>;
|
|
112
120
|
private mode: "forward" | "rollback";
|
|
113
121
|
private rollbackActions?: RollbackAction[];
|
|
114
122
|
private rollbackCheckpointSet: boolean;
|
|
@@ -116,6 +124,7 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
116
124
|
private usedNamesInExecution = new Set<string>();
|
|
117
125
|
private pendingCompletableMessageIds = new Set<string>();
|
|
118
126
|
private historyNotifier?: () => void;
|
|
127
|
+
private onError?: WorkflowErrorHandler;
|
|
119
128
|
private logger?: Logger;
|
|
120
129
|
|
|
121
130
|
constructor(
|
|
@@ -129,7 +138,9 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
129
138
|
rollbackActions?: RollbackAction[],
|
|
130
139
|
rollbackCheckpointSet = false,
|
|
131
140
|
historyNotifier?: () => void,
|
|
141
|
+
onError?: WorkflowErrorHandler,
|
|
132
142
|
logger?: Logger,
|
|
143
|
+
visitedKeys?: Set<string>,
|
|
133
144
|
) {
|
|
134
145
|
this.currentLocation = location;
|
|
135
146
|
this.abortController = abortController ?? new AbortController();
|
|
@@ -137,7 +148,9 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
137
148
|
this.rollbackActions = rollbackActions;
|
|
138
149
|
this.rollbackCheckpointSet = rollbackCheckpointSet;
|
|
139
150
|
this.historyNotifier = historyNotifier;
|
|
151
|
+
this.onError = onError;
|
|
140
152
|
this.logger = logger;
|
|
153
|
+
this.visitedKeys = visitedKeys ?? new Set();
|
|
141
154
|
}
|
|
142
155
|
|
|
143
156
|
get abortSignal(): AbortSignal {
|
|
@@ -147,7 +160,8 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
147
160
|
get queue(): WorkflowQueue {
|
|
148
161
|
return {
|
|
149
162
|
next: async (name, opts) => await this.queueNext(name, opts),
|
|
150
|
-
nextBatch: async (name, opts) =>
|
|
163
|
+
nextBatch: async (name, opts) =>
|
|
164
|
+
await this.queueNextBatch(name, opts),
|
|
151
165
|
send: async (name, body) => await this.queueSend(name, body),
|
|
152
166
|
};
|
|
153
167
|
}
|
|
@@ -190,18 +204,65 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
190
204
|
this.rollbackActions,
|
|
191
205
|
this.rollbackCheckpointSet,
|
|
192
206
|
this.historyNotifier,
|
|
207
|
+
this.onError,
|
|
193
208
|
this.logger,
|
|
209
|
+
this.visitedKeys,
|
|
194
210
|
);
|
|
195
211
|
}
|
|
196
212
|
|
|
197
213
|
/**
|
|
198
214
|
* Log a debug message using the configured logger.
|
|
199
215
|
*/
|
|
200
|
-
private log(
|
|
216
|
+
private log(
|
|
217
|
+
level: "debug" | "info" | "warn" | "error",
|
|
218
|
+
data: Record<string, unknown>,
|
|
219
|
+
): void {
|
|
201
220
|
if (!this.logger) return;
|
|
202
221
|
this.logger[level](data);
|
|
203
222
|
}
|
|
204
223
|
|
|
224
|
+
private async notifyError(event: WorkflowErrorEvent): Promise<void> {
|
|
225
|
+
if (!this.onError) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await this.onError(event);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
this.log("warn", {
|
|
233
|
+
msg: "workflow error hook failed",
|
|
234
|
+
hookEventType: getErrorEventTag(event),
|
|
235
|
+
error: extractErrorInfo(error),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async notifyStepError<T>(
|
|
241
|
+
config: StepConfig<T>,
|
|
242
|
+
attempt: number,
|
|
243
|
+
error: unknown,
|
|
244
|
+
opts: {
|
|
245
|
+
willRetry: boolean;
|
|
246
|
+
retryDelay?: number;
|
|
247
|
+
retryAt?: number;
|
|
248
|
+
},
|
|
249
|
+
): Promise<void> {
|
|
250
|
+
const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
251
|
+
await this.notifyError({
|
|
252
|
+
step: {
|
|
253
|
+
workflowId: this.workflowId,
|
|
254
|
+
stepName: config.name,
|
|
255
|
+
attempt,
|
|
256
|
+
maxRetries,
|
|
257
|
+
remainingRetries: Math.max(0, maxRetries - (attempt - 1)),
|
|
258
|
+
willRetry: opts.willRetry,
|
|
259
|
+
retryDelay: opts.retryDelay,
|
|
260
|
+
retryAt: opts.retryAt,
|
|
261
|
+
error: extractErrorInfo(error),
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
205
266
|
/**
|
|
206
267
|
* Mark a key as visited.
|
|
207
268
|
*/
|
|
@@ -216,12 +277,12 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
216
277
|
private checkDuplicateName(name: string): void {
|
|
217
278
|
const fullKey =
|
|
218
279
|
locationToKey(this.storage, this.currentLocation) + "/" + name;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
280
|
+
if (this.usedNamesInExecution.has(fullKey)) {
|
|
281
|
+
throw new HistoryDivergedError(
|
|
282
|
+
`Duplicate entry name "${name}" at location "${locationToKey(this.storage, this.currentLocation)}". ` +
|
|
283
|
+
`Each step/loop/sleep/queue.next/join/race must have a unique name within its scope.`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
225
286
|
this.usedNamesInExecution.add(fullKey);
|
|
226
287
|
}
|
|
227
288
|
|
|
@@ -391,19 +452,36 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
391
452
|
);
|
|
392
453
|
|
|
393
454
|
// Replay successful result (including void steps).
|
|
394
|
-
if (
|
|
455
|
+
if (
|
|
456
|
+
metadata.status === "completed" ||
|
|
457
|
+
stepData.output !== undefined
|
|
458
|
+
) {
|
|
395
459
|
return stepData.output as T;
|
|
396
460
|
}
|
|
397
461
|
|
|
398
462
|
// Check if we should retry
|
|
399
463
|
const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
400
464
|
|
|
401
|
-
if (metadata.attempts
|
|
465
|
+
if (metadata.attempts > maxRetries) {
|
|
402
466
|
// Prefer step history error, but fall back to metadata since
|
|
403
467
|
// driver implementations may persist metadata without the history
|
|
404
468
|
// entry error (e.g. partial writes/crashes between attempts).
|
|
405
469
|
const lastError = stepData.error ?? metadata.error;
|
|
406
|
-
|
|
470
|
+
const exhaustedError = markErrorReported(
|
|
471
|
+
new StepExhaustedError(config.name, lastError),
|
|
472
|
+
);
|
|
473
|
+
if (metadata.status !== "exhausted") {
|
|
474
|
+
metadata.status = "exhausted";
|
|
475
|
+
metadata.dirty = true;
|
|
476
|
+
await this.flushStorage();
|
|
477
|
+
await this.notifyStepError(
|
|
478
|
+
config,
|
|
479
|
+
metadata.attempts,
|
|
480
|
+
exhaustedError,
|
|
481
|
+
{ willRetry: false },
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
throw exhaustedError;
|
|
407
485
|
}
|
|
408
486
|
|
|
409
487
|
// Calculate backoff and yield to scheduler
|
|
@@ -427,7 +505,11 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
427
505
|
existing ?? createEntry(location, { type: "step", data: {} });
|
|
428
506
|
if (!existing) {
|
|
429
507
|
// New entry - register name
|
|
430
|
-
this.log("debug", {
|
|
508
|
+
this.log("debug", {
|
|
509
|
+
msg: "executing new step",
|
|
510
|
+
step: config.name,
|
|
511
|
+
key,
|
|
512
|
+
});
|
|
431
513
|
const nameIndex = registerName(this.storage, config.name);
|
|
432
514
|
entry.location = [...location];
|
|
433
515
|
entry.location[entry.location.length - 1] = nameIndex;
|
|
@@ -437,6 +519,11 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
437
519
|
}
|
|
438
520
|
|
|
439
521
|
const metadata = getOrCreateMetadata(this.storage, entry.id);
|
|
522
|
+
const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
523
|
+
const retryBackoffBase =
|
|
524
|
+
config.retryBackoffBase ?? DEFAULT_RETRY_BACKOFF_BASE;
|
|
525
|
+
const retryBackoffMax =
|
|
526
|
+
config.retryBackoffMax ?? DEFAULT_RETRY_BACKOFF_MAX;
|
|
440
527
|
metadata.status = "running";
|
|
441
528
|
metadata.attempts++;
|
|
442
529
|
metadata.lastAttemptAt = Date.now();
|
|
@@ -453,63 +540,103 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
453
540
|
config.name,
|
|
454
541
|
);
|
|
455
542
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
543
|
+
if (entry.kind.type === "step") {
|
|
544
|
+
entry.kind.data.output = output;
|
|
545
|
+
}
|
|
546
|
+
entry.dirty = true;
|
|
547
|
+
metadata.status = "completed";
|
|
548
|
+
metadata.error = undefined;
|
|
549
|
+
metadata.completedAt = Date.now();
|
|
463
550
|
|
|
464
|
-
|
|
551
|
+
// Ephemeral steps don't trigger an immediate flush. This avoids the
|
|
465
552
|
// synchronous write overhead for transient operations. Note that the
|
|
466
553
|
// step's entry is still marked dirty and WILL be persisted on the
|
|
467
554
|
// next flush from a non-ephemeral operation. The purpose of ephemeral
|
|
468
555
|
// is to batch writes, not to avoid persistence entirely.
|
|
469
556
|
if (!config.ephemeral) {
|
|
470
|
-
this.log("debug", {
|
|
557
|
+
this.log("debug", {
|
|
558
|
+
msg: "flushing step",
|
|
559
|
+
step: config.name,
|
|
560
|
+
key,
|
|
561
|
+
});
|
|
471
562
|
await this.flushStorage();
|
|
472
563
|
}
|
|
473
564
|
|
|
474
|
-
this.log("debug", {
|
|
565
|
+
this.log("debug", {
|
|
566
|
+
msg: "step completed",
|
|
567
|
+
step: config.name,
|
|
568
|
+
key,
|
|
569
|
+
});
|
|
475
570
|
return output;
|
|
476
571
|
} catch (error) {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
482
|
-
entry.dirty = true;
|
|
483
|
-
metadata.status = "exhausted";
|
|
484
|
-
metadata.error = String(error);
|
|
485
|
-
await this.flushStorage();
|
|
486
|
-
throw new CriticalError(error.message);
|
|
572
|
+
// Timeout errors are treated as critical (no retry)
|
|
573
|
+
if (error instanceof StepTimeoutError) {
|
|
574
|
+
if (entry.kind.type === "step") {
|
|
575
|
+
entry.kind.data.error = String(error);
|
|
487
576
|
}
|
|
577
|
+
entry.dirty = true;
|
|
578
|
+
metadata.status = "exhausted";
|
|
579
|
+
metadata.error = String(error);
|
|
580
|
+
await this.flushStorage();
|
|
581
|
+
await this.notifyStepError(config, metadata.attempts, error, {
|
|
582
|
+
willRetry: false,
|
|
583
|
+
});
|
|
584
|
+
throw markErrorReported(new CriticalError(error.message));
|
|
585
|
+
}
|
|
488
586
|
|
|
489
587
|
if (
|
|
490
588
|
error instanceof CriticalError ||
|
|
491
589
|
error instanceof RollbackError
|
|
492
|
-
|
|
493
|
-
if (entry.kind.type === "step") {
|
|
494
|
-
entry.kind.data.error = String(error);
|
|
495
|
-
}
|
|
496
|
-
entry.dirty = true;
|
|
497
|
-
metadata.status = "exhausted";
|
|
498
|
-
metadata.error = String(error);
|
|
499
|
-
await this.flushStorage();
|
|
500
|
-
throw error;
|
|
501
|
-
}
|
|
502
|
-
|
|
590
|
+
) {
|
|
503
591
|
if (entry.kind.type === "step") {
|
|
504
592
|
entry.kind.data.error = String(error);
|
|
505
593
|
}
|
|
506
594
|
entry.dirty = true;
|
|
507
|
-
metadata.status = "
|
|
595
|
+
metadata.status = "exhausted";
|
|
508
596
|
metadata.error = String(error);
|
|
509
|
-
|
|
510
597
|
await this.flushStorage();
|
|
598
|
+
await this.notifyStepError(config, metadata.attempts, error, {
|
|
599
|
+
willRetry: false,
|
|
600
|
+
});
|
|
601
|
+
throw markErrorReported(error);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (entry.kind.type === "step") {
|
|
605
|
+
entry.kind.data.error = String(error);
|
|
606
|
+
}
|
|
607
|
+
entry.dirty = true;
|
|
608
|
+
const willRetry = metadata.attempts <= maxRetries;
|
|
609
|
+
metadata.status = willRetry ? "failed" : "exhausted";
|
|
610
|
+
metadata.error = String(error);
|
|
511
611
|
|
|
512
|
-
|
|
612
|
+
await this.flushStorage();
|
|
613
|
+
if (willRetry) {
|
|
614
|
+
const retryDelay = calculateBackoff(
|
|
615
|
+
metadata.attempts,
|
|
616
|
+
retryBackoffBase,
|
|
617
|
+
retryBackoffMax,
|
|
618
|
+
);
|
|
619
|
+
const retryAt = metadata.lastAttemptAt + retryDelay;
|
|
620
|
+
await this.notifyStepError(config, metadata.attempts, error, {
|
|
621
|
+
willRetry: true,
|
|
622
|
+
retryDelay,
|
|
623
|
+
retryAt,
|
|
624
|
+
});
|
|
625
|
+
throw new StepFailedError(
|
|
626
|
+
config.name,
|
|
627
|
+
error,
|
|
628
|
+
metadata.attempts,
|
|
629
|
+
retryAt,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const exhaustedError = markErrorReported(
|
|
634
|
+
new StepExhaustedError(config.name, String(error)),
|
|
635
|
+
);
|
|
636
|
+
await this.notifyStepError(config, metadata.attempts, error, {
|
|
637
|
+
willRetry: false,
|
|
638
|
+
});
|
|
639
|
+
throw exhaustedError;
|
|
513
640
|
}
|
|
514
641
|
}
|
|
515
642
|
|
|
@@ -644,7 +771,11 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
644
771
|
}
|
|
645
772
|
|
|
646
773
|
const loopData = existing.kind.data;
|
|
647
|
-
metadata = await loadMetadata(
|
|
774
|
+
metadata = await loadMetadata(
|
|
775
|
+
this.storage,
|
|
776
|
+
this.driver,
|
|
777
|
+
existing.id,
|
|
778
|
+
);
|
|
648
779
|
|
|
649
780
|
if (rollbackMode) {
|
|
650
781
|
if (loopData.output !== undefined) {
|
|
@@ -692,20 +823,26 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
692
823
|
metadata.dirty = true;
|
|
693
824
|
}
|
|
694
825
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
826
|
+
const historyPruneInterval =
|
|
827
|
+
config.historyPruneInterval ?? DEFAULT_LOOP_HISTORY_PRUNE_INTERVAL;
|
|
828
|
+
const historySize = config.historySize ?? historyPruneInterval;
|
|
829
|
+
|
|
830
|
+
// Track the last iteration we pruned up to so we only delete
|
|
831
|
+
// newly-expired iterations instead of re-scanning from 0.
|
|
832
|
+
let lastPrunedUpTo = 0;
|
|
833
|
+
|
|
834
|
+
// Deferred flush promise from the previous prune cycle. Awaited at the
|
|
835
|
+
// start of the next iteration so the flush runs in parallel with user code.
|
|
836
|
+
let deferredFlush: Promise<void> | null = null;
|
|
706
837
|
|
|
707
838
|
// Execute loop iterations
|
|
708
839
|
while (true) {
|
|
840
|
+
// Await any deferred flush from the previous prune cycle
|
|
841
|
+
if (deferredFlush) {
|
|
842
|
+
await deferredFlush;
|
|
843
|
+
deferredFlush = null;
|
|
844
|
+
}
|
|
845
|
+
|
|
709
846
|
if (rollbackMode && rollbackSingleIteration) {
|
|
710
847
|
if (rollbackIterationRan) {
|
|
711
848
|
return rollbackOutput as T;
|
|
@@ -752,13 +889,14 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
752
889
|
metadata.dirty = true;
|
|
753
890
|
}
|
|
754
891
|
|
|
755
|
-
|
|
756
|
-
|
|
892
|
+
// Collect pruning deletions and flush
|
|
893
|
+
const deletions = this.collectLoopPruning(
|
|
757
894
|
location,
|
|
758
895
|
iteration + 1,
|
|
759
|
-
|
|
760
|
-
|
|
896
|
+
historySize,
|
|
897
|
+
lastPrunedUpTo,
|
|
761
898
|
);
|
|
899
|
+
await this.flushStorageWithDeletions(deletions);
|
|
762
900
|
|
|
763
901
|
if (rollbackMode && rollbackSingleIteration) {
|
|
764
902
|
rollbackOutput = result.value;
|
|
@@ -775,72 +913,102 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
775
913
|
}
|
|
776
914
|
iteration++;
|
|
777
915
|
|
|
778
|
-
|
|
779
|
-
if (iteration % commitInterval === 0) {
|
|
916
|
+
if (!rollbackMode) {
|
|
780
917
|
if (entry.kind.type === "loop") {
|
|
781
918
|
entry.kind.data.state = state;
|
|
782
919
|
entry.kind.data.iteration = iteration;
|
|
783
920
|
}
|
|
784
921
|
entry.dirty = true;
|
|
922
|
+
}
|
|
785
923
|
|
|
786
|
-
|
|
787
|
-
|
|
924
|
+
// Periodically defer the flush so the next iteration can overlap
|
|
925
|
+
// with loop pruning and any pending dirty state writes.
|
|
926
|
+
if (iteration % historyPruneInterval === 0) {
|
|
927
|
+
const deletions = this.collectLoopPruning(
|
|
788
928
|
location,
|
|
789
929
|
iteration,
|
|
790
|
-
|
|
791
|
-
|
|
930
|
+
historySize,
|
|
931
|
+
lastPrunedUpTo,
|
|
792
932
|
);
|
|
933
|
+
lastPrunedUpTo = Math.max(0, iteration - historySize);
|
|
934
|
+
|
|
935
|
+
// Defer the flush to run in parallel with the next iteration
|
|
936
|
+
deferredFlush = this.flushStorageWithDeletions(deletions);
|
|
793
937
|
}
|
|
794
938
|
}
|
|
795
939
|
}
|
|
796
940
|
|
|
797
941
|
/**
|
|
798
|
-
*
|
|
799
|
-
*
|
|
800
|
-
* Loop locations always end with a NameIndex (number) because loops are
|
|
801
|
-
* created via appendName(). Even for nested loops, the innermost loop's
|
|
802
|
-
* location ends with its name index:
|
|
942
|
+
* Collect pending deletions for loop history pruning.
|
|
803
943
|
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
* This function removes iterations older than (currentIteration - historyKeep)
|
|
809
|
-
* every historyEvery iterations.
|
|
944
|
+
* Only deletes iterations in the range [fromIteration, keepFrom) where
|
|
945
|
+
* keepFrom = currentIteration - historySize. This avoids re-scanning
|
|
946
|
+
* already-deleted iterations.
|
|
810
947
|
*/
|
|
811
|
-
private
|
|
948
|
+
private collectLoopPruning(
|
|
812
949
|
loopLocation: Location,
|
|
813
950
|
currentIteration: number,
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
):
|
|
817
|
-
if (
|
|
818
|
-
return;
|
|
951
|
+
historySize: number,
|
|
952
|
+
fromIteration: number,
|
|
953
|
+
): PendingDeletions | undefined {
|
|
954
|
+
if (currentIteration <= historySize) {
|
|
955
|
+
return undefined;
|
|
819
956
|
}
|
|
820
|
-
|
|
821
|
-
|
|
957
|
+
|
|
958
|
+
const keepFrom = Math.max(0, currentIteration - historySize);
|
|
959
|
+
if (fromIteration >= keepFrom) {
|
|
960
|
+
return undefined;
|
|
822
961
|
}
|
|
823
|
-
|
|
824
|
-
// Get the loop name index from the last segment of loopLocation.
|
|
825
|
-
// This is always a NameIndex (number) because loop entries are created
|
|
826
|
-
// via appendName(), not appendLoopIteration().
|
|
962
|
+
|
|
827
963
|
const loopSegment = loopLocation[loopLocation.length - 1];
|
|
828
964
|
if (typeof loopSegment !== "number") {
|
|
829
965
|
throw new Error("Expected loop location to end with a name index");
|
|
830
966
|
}
|
|
831
967
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
968
|
+
const range = buildLoopIterationRange(
|
|
969
|
+
loopLocation,
|
|
970
|
+
loopSegment,
|
|
971
|
+
fromIteration,
|
|
972
|
+
keepFrom,
|
|
973
|
+
);
|
|
974
|
+
const metadataKeys: Uint8Array[] = [];
|
|
975
|
+
|
|
976
|
+
for (const [key, entry] of this.storage.history.entries) {
|
|
977
|
+
if (!isLocationPrefix(loopLocation, entry.location)) {
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const iterationSegment = entry.location[loopLocation.length];
|
|
982
|
+
if (
|
|
983
|
+
!iterationSegment ||
|
|
984
|
+
typeof iterationSegment === "number" ||
|
|
985
|
+
iterationSegment.loop !== loopSegment ||
|
|
986
|
+
iterationSegment.iteration < fromIteration ||
|
|
987
|
+
iterationSegment.iteration >= keepFrom
|
|
988
|
+
) {
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
metadataKeys.push(buildEntryMetadataKey(entry.id));
|
|
993
|
+
this.storage.entryMetadata.delete(entry.id);
|
|
994
|
+
this.storage.history.entries.delete(key);
|
|
843
995
|
}
|
|
996
|
+
|
|
997
|
+
return {
|
|
998
|
+
prefixes: [],
|
|
999
|
+
keys: metadataKeys,
|
|
1000
|
+
ranges: [range],
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Flush storage with optional pending deletions so pruning
|
|
1006
|
+
* happens alongside the state write.
|
|
1007
|
+
*/
|
|
1008
|
+
private async flushStorageWithDeletions(
|
|
1009
|
+
deletions?: PendingDeletions,
|
|
1010
|
+
): Promise<void> {
|
|
1011
|
+
await flush(this.storage, this.driver, this.historyNotifier, deletions);
|
|
844
1012
|
}
|
|
845
1013
|
|
|
846
1014
|
// === Sleep ===
|
|
@@ -1246,7 +1414,8 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
1246
1414
|
);
|
|
1247
1415
|
const messageKey = locationToKey(this.storage, messageLocation);
|
|
1248
1416
|
this.markVisited(messageKey);
|
|
1249
|
-
const existingMessage =
|
|
1417
|
+
const existingMessage =
|
|
1418
|
+
this.storage.history.entries.get(messageKey);
|
|
1250
1419
|
if (!existingMessage || existingMessage.kind.type !== "message") {
|
|
1251
1420
|
throw new HistoryDivergedError(
|
|
1252
1421
|
`Expected queue message "${name}:${i}" in history`,
|
|
@@ -1267,7 +1436,9 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
1267
1436
|
return results;
|
|
1268
1437
|
}
|
|
1269
1438
|
|
|
1270
|
-
private toWorkflowQueueMessage<T>(
|
|
1439
|
+
private toWorkflowQueueMessage<T>(
|
|
1440
|
+
message: Message,
|
|
1441
|
+
): WorkflowQueueMessage<T> {
|
|
1271
1442
|
return {
|
|
1272
1443
|
id: message.id,
|
|
1273
1444
|
name: message.name,
|
|
@@ -1377,15 +1548,17 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
1377
1548
|
if (
|
|
1378
1549
|
typeof value === "object" &&
|
|
1379
1550
|
value !== null &&
|
|
1380
|
-
(value as Record<string, unknown>)[QUEUE_HISTORY_MESSAGE_MARKER] ===
|
|
1551
|
+
(value as Record<string, unknown>)[QUEUE_HISTORY_MESSAGE_MARKER] ===
|
|
1552
|
+
1
|
|
1381
1553
|
) {
|
|
1382
1554
|
const serialized = value as Record<string, unknown>;
|
|
1383
|
-
const id =
|
|
1384
|
-
typeof serialized.id === "string" ? serialized.id : "";
|
|
1555
|
+
const id = typeof serialized.id === "string" ? serialized.id : "";
|
|
1385
1556
|
const serializedName =
|
|
1386
1557
|
typeof serialized.name === "string" ? serialized.name : name;
|
|
1387
1558
|
const createdAt =
|
|
1388
|
-
typeof serialized.createdAt === "number"
|
|
1559
|
+
typeof serialized.createdAt === "number"
|
|
1560
|
+
? serialized.createdAt
|
|
1561
|
+
: 0;
|
|
1389
1562
|
const completed =
|
|
1390
1563
|
typeof serialized.completed === "boolean"
|
|
1391
1564
|
? serialized.completed
|
|
@@ -1734,7 +1907,10 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
1734
1907
|
}
|
|
1735
1908
|
if (error instanceof MessageWaitError) {
|
|
1736
1909
|
// Track message wait errors, prefer sleep errors with deadlines
|
|
1737
|
-
if (
|
|
1910
|
+
if (
|
|
1911
|
+
!yieldError ||
|
|
1912
|
+
!(yieldError instanceof SleepError)
|
|
1913
|
+
) {
|
|
1738
1914
|
if (!yieldError) {
|
|
1739
1915
|
yieldError = error;
|
|
1740
1916
|
} else if (yieldError instanceof MessageWaitError) {
|
package/src/driver.ts
CHANGED
|
@@ -53,6 +53,11 @@ export interface EngineDriver {
|
|
|
53
53
|
*/
|
|
54
54
|
deletePrefix(prefix: Uint8Array): Promise<void>;
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Delete all keys in the half-open range [start, end).
|
|
58
|
+
*/
|
|
59
|
+
deleteRange(start: Uint8Array, end: Uint8Array): Promise<void>;
|
|
60
|
+
|
|
56
61
|
/**
|
|
57
62
|
* List all key-value pairs with a given prefix.
|
|
58
63
|
*
|