@rivetkit/workflow-engine 2.1.5 → 2.1.6-rc.1
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-MMWB37UG.js} +449 -209
- package/dist/tsup/chunk-MMWB37UG.js.map +1 -0
- package/dist/tsup/{chunk-KQO2TD7T.cjs → chunk-SHICGAKC.cjs} +448 -208
- package/dist/tsup/chunk-SHICGAKC.cjs.map +1 -0
- package/dist/tsup/index.cjs +2 -4
- package/dist/tsup/index.cjs.map +1 -1
- package/dist/tsup/index.d.cts +85 -25
- package/dist/tsup/index.d.ts +85 -25
- package/dist/tsup/index.js +5 -7
- package/dist/tsup/testing.cjs +35 -25
- 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 +19 -9
- package/dist/tsup/testing.js.map +1 -1
- package/package.json +1 -1
- package/src/context.ts +298 -114
- package/src/driver.ts +5 -0
- package/src/error-utils.ts +87 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +84 -55
- package/src/keys.ts +26 -0
- package/src/location.ts +4 -1
- package/src/storage.ts +62 -21
- 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
|
|
|
@@ -297,7 +358,7 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
297
358
|
// This means workflow code may have changed
|
|
298
359
|
throw new HistoryDivergedError(
|
|
299
360
|
`Entry "${key}" exists in history but was not visited. ` +
|
|
300
|
-
|
|
361
|
+
`Workflow code may have changed. Use ctx.removed() to handle migrations.`,
|
|
301
362
|
);
|
|
302
363
|
}
|
|
303
364
|
}
|
|
@@ -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,111 @@ 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(
|
|
621
|
+
config,
|
|
622
|
+
metadata.attempts,
|
|
623
|
+
error,
|
|
624
|
+
{
|
|
625
|
+
willRetry: true,
|
|
626
|
+
retryDelay,
|
|
627
|
+
retryAt,
|
|
628
|
+
},
|
|
629
|
+
);
|
|
630
|
+
throw new StepFailedError(
|
|
631
|
+
config.name,
|
|
632
|
+
error,
|
|
633
|
+
metadata.attempts,
|
|
634
|
+
retryAt,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const exhaustedError = markErrorReported(
|
|
639
|
+
new StepExhaustedError(config.name, String(error)),
|
|
640
|
+
);
|
|
641
|
+
await this.notifyStepError(
|
|
642
|
+
config,
|
|
643
|
+
metadata.attempts,
|
|
644
|
+
error,
|
|
645
|
+
{ willRetry: false },
|
|
646
|
+
);
|
|
647
|
+
throw exhaustedError;
|
|
513
648
|
}
|
|
514
649
|
}
|
|
515
650
|
|
|
@@ -644,7 +779,11 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
644
779
|
}
|
|
645
780
|
|
|
646
781
|
const loopData = existing.kind.data;
|
|
647
|
-
metadata = await loadMetadata(
|
|
782
|
+
metadata = await loadMetadata(
|
|
783
|
+
this.storage,
|
|
784
|
+
this.driver,
|
|
785
|
+
existing.id,
|
|
786
|
+
);
|
|
648
787
|
|
|
649
788
|
if (rollbackMode) {
|
|
650
789
|
if (loopData.output !== undefined) {
|
|
@@ -692,20 +831,26 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
692
831
|
metadata.dirty = true;
|
|
693
832
|
}
|
|
694
833
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
834
|
+
const historyPruneInterval =
|
|
835
|
+
config.historyPruneInterval ?? DEFAULT_LOOP_HISTORY_PRUNE_INTERVAL;
|
|
836
|
+
const historySize = config.historySize ?? historyPruneInterval;
|
|
837
|
+
|
|
838
|
+
// Track the last iteration we pruned up to so we only delete
|
|
839
|
+
// newly-expired iterations instead of re-scanning from 0.
|
|
840
|
+
let lastPrunedUpTo = 0;
|
|
841
|
+
|
|
842
|
+
// Deferred flush promise from the previous prune cycle. Awaited at the
|
|
843
|
+
// start of the next iteration so the flush runs in parallel with user code.
|
|
844
|
+
let deferredFlush: Promise<void> | null = null;
|
|
706
845
|
|
|
707
846
|
// Execute loop iterations
|
|
708
847
|
while (true) {
|
|
848
|
+
// Await any deferred flush from the previous prune cycle
|
|
849
|
+
if (deferredFlush) {
|
|
850
|
+
await deferredFlush;
|
|
851
|
+
deferredFlush = null;
|
|
852
|
+
}
|
|
853
|
+
|
|
709
854
|
if (rollbackMode && rollbackSingleIteration) {
|
|
710
855
|
if (rollbackIterationRan) {
|
|
711
856
|
return rollbackOutput as T;
|
|
@@ -752,13 +897,14 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
752
897
|
metadata.dirty = true;
|
|
753
898
|
}
|
|
754
899
|
|
|
755
|
-
|
|
756
|
-
|
|
900
|
+
// Collect pruning deletions and flush
|
|
901
|
+
const deletions = this.collectLoopPruning(
|
|
757
902
|
location,
|
|
758
903
|
iteration + 1,
|
|
759
|
-
|
|
760
|
-
|
|
904
|
+
historySize,
|
|
905
|
+
lastPrunedUpTo,
|
|
761
906
|
);
|
|
907
|
+
await this.flushStorageWithDeletions(deletions);
|
|
762
908
|
|
|
763
909
|
if (rollbackMode && rollbackSingleIteration) {
|
|
764
910
|
rollbackOutput = result.value;
|
|
@@ -775,72 +921,102 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
775
921
|
}
|
|
776
922
|
iteration++;
|
|
777
923
|
|
|
778
|
-
|
|
779
|
-
if (iteration % commitInterval === 0) {
|
|
924
|
+
if (!rollbackMode) {
|
|
780
925
|
if (entry.kind.type === "loop") {
|
|
781
926
|
entry.kind.data.state = state;
|
|
782
927
|
entry.kind.data.iteration = iteration;
|
|
783
928
|
}
|
|
784
929
|
entry.dirty = true;
|
|
930
|
+
}
|
|
785
931
|
|
|
786
|
-
|
|
787
|
-
|
|
932
|
+
// Periodically defer the flush so the next iteration can overlap
|
|
933
|
+
// with loop pruning and any pending dirty state writes.
|
|
934
|
+
if (iteration % historyPruneInterval === 0) {
|
|
935
|
+
const deletions = this.collectLoopPruning(
|
|
788
936
|
location,
|
|
789
937
|
iteration,
|
|
790
|
-
|
|
791
|
-
|
|
938
|
+
historySize,
|
|
939
|
+
lastPrunedUpTo,
|
|
792
940
|
);
|
|
941
|
+
lastPrunedUpTo = Math.max(0, iteration - historySize);
|
|
942
|
+
|
|
943
|
+
// Defer the flush to run in parallel with the next iteration
|
|
944
|
+
deferredFlush = this.flushStorageWithDeletions(deletions);
|
|
793
945
|
}
|
|
794
946
|
}
|
|
795
947
|
}
|
|
796
948
|
|
|
797
949
|
/**
|
|
798
|
-
*
|
|
950
|
+
* Collect pending deletions for loop history pruning.
|
|
799
951
|
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
* ctx.loop("outer") → location: [outerIndex]
|
|
805
|
-
* iteration 0 → location: [{ loop: outerIndex, iteration: 0 }]
|
|
806
|
-
* ctx.loop("inner") → location: [{ loop: outerIndex, iteration: 0 }, innerIndex]
|
|
807
|
-
*
|
|
808
|
-
* This function removes iterations older than (currentIteration - historyKeep)
|
|
809
|
-
* every historyEvery iterations.
|
|
952
|
+
* Only deletes iterations in the range [fromIteration, keepFrom) where
|
|
953
|
+
* keepFrom = currentIteration - historySize. This avoids re-scanning
|
|
954
|
+
* already-deleted iterations.
|
|
810
955
|
*/
|
|
811
|
-
private
|
|
956
|
+
private collectLoopPruning(
|
|
812
957
|
loopLocation: Location,
|
|
813
958
|
currentIteration: number,
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
):
|
|
817
|
-
if (
|
|
818
|
-
return;
|
|
959
|
+
historySize: number,
|
|
960
|
+
fromIteration: number,
|
|
961
|
+
): PendingDeletions | undefined {
|
|
962
|
+
if (currentIteration <= historySize) {
|
|
963
|
+
return undefined;
|
|
819
964
|
}
|
|
820
|
-
|
|
821
|
-
|
|
965
|
+
|
|
966
|
+
const keepFrom = Math.max(0, currentIteration - historySize);
|
|
967
|
+
if (fromIteration >= keepFrom) {
|
|
968
|
+
return undefined;
|
|
822
969
|
}
|
|
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().
|
|
970
|
+
|
|
827
971
|
const loopSegment = loopLocation[loopLocation.length - 1];
|
|
828
972
|
if (typeof loopSegment !== "number") {
|
|
829
973
|
throw new Error("Expected loop location to end with a name index");
|
|
830
974
|
}
|
|
831
975
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
976
|
+
const range = buildLoopIterationRange(
|
|
977
|
+
loopLocation,
|
|
978
|
+
loopSegment,
|
|
979
|
+
fromIteration,
|
|
980
|
+
keepFrom,
|
|
981
|
+
);
|
|
982
|
+
const metadataKeys: Uint8Array[] = [];
|
|
983
|
+
|
|
984
|
+
for (const [key, entry] of this.storage.history.entries) {
|
|
985
|
+
if (!isLocationPrefix(loopLocation, entry.location)) {
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const iterationSegment = entry.location[loopLocation.length];
|
|
990
|
+
if (
|
|
991
|
+
!iterationSegment ||
|
|
992
|
+
typeof iterationSegment === "number" ||
|
|
993
|
+
iterationSegment.loop !== loopSegment ||
|
|
994
|
+
iterationSegment.iteration < fromIteration ||
|
|
995
|
+
iterationSegment.iteration >= keepFrom
|
|
996
|
+
) {
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
metadataKeys.push(buildEntryMetadataKey(entry.id));
|
|
1001
|
+
this.storage.entryMetadata.delete(entry.id);
|
|
1002
|
+
this.storage.history.entries.delete(key);
|
|
843
1003
|
}
|
|
1004
|
+
|
|
1005
|
+
return {
|
|
1006
|
+
prefixes: [],
|
|
1007
|
+
keys: metadataKeys,
|
|
1008
|
+
ranges: [range],
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Flush storage with optional pending deletions so pruning
|
|
1014
|
+
* happens alongside the state write.
|
|
1015
|
+
*/
|
|
1016
|
+
private async flushStorageWithDeletions(
|
|
1017
|
+
deletions?: PendingDeletions,
|
|
1018
|
+
): Promise<void> {
|
|
1019
|
+
await flush(this.storage, this.driver, this.historyNotifier, deletions);
|
|
844
1020
|
}
|
|
845
1021
|
|
|
846
1022
|
// === Sleep ===
|
|
@@ -1246,7 +1422,8 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
1246
1422
|
);
|
|
1247
1423
|
const messageKey = locationToKey(this.storage, messageLocation);
|
|
1248
1424
|
this.markVisited(messageKey);
|
|
1249
|
-
const existingMessage =
|
|
1425
|
+
const existingMessage =
|
|
1426
|
+
this.storage.history.entries.get(messageKey);
|
|
1250
1427
|
if (!existingMessage || existingMessage.kind.type !== "message") {
|
|
1251
1428
|
throw new HistoryDivergedError(
|
|
1252
1429
|
`Expected queue message "${name}:${i}" in history`,
|
|
@@ -1267,7 +1444,9 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
1267
1444
|
return results;
|
|
1268
1445
|
}
|
|
1269
1446
|
|
|
1270
|
-
private toWorkflowQueueMessage<T>(
|
|
1447
|
+
private toWorkflowQueueMessage<T>(
|
|
1448
|
+
message: Message,
|
|
1449
|
+
): WorkflowQueueMessage<T> {
|
|
1271
1450
|
return {
|
|
1272
1451
|
id: message.id,
|
|
1273
1452
|
name: message.name,
|
|
@@ -1377,15 +1556,17 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
1377
1556
|
if (
|
|
1378
1557
|
typeof value === "object" &&
|
|
1379
1558
|
value !== null &&
|
|
1380
|
-
(value as Record<string, unknown>)[QUEUE_HISTORY_MESSAGE_MARKER] ===
|
|
1559
|
+
(value as Record<string, unknown>)[QUEUE_HISTORY_MESSAGE_MARKER] ===
|
|
1560
|
+
1
|
|
1381
1561
|
) {
|
|
1382
1562
|
const serialized = value as Record<string, unknown>;
|
|
1383
|
-
const id =
|
|
1384
|
-
typeof serialized.id === "string" ? serialized.id : "";
|
|
1563
|
+
const id = typeof serialized.id === "string" ? serialized.id : "";
|
|
1385
1564
|
const serializedName =
|
|
1386
1565
|
typeof serialized.name === "string" ? serialized.name : name;
|
|
1387
1566
|
const createdAt =
|
|
1388
|
-
typeof serialized.createdAt === "number"
|
|
1567
|
+
typeof serialized.createdAt === "number"
|
|
1568
|
+
? serialized.createdAt
|
|
1569
|
+
: 0;
|
|
1389
1570
|
const completed =
|
|
1390
1571
|
typeof serialized.completed === "boolean"
|
|
1391
1572
|
? serialized.completed
|
|
@@ -1734,7 +1915,10 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
|
|
|
1734
1915
|
}
|
|
1735
1916
|
if (error instanceof MessageWaitError) {
|
|
1736
1917
|
// Track message wait errors, prefer sleep errors with deadlines
|
|
1737
|
-
if (
|
|
1918
|
+
if (
|
|
1919
|
+
!yieldError ||
|
|
1920
|
+
!(yieldError instanceof SleepError)
|
|
1921
|
+
) {
|
|
1738
1922
|
if (!yieldError) {
|
|
1739
1923
|
yieldError = error;
|
|
1740
1924
|
} 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
|
*
|