@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/src/storage.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  import type { EngineDriver, KVWrite } from "./driver.js";
16
16
  import {
17
17
  buildEntryMetadataKey,
18
+ buildEntryMetadataPrefix,
18
19
  buildHistoryKey,
19
20
  buildHistoryPrefix,
20
21
  buildHistoryPrefixAll,
@@ -24,6 +25,7 @@ import {
24
25
  buildWorkflowOutputKey,
25
26
  buildWorkflowStateKey,
26
27
  compareKeys,
28
+ parseEntryMetadataKey,
27
29
  parseNameKey,
28
30
  } from "./keys.js";
29
31
  import { isLocationPrefix, locationToKey } from "./location.js";
@@ -129,9 +131,7 @@ export function getOrCreateMetadata(
129
131
  /**
130
132
  * Load storage from the driver.
131
133
  */
132
- export async function loadStorage(
133
- driver: EngineDriver,
134
- ): Promise<Storage> {
134
+ export async function loadStorage(driver: EngineDriver): Promise<Storage> {
135
135
  const storage = createStorage();
136
136
 
137
137
  // Load name registry
@@ -155,6 +155,16 @@ export async function loadStorage(
155
155
  storage.history.entries.set(key, parsed);
156
156
  }
157
157
 
158
+ // Load entry metadata so observers can reconstruct workflow state after
159
+ // the actor wakes and rebuilds storage from persisted history.
160
+ const metadataEntries = await driver.list(buildEntryMetadataPrefix());
161
+ for (const entry of metadataEntries) {
162
+ const entryId = parseEntryMetadataKey(entry.key);
163
+ const metadata = deserializeEntryMetadata(entry.value);
164
+ metadata.dirty = false;
165
+ storage.entryMetadata.set(entryId, metadata);
166
+ }
167
+
158
168
  // Load workflow state
159
169
  const stateValue = await driver.get(buildWorkflowStateKey());
160
170
  if (stateValue) {
@@ -207,12 +217,25 @@ export async function loadMetadata(
207
217
  }
208
218
 
209
219
  /**
210
- * Flush all dirty data to the driver.
220
+ * Pending deletions collected by collectLoopPruning to be included
221
+ * in the next flush alongside the state write.
222
+ */
223
+ export interface PendingDeletions {
224
+ prefixes: Uint8Array[];
225
+ keys: Uint8Array[];
226
+ ranges: { start: Uint8Array; end: Uint8Array }[];
227
+ }
228
+
229
+ /**
230
+ * Flush all dirty data to the driver. Optionally includes pending
231
+ * deletions so that history pruning happens alongside the
232
+ * state write.
211
233
  */
212
234
  export async function flush(
213
235
  storage: Storage,
214
236
  driver: EngineDriver,
215
237
  onHistoryUpdated?: () => void,
238
+ pendingDeletions?: PendingDeletions,
216
239
  ): Promise<void> {
217
240
  const writes: KVWrite[] = [];
218
241
  let historyUpdated = false;
@@ -293,6 +316,25 @@ export async function flush(
293
316
  await driver.batch(writes);
294
317
  }
295
318
 
319
+ // Apply pending deletions after the batch write. These are collected
320
+ // by collectLoopPruning so pruning happens alongside the state write.
321
+ if (pendingDeletions) {
322
+ const deleteOps: Promise<void>[] = [];
323
+ for (const prefix of pendingDeletions.prefixes) {
324
+ deleteOps.push(driver.deletePrefix(prefix));
325
+ }
326
+ for (const range of pendingDeletions.ranges) {
327
+ deleteOps.push(driver.deleteRange(range.start, range.end));
328
+ }
329
+ for (const key of pendingDeletions.keys) {
330
+ deleteOps.push(driver.delete(key));
331
+ }
332
+ if (deleteOps.length > 0) {
333
+ await Promise.all(deleteOps);
334
+ historyUpdated = true;
335
+ }
336
+ }
337
+
296
338
  // Update flushed tracking after successful write
297
339
  storage.flushedNameCount = storage.nameRegistry.length;
298
340
  storage.flushedState = storage.state;
@@ -314,30 +356,41 @@ export async function deleteEntriesWithPrefix(
314
356
  prefixLocation: Location,
315
357
  onHistoryUpdated?: () => void,
316
358
  ): Promise<void> {
317
- // Collect entry IDs for metadata cleanup
318
- const entryIds: string[] = [];
359
+ const deletions = collectDeletionsForPrefix(storage, prefixLocation);
360
+
361
+ // Apply deletions to driver
362
+ await driver.deletePrefix(deletions.prefixes[0]!);
363
+ await Promise.all(deletions.keys.map((key) => driver.delete(key)));
364
+
365
+ if (deletions.keys.length > 0 && onHistoryUpdated) {
366
+ onHistoryUpdated();
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Remove entries matching a location prefix from memory and collect
372
+ * the driver-level deletion operations. The returned PendingDeletions
373
+ * can be applied immediately or batched with a flush.
374
+ */
375
+ export function collectDeletionsForPrefix(
376
+ storage: Storage,
377
+ prefixLocation: Location,
378
+ ): PendingDeletions {
379
+ const pending: PendingDeletions = {
380
+ prefixes: [buildHistoryPrefix(prefixLocation)],
381
+ keys: [],
382
+ ranges: [],
383
+ };
319
384
 
320
- // Collect entries to delete and their IDs
321
385
  for (const [key, entry] of storage.history.entries) {
322
- // Check if the entry's location starts with the prefix location
323
386
  if (isLocationPrefix(prefixLocation, entry.location)) {
324
- entryIds.push(entry.id);
387
+ pending.keys.push(buildEntryMetadataKey(entry.id));
325
388
  storage.entryMetadata.delete(entry.id);
326
389
  storage.history.entries.delete(key);
327
390
  }
328
391
  }
329
392
 
330
- // Delete entries from driver using binary prefix
331
- await driver.deletePrefix(buildHistoryPrefix(prefixLocation));
332
-
333
- // Delete metadata from driver in parallel
334
- await Promise.all(
335
- entryIds.map((id) => driver.delete(buildEntryMetadataKey(id))),
336
- );
337
-
338
- if (entryIds.length > 0 && onHistoryUpdated) {
339
- onHistoryUpdated();
340
- }
393
+ return pending;
341
394
  }
342
395
 
343
396
  /**
package/src/testing.ts CHANGED
@@ -33,7 +33,11 @@ class InMemoryWorkflowMessageDriver implements WorkflowMessageDriver {
33
33
  : undefined;
34
34
  const selected: Array<{ message: Message; index: number }> = [];
35
35
 
36
- for (let i = 0; i < this.#messages.length && selected.length < limitedCount; i++) {
36
+ for (
37
+ let i = 0;
38
+ i < this.#messages.length && selected.length < limitedCount;
39
+ i++
40
+ ) {
37
41
  const message = this.#messages[i];
38
42
  if (nameSet && !nameSet.has(message.name)) {
39
43
  continue;
@@ -64,7 +68,9 @@ class InMemoryWorkflowMessageDriver implements WorkflowMessageDriver {
64
68
  }
65
69
 
66
70
  async completeMessage(messageId: string): Promise<void> {
67
- const index = this.#messages.findIndex((message) => message.id === messageId);
71
+ const index = this.#messages.findIndex(
72
+ (message) => message.id === messageId,
73
+ );
68
74
  if (index !== -1) {
69
75
  this.#messages.splice(index, 1);
70
76
  }
@@ -78,7 +84,8 @@ class InMemoryWorkflowMessageDriver implements WorkflowMessageDriver {
78
84
  throw new EvictedError();
79
85
  }
80
86
 
81
- const nameSet = messageNames.length > 0 ? new Set(messageNames) : undefined;
87
+ const nameSet =
88
+ messageNames.length > 0 ? new Set(messageNames) : undefined;
82
89
  if (
83
90
  this.#messages.some((message) =>
84
91
  nameSet ? nameSet.has(message.name) : true,
@@ -103,7 +110,9 @@ class InMemoryWorkflowMessageDriver implements WorkflowMessageDriver {
103
110
  waiter.reject(new EvictedError());
104
111
  },
105
112
  };
106
- abortSignal.addEventListener("abort", waiter.onAbort, { once: true });
113
+ abortSignal.addEventListener("abort", waiter.onAbort, {
114
+ once: true,
115
+ });
107
116
  this.#waiters.add(waiter);
108
117
  });
109
118
  }
@@ -173,6 +182,18 @@ export class InMemoryDriver implements EngineDriver {
173
182
  }
174
183
  }
175
184
 
185
+ async deleteRange(start: Uint8Array, end: Uint8Array): Promise<void> {
186
+ await sleep(this.latency);
187
+ for (const [hexKey, entry] of this.kv) {
188
+ if (
189
+ compareKeys(entry.key, start) >= 0 &&
190
+ compareKeys(entry.key, end) < 0
191
+ ) {
192
+ this.kv.delete(hexKey);
193
+ }
194
+ }
195
+ }
196
+
176
197
  async list(prefix: Uint8Array): Promise<KVEntry[]> {
177
198
  await sleep(this.latency);
178
199
  const results: KVEntry[] = [];
package/src/types.ts CHANGED
@@ -225,8 +225,7 @@ export interface WorkflowQueueNextOptions {
225
225
  * Options for receiving a batch of queue messages in workflows.
226
226
  */
227
227
  export interface WorkflowQueueNextBatchOptions
228
- extends WorkflowQueueNextOptions
229
- {
228
+ extends WorkflowQueueNextOptions {
230
229
  /** Maximum number of messages to receive. Defaults to 1. */
231
230
  count?: number;
232
231
  }
@@ -297,6 +296,47 @@ export interface WorkflowError {
297
296
  metadata?: Record<string, unknown>;
298
297
  }
299
298
 
299
+ /**
300
+ * Error event emitted while a workflow is running.
301
+ */
302
+ export interface WorkflowStepErrorEvent {
303
+ workflowId: string;
304
+ stepName: string;
305
+ attempt: number;
306
+ maxRetries: number;
307
+ remainingRetries: number;
308
+ willRetry: boolean;
309
+ retryDelay?: number;
310
+ retryAt?: number;
311
+ error: WorkflowError;
312
+ }
313
+
314
+ /**
315
+ * Error event emitted when a rollback handler fails.
316
+ */
317
+ export interface WorkflowRollbackErrorEvent {
318
+ workflowId: string;
319
+ stepName: string;
320
+ error: WorkflowError;
321
+ }
322
+
323
+ /**
324
+ * Error event emitted for workflow-level failures outside individual steps.
325
+ */
326
+ export interface WorkflowRunErrorEvent {
327
+ workflowId: string;
328
+ error: WorkflowError;
329
+ }
330
+
331
+ export type WorkflowErrorEvent =
332
+ | { step: WorkflowStepErrorEvent }
333
+ | { rollback: WorkflowRollbackErrorEvent }
334
+ | { workflow: WorkflowRunErrorEvent };
335
+
336
+ export type WorkflowErrorHandler = (
337
+ event: WorkflowErrorEvent,
338
+ ) => void | Promise<void>;
339
+
300
340
  /**
301
341
  * Complete storage state for a workflow.
302
342
  */
@@ -384,15 +424,11 @@ export type LoopIterationResult<S, T> = Promise<
384
424
  export interface LoopConfig<S, T> {
385
425
  name: string;
386
426
  state?: S;
387
- run: (
388
- ctx: WorkflowContextInterface,
389
- state: S,
390
- ) => LoopIterationResult<S, T>;
391
- commitInterval?: number;
392
- /** Trim loop history every N iterations. Defaults to commitInterval or 20. */
393
- historyEvery?: number;
394
- /** Retain the last N iterations of history. Defaults to commitInterval or 20. */
395
- historyKeep?: number;
427
+ run: (ctx: WorkflowContextInterface, state: S) => LoopIterationResult<S, T>;
428
+ /** Prune old loop iterations every N iterations. Default: 20. */
429
+ historyPruneInterval?: number;
430
+ /** Number of past iterations to retain when pruning. Defaults to historyPruneInterval. */
431
+ historySize?: number;
396
432
  }
397
433
 
398
434
  /**
@@ -458,6 +494,7 @@ export interface RunWorkflowOptions {
458
494
  mode?: WorkflowRunMode;
459
495
  logger?: Logger;
460
496
  onHistoryUpdated?: (history: WorkflowHistorySnapshot) => void;
497
+ onError?: WorkflowErrorHandler;
461
498
  }
462
499
 
463
500
  export type WorkflowFunction<TInput = unknown, TOutput = unknown> = (