@rivetkit/workflow-engine 0.0.0-feat-workflows-public-host-apis.1550fe4 → 0.0.0-feat-sqlite-sync.18dce02

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/context.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from "pino";
2
- import type { EngineDriver } from "./driver.js";
2
+ import type { EngineDriver, WorkflowSpan } from "./driver.js";
3
3
  import {
4
4
  extractErrorInfo,
5
5
  getErrorEventTag,
@@ -345,6 +345,7 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
345
345
  private abortController: AbortController;
346
346
  private currentLocation: Location;
347
347
  private visitedKeys: Set<string>;
348
+ private runSpan?: WorkflowSpan;
348
349
  private mode: "forward" | "rollback";
349
350
  private rollbackActions?: RollbackAction[];
350
351
  private rollbackCheckpointSet: boolean;
@@ -369,6 +370,7 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
369
370
  onError?: WorkflowErrorHandler,
370
371
  logger?: Logger,
371
372
  visitedKeys?: Set<string>,
373
+ runSpan?: WorkflowSpan,
372
374
  ) {
373
375
  this.currentLocation = location;
374
376
  this.abortController = abortController ?? new AbortController();
@@ -379,6 +381,7 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
379
381
  this.onError = onError;
380
382
  this.logger = logger;
381
383
  this.visitedKeys = visitedKeys ?? new Set();
384
+ this.runSpan = runSpan;
382
385
  }
383
386
 
384
387
  get abortSignal(): AbortSignal {
@@ -435,6 +438,7 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
435
438
  this.onError,
436
439
  this.logger,
437
440
  this.visitedKeys,
441
+ this.runSpan,
438
442
  );
439
443
  }
440
444
 
@@ -906,10 +910,15 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
906
910
  // Get timeout configuration
907
911
  const timeout = config.timeout ?? DEFAULT_STEP_TIMEOUT;
908
912
 
913
+ const stepSpan = this.runSpan?.startStep(
914
+ config.name,
915
+ metadata.attempts,
916
+ );
917
+
909
918
  try {
910
919
  // Execute with timeout
911
920
  const output = await this.executeWithTimeout(
912
- config.run(),
921
+ stepSpan ? stepSpan.run(() => config.run()) : config.run(),
913
922
  timeout,
914
923
  config.name,
915
924
  );
@@ -936,6 +945,7 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
936
945
  await this.flushStorage();
937
946
  }
938
947
 
