@rivetkit/workflow-engine 0.0.0-main.6151ab0 → 0.0.0-main.78336a1

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
@@ -40,6 +40,12 @@ import type {
40
40
  WorkflowHistorySnapshot,
41
41
  } from "./types.js";
42
42
 
43
+ export const MAX_KV_BATCH_ENTRIES = 128;
44
+ export const MAX_KV_BATCH_PAYLOAD_BYTES = 976 * 1024;
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
+
43
49
  /**
44
50
  * Create an empty storage instance.
45
51
  */
@@ -238,6 +244,8 @@ export async function flush(
238
244
  pendingDeletions?: PendingDeletions,
239
245
  ): Promise<void> {
240
246
  const writes: KVWrite[] = [];
247
+ const dirtyEntries: Entry[] = [];
248
+ const dirtyMetadata: EntryMetadata[] = [];
241
249
  let historyUpdated = false;
242
250
 
243
251
  // Flush only new names (those added since last flush)
@@ -263,7 +271,7 @@ export async function flush(
263
271
  key: buildHistoryKey(entry.location),
264
272
  value: serializeEntry(entry),
265
273
  });
266
- entry.dirty = false;
274
+ dirtyEntries.push(entry);
267
275
  historyUpdated = true;
268
276
  }
269
277
  }
@@ -275,7 +283,7 @@ export async function flush(
275
283
  key: buildEntryMetadataKey(id),
276
284
  value: serializeEntryMetadata(metadata),
277
285
  });
278
- metadata.dirty = false;
286
+ dirtyMetadata.push(metadata);
279
287
  historyUpdated = true;
280
288
  }
281
289
  }
@@ -313,29 +321,31 @@ export async function flush(
313
321
  }
314
322
 
315
323
  if (writes.length > 0) {
316
- await driver.batch(writes);
324
+ if (driver.atomicBatch) {
325
+ await driver.batch(writes);
326
+ } else {
327
+ for (const chunk of splitBatchWrites(writes)) {
328
+ await driver.batch(chunk);
329
+ }
330
+ }
317
331
  }
318
332
 
319
333
  // Apply pending deletions after the batch write. These are collected
320
334
  // by collectLoopPruning so pruning happens alongside the state write.
321
335
  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);
336
+ const didChange = await runDeletes(driver, pendingDeletions);
337
+ if (didChange) {
334
338
  historyUpdated = true;
335
339
  }
336
340
  }
337
341
 
338
342
  // Update flushed tracking after successful write
343
+ for (const entry of dirtyEntries) {
344
+ entry.dirty = false;
345
+ }
346
+ for (const metadata of dirtyMetadata) {
347
+ metadata.dirty = false;
348
+ }
339
349
  storage.flushedNameCount = storage.nameRegistry.length;
340
350
  storage.flushedState = storage.state;
341
351
  storage.flushedOutput = storage.output;
@@ -346,6 +356,78 @@ export async function flush(
346
356
  }
347
357
  }
348
358
 
359
+ function splitBatchWrites(writes: KVWrite[]): KVWrite[][] {
360
+ const chunks: KVWrite[][] = [];
361
+ let chunk: KVWrite[] = [];
362
+ let chunkBytes = 0;
363
+
364
+ for (const write of writes) {
365
+ const writeBytes = write.key.byteLength + write.value.byteLength;
366
+ if (writeBytes > MAX_KV_BATCH_PAYLOAD_BYTES) {
367
+ throw new Error(
368
+ `KV batch write is ${writeBytes} bytes, exceeding the ${MAX_KV_BATCH_PAYLOAD_BYTES} byte limit`,
369
+ );
370
+ }
371
+
372
+ if (
373
+ chunk.length >= MAX_KV_BATCH_ENTRIES ||
374
+ (chunk.length > 0 &&
375
+ chunkBytes + writeBytes > MAX_KV_BATCH_PAYLOAD_BYTES)
376
+ ) {
377
+ chunks.push(chunk);
378
+ chunk = [];
379
+ chunkBytes = 0;
380
+ }
381
+
382
+ chunk.push(write);
383
+ chunkBytes += writeBytes;
384
+ }
385
+
386
+ if (chunk.length > 0) {
387
+ chunks.push(chunk);
388
+ }
389
+
390
+ return chunks;
391
+ }
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((prefix) => () => driver.deletePrefix(prefix)),
413
+ ...deletions.ranges.map(
414
+ (range) => () => driver.deleteRange(range.start, range.end),
415
+ ),
416
+ ...splitBatchDeletes(deletions.keys).map(
417
+ (chunk) => () => driver.batchDelete(chunk),
418
+ ),
419
+ ];
420
+ if (ops.length === 0) {
421
+ return false;
422
+ }
423
+ for (let i = 0; i < ops.length; i += MAX_CONCURRENT_DELETES) {
424
+ await Promise.all(
425
+ ops.slice(i, i + MAX_CONCURRENT_DELETES).map((op) => op()),
426
+ );
427
+ }
428
+ return true;
429
+ }
430
+
349
431
  /**
350
432
  * Delete entries with a given location prefix (used for loop forgetting).
351
433
  * Also cleans up associated metadata from both memory and driver.
@@ -359,8 +441,7 @@ export async function deleteEntriesWithPrefix(
359
441
  const deletions = collectDeletionsForPrefix(storage, prefixLocation);
360
442
 
361
443
  // Apply deletions to driver
362
- await driver.deletePrefix(deletions.prefixes[0]!);
363
- await Promise.all(deletions.keys.map((key) => driver.delete(key)));
444
+ await runDeletes(driver, deletions);
364
445
 
365
446
  if (deletions.keys.length > 0 && onHistoryUpdated) {
366
447
  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) {
package/src/types.ts CHANGED
@@ -135,6 +135,17 @@ export interface RemovedEntry {
135
135
  originalName?: string;
136
136
  }
137
137
 
138
+ /**
139
+ * Version check entry data - records which version of a code path this
140
+ * workflow instance is pinned to at a given location.
141
+ */
142
+ export interface VersionCheckEntry {
143
+ /** The version this instance resolved to at this location. */
144
+ resolved: number;
145
+ /** The `latest` value seen when this entry was first resolved (diagnostics). */
146
+ latest: number;
147
+ }
148
+
138
149
  /**
139
150
  * All possible entry kind types.
140
151
  */