948
+ stepSpan?.finish("ok");
939
949
  this.log("debug", {
940
950
  msg: "step completed",
941
951
  step: config.name,
@@ -951,6 +961,7 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
951
961
  // Timeout errors are treated as critical by default. Steps opt
952
962
  // into retrying on timeout with retryOnTimeout: true.
953
963
  if (error instanceof StepTimeoutError && !config.retryOnTimeout) {
964
+ stepSpan?.finish("failed", error);
954
965
  metadata.status = "exhausted";
955
966
  metadata.error = String(error);
956
967
  await this.notifyStepError(config, metadata.attempts, error, {
@@ -970,6 +981,7 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
970
981
  error instanceof CriticalError ||
971
982
  error instanceof RollbackError
972
983
  ) {
984
+ stepSpan?.finish("failed", error);
973
985
  metadata.status = "exhausted";
974
986
  metadata.error = String(error);
975
987
  await this.notifyStepError(config, metadata.attempts, error, {
@@ -989,6 +1001,7 @@ export class WorkflowContextImpl implements WorkflowContextInterface {
989
1001
  }
990
1002
 
991
1003
  const willRetry = metadata.attempts <= maxRetries;
1004
+ stepSpan?.finish(willRetry ? "retry" : "failed", error);
992
1005
  metadata.status = willRetry ? "failed" : "exhausted";
993
1006
  metadata.error = String(error);
994
1007
 
package/src/driver.ts CHANGED
@@ -16,6 +16,42 @@ export interface KVWrite {
16
16
  value: Uint8Array;
17
17
  }
18
18
 
19
+ /** How one run of the workflow function ended. */
20
+ export type WorkflowOutcome =
21
+ | "completed"
22
+ | "sleeping"
23
+ | "evicted"
24
+ | "failed"
25
+ | "cancelled";
26
+
27
+ /** How one step attempt ended. `retry` will be tried again, `failed` will not. */
28
+ export type WorkflowStepOutcome = "ok" | "retry" | "failed";
29
+
30
+ /** One open run of the workflow function. */
31
+ export interface WorkflowSpan {
32
+ /** Runs `body` attributed to this run. */
33
+ run<T>(body: () => Promise<T>): Promise<T>;
34
+ /** Returns nothing when the attempt is not being traced. */
35
+ startStep(name: string, attempt: number): WorkflowStepSpan | undefined;
36
+ finish(outcome: WorkflowOutcome): Promise<void>;
37
+ }
38
+
39
+ /** One open attempt at one step. */
40
+ export interface WorkflowStepSpan {
41
+ /** Runs `body` attributed to this attempt. */
42
+ run<T>(body: () => Promise<T>): Promise<T>;
43
+ /** `error` is what the step callback threw. */
44
+ finish(outcome: WorkflowStepOutcome, error?: unknown): void;
45
+ }
46
+
47
+ /**
48
+ * Receives the start and end of each workflow run and step attempt. A
49
+ * completed step replayed from history is not an attempt and is not reported.
50
+ */
51
+ export interface WorkflowTelemetryDriver {
52
+ startSpan(): Promise<WorkflowSpan>;
53
+ }
54
+
19
55
  /**
20
56
  * The engine driver provides the KV and scheduling interface.
21
57
  * Implementations must provide these methods to integrate with different backends.
@@ -54,6 +90,12 @@ export interface EngineDriver {
54
90
  */
55
91
  delete(key: Uint8Array): Promise<void>;
56
92
 
93
+ /**
94
+ * Batch delete multiple keys in a single operation.
95
+ * Should be atomic if possible.
96
+ */
97
+ batchDelete(keys: Uint8Array[]): Promise<void>;
98
+
57
99
  /**
58
100
  * Delete all keys with a given prefix.
59
101
  */
@@ -111,4 +153,7 @@ export interface EngineDriver {
111
153
  messageNames: string[],
112
154
  abortSignal: AbortSignal,
113
155
  ): Promise<void>;
156
+
157
+ /** Present when the host traces workflows. */
158
+ readonly telemetry?: WorkflowTelemetryDriver;
114
159
  }
package/src/index.ts CHANGED
@@ -12,7 +12,16 @@ export {
12
12
  WorkflowContextImpl,
13
13
  } from "./context.js";
14
14
  // Driver
15
- export type { EngineDriver, KVEntry, KVWrite } from "./driver.js";
15
+ export type {
16
+ EngineDriver,
17
+ KVEntry,
18
+ KVWrite,
19
+ WorkflowOutcome,
20
+ WorkflowSpan,
21
+ WorkflowStepOutcome,
22
+ WorkflowStepSpan,
23
+ WorkflowTelemetryDriver,
24
+ } from "./driver.js";
16
25
  export { extractErrorInfo } from "./error-utils.js";
17
26
  // Errors
18
27
  export {
@@ -143,7 +152,7 @@ import {
143
152
  } from "../schemas/serde.js";
144
153
  import { type RollbackAction, WorkflowContextImpl } from "./context.js";
145
154
  // Main workflow runner
146
- import type { EngineDriver } from "./driver.js";
155
+ import type { EngineDriver, WorkflowOutcome, WorkflowSpan } from "./driver.js";
147
156
  import {
148
157
  extractErrorInfo,
149
158
  getErrorEventTag,
@@ -288,6 +297,7 @@ async function executeRollback<TInput, TOutput>(
288
297
  historyNotifier?: HistoryNotifier,
289
298
  onError?: RunWorkflowOptions["onError"],
290
299
  logger?: Logger,
300
+ runSpan?: WorkflowSpan,
291
301
  ): Promise<void> {
292
302
  const rollbackActions: RollbackAction[] = [];
293
303
  const ctx = new WorkflowContextImpl(
@@ -303,6 +313,8 @@ async function executeRollback<TInput, TOutput>(
303
313
  historyNotifier,
304
314
  onError,
305
315
  logger,
316
+ undefined,
317
+ runSpan,
306
318
  );
307
319
 
308
320
  try {
@@ -511,16 +523,19 @@ async function executeLiveWorkflow<TInput, TOutput>(
511
523
  let lastResult: WorkflowResult<TOutput> | undefined;
512
524
 
513
525
  while (true) {
514
- const result = await executeWorkflow(
515
- workflowId,
516
- workflowFn,
517
- input,
518
- driver,
519
- messageDriver,
520
- abortController,
521
- onHistoryUpdated,
522
- onError,
523
- logger,
526
+ const result = await traceRun(driver, (runSpan) =>
527
+ executeWorkflow(
528
+ workflowId,
529
+ workflowFn,
530
+ input,
531
+ driver,
532
+ messageDriver,
533
+ abortController,
534
+ onHistoryUpdated,
535
+ onError,
536
+ logger,
537
+ runSpan,
538
+ ),
524
539
  );
525
540
  lastResult = result;
526
541
 
@@ -642,16 +657,19 @@ export function runWorkflow<TInput, TOutput>(
642
657
  options.onError,
643
658
  logger,
644
659
  )
645
- : executeWorkflow(
646
- workflowId,
647
- workflowFn,
648
- input,
649
- driver,
650
- messageDriver,
651
- abortController,
652
- options.onHistoryUpdated,
653
- options.onError,
654
- logger,
660
+ : traceRun(driver, (runSpan) =>
661
+ executeWorkflow(
662
+ workflowId,
663
+ workflowFn,
664
+ input,
665
+ driver,
666
+ messageDriver,
667
+ abortController,
668
+ options.onHistoryUpdated,
669
+ options.onError,
670
+ logger,
671
+ runSpan,
672
+ ),
655
673
  );
656
674
 
657
675
  return {
@@ -886,6 +904,49 @@ function findReplayBoundaryEntry(
886
904
  return boundary;
887
905
  }
888
906
 
907
+ function runOutcomeFromState(state: WorkflowState): WorkflowOutcome {
908
+ switch (state) {
909
+ case "completed":
910
+ return "completed";
911
+ case "sleeping":
912
+ return "sleeping";
913
+ case "failed":
914
+ return "failed";
915
+ case "cancelled":
916
+ return "cancelled";
917
+ case "pending":
918
+ case "running":
919
+ case "rolling_back":
920
+ return "evicted";
921
+ }
922
+ }
923
+
924
+ /** Internal: Report one workflow run to the driver's telemetry, when it has any. */
925
+ async function traceRun<TOutput>(
926
+ driver: EngineDriver,
927
+ run: (span?: WorkflowSpan) => Promise<WorkflowResult<TOutput>>,
928
+ ): Promise<WorkflowResult<TOutput>> {
929
+ if (!driver.telemetry) {
930
+ return await run();
931
+ }
932
+
933
+ const span = await driver.telemetry.startSpan();
934
+ let outcome: WorkflowOutcome = "failed";
935
+ try {
936
+ const result = await span.run(() => run(span));
937
+ outcome = runOutcomeFromState(result.state);
938
+ return result;
939
+ } catch (error) {
940
+ // A run throws EvictedError only when the workflow was already cancelled.
941
+ if (error instanceof EvictedError) {
942
+ outcome = "cancelled";
943
+ }
944
+ throw error;
945
+ } finally {
946
+ await span.finish(outcome);
947
+ }
948
+ }
949
+
889
950
  /**
890
951
  * Internal: Execute the workflow and return the result.
891
952
  */
@@ -899,6 +960,7 @@ async function executeWorkflow<TInput, TOutput>(
899
960
  onHistoryUpdated?: (history: WorkflowHistorySnapshot) => void,
900
961
  onError?: RunWorkflowOptions["onError"],
901
962
  logger?: Logger,
963
+ runSpan?: WorkflowSpan,
902
964
  ): Promise<WorkflowResult<TOutput>> {
903
965
  const storage = await loadStorage(driver);
904
966
  const historyNotifier: HistoryNotifier = onHistoryUpdated
@@ -953,6 +1015,7 @@ async function executeWorkflow<TInput, TOutput>(
953
1015
  historyNotifier,
954
1016
  onError,
955
1017
  logger,
1018
+ runSpan,
956
1019
  );
957
1020
  } catch (error) {
958
1021
  if (error instanceof EvictedError) {
@@ -986,6 +1049,8 @@ async function executeWorkflow<TInput, TOutput>(
986
1049
  historyNotifier,
987
1050
  onError,
988
1051
  logger,
1052
+ undefined,
1053
+ runSpan,
989
1054
  );
990
1055
 
991
1056
  storage.state = "running";
@@ -1064,6 +1129,7 @@ async function executeWorkflow<TInput, TOutput>(
1064
1129
  historyNotifier,
1065
1130
  onError,
1066
1131
  logger,
1132
+ runSpan,
1067
1133
  );
1068
1134
  } catch (rollbackError) {
1069
1135
  if (rollbackError instanceof EvictedError) {
package/src/keys.ts CHANGED
@@ -14,6 +14,7 @@ export const KEY_PREFIX = {
14
14
  HISTORY: 2, // History entries: [2, ...locationSegments]
15
15
  WORKFLOW: 3, // Workflow metadata: [3, field]
16
16
  ENTRY_METADATA: 4, // Entry metadata: [4, entryId]
17
+ // 5 is reserved for RivetKit's workflow trace context.
17
18
  } as const;
18
19
 
19
20
  // Workflow metadata field identifiers
package/src/storage.ts CHANGED
@@ -43,6 +43,9 @@ import type {
43
43
  export const MAX_KV_BATCH_ENTRIES = 128;
44
44
  export const MAX_KV_BATCH_PAYLOAD_BYTES = 976 * 1024;
45
45
 
46
+ /** Max delete ops (one transaction/permit each) run at once, under the 128-permit cap. */
47
+ export const MAX_CONCURRENT_DELETES = 64;
48
+
46
49
  /**
47
50
  * Create an empty storage instance.
48
51
  */
@@ -330,18 +333,8 @@ export async function flush(
330
333
  // Apply pending deletions after the batch write. These are collected
331
334
  // by collectLoopPruning so pruning happens alongside the state write.
332
335
  if (pendingDeletions) {
333
- const deleteOps: Promise<void>[] = [];
334
- for (const prefix of pendingDeletions.prefixes) {
335
- deleteOps.push(driver.deletePrefix(prefix));
336
- }
337
- for (const range of pendingDeletions.ranges) {
338
- deleteOps.push(driver.deleteRange(range.start, range.end));
339
- }
340
- for (const key of pendingDeletions.keys) {
341
- deleteOps.push(driver.delete(key));
342
- }
343
- if (deleteOps.length > 0) {
344
- await Promise.all(deleteOps);
336
+ const didChange = await runDeletes(driver, pendingDeletions);
337
+ if (didChange) {
345
338
  historyUpdated = true;
346
339
  }
347
340
  }
@@ -397,6 +390,46 @@ function splitBatchWrites(writes: KVWrite[]): KVWrite[][] {
397
390
  return chunks;
398
391
  }
399
392
 
393
+ /**
394
+ * Split delete keys into batches within one KV transaction (MAX_KV_BATCH_ENTRIES).
395
+ */
396
+ function splitBatchDeletes(keys: Uint8Array[]): Uint8Array[][] {
397
+ const chunks: Uint8Array[][] = [];
398
+ for (let i = 0; i < keys.length; i += MAX_KV_BATCH_ENTRIES) {
399
+ chunks.push(keys.slice(i, i + MAX_KV_BATCH_ENTRIES));
400
+ }
401
+ return chunks;
402
+ }
403
+
404
+ /**
405
+ * Apply deletions concurrently in bounded rounds; returns whether anything was deleted.
406
+ */
407
+ async function runDeletes(
408
+ driver: EngineDriver,
409
+ deletions: PendingDeletions,
410
+ ): Promise<boolean> {
411
+ const ops = [
412
+ ...deletions.prefixes.map(
413
+ (prefix) => () => driver.deletePrefix(prefix),
414
+ ),
415
+ ...deletions.ranges.map(
416
+ (range) => () => driver.deleteRange(range.start, range.end),
417
+ ),
418
+ ...splitBatchDeletes(deletions.keys).map(
419
+ (chunk) => () => driver.batchDelete(chunk),
420
+ ),
421
+ ];
422
+ if (ops.length === 0) {
423
+ return false;
424
+ }
425
+ for (let i = 0; i < ops.length; i += MAX_CONCURRENT_DELETES) {
426
+ await Promise.all(
427
+ ops.slice(i, i + MAX_CONCURRENT_DELETES).map((op) => op()),
428
+ );
429
+ }
430
+ return true;
431
+ }
432
+
400
433
  /**
401
434
  * Delete entries with a given location prefix (used for loop forgetting).
402
435
  * Also cleans up associated metadata from both memory and driver.
@@ -410,8 +443,7 @@ export async function deleteEntriesWithPrefix(
410
443
  const deletions = collectDeletionsForPrefix(storage, prefixLocation);
411
444
 
412
445
  // Apply deletions to driver
413
- await driver.deletePrefix(deletions.prefixes[0]!);
414
- await Promise.all(deletions.keys.map((key) => driver.delete(key)));
446
+ await runDeletes(driver, deletions);
415
447
 
416
448
  if (deletions.keys.length > 0 && onHistoryUpdated) {
417
449
  onHistoryUpdated();
package/src/testing.ts CHANGED
@@ -173,6 +173,13 @@ export class InMemoryDriver implements EngineDriver {
173
173
  this.kv.delete(keyToHex(key));
174
174
  }
175
175
 
176
+ async batchDelete(keys: Uint8Array[]): Promise<void> {
177
+ await sleep(this.latency);
178
+ for (const key of keys) {
179
+ this.kv.delete(keyToHex(key));
180
+ }
181
+ }
182
+
176
183
  async deletePrefix(prefix: Uint8Array): Promise<void> {
177
184
  await sleep(this.latency);
178
185
  for (const [hexKey, entry] of this.kv) {