@@ -146,7 +157,8 @@ export type EntryKindType =
146
157
  | "rollback_checkpoint"
147
158
  | "join"
148
159
  | "race"
149
- | "removed";
160
+ | "removed"
161
+ | "version_check";
150
162
 
151
163
  /**
152
164
  * Type-specific entry data.
@@ -159,7 +171,8 @@ export type EntryKind =
159
171
  | { type: "rollback_checkpoint"; data: RollbackCheckpointEntry }
160
172
  | { type: "join"; data: JoinEntry }
161
173
  | { type: "race"; data: RaceEntry }
162
- | { type: "removed"; data: RemovedEntry };
174
+ | { type: "removed"; data: RemovedEntry }
175
+ | { type: "version_check"; data: VersionCheckEntry };
163
176
 
164
177
  /**
165
178
  * An entry in the workflow history.
@@ -399,6 +412,8 @@ export interface StepConfig<T> {
399
412
  retryBackoffMax?: number;
400
413
  /** Timeout in ms for step execution (default: 30000). Set to 0 to disable. */
401
414
  timeout?: number;
415
+ /** If true, step timeouts retry like any other error instead of failing immediately as critical. Default: false. */
416
+ retryOnTimeout?: boolean;
402
417
  }
403
418
 
404
419
  export type TryStepCatchKind =
@@ -422,11 +437,7 @@ export interface TryStepConfig<T> extends StepConfig<T> {
422
437
  catch?: readonly TryStepCatchKind[];
423
438
  }
424
439
 
425
- export type TryBlockCatchKind =
426
- | "step"
427
- | "join"
428
- | "race"
429
- | "rollback";
440
+ export type TryBlockCatchKind = "step" | "join" | "race" | "rollback";
430
441
 
431
442
  export interface TryBlockFailure {
432
443
  source: "step" | "join" | "race" | "block";
@@ -459,7 +470,7 @@ export type LoopResult<S, T> =
459
470
  * `Loop.continue(undefined)`.
460
471
  */
461
472
  export type LoopIterationResult<S, T> = Promise<
462
- LoopResult<S, T> | (S extends undefined ? void : never)
473
+ LoopResult<S, T> | (S extends undefined ? undefined : never)
463
474
  >;
464
475
 
465
476
  /**
@@ -473,6 +484,12 @@ export interface LoopConfig<S, T> {
473
484
  historyPruneInterval?: number;
474
485
  /** Number of past iterations to retain when pruning. Defaults to historyPruneInterval. */
475
486
  historySize?: number;
487
+ /** @deprecated Use historyPruneInterval. */
488
+ commitInterval?: number;
489
+ /** @deprecated Use historyPruneInterval. */
490
+ historyEvery?: number;
491
+ /** @deprecated Use historySize. */
492
+ historyKeep?: number;
476
493
  }
477
494
 
478
495
  /**
@@ -535,6 +552,8 @@ export interface WorkflowContextInterface {
535
552
 
536
553
  removed(name: string, originalType: EntryKindType): Promise<void>;
537
554
 
555
+ getVersion(name: string, latest: number): Promise<number>;
556
+
538
557
  isEvicted(): boolean;
539
558
  }
540
559