@rivetkit/workflow-engine 0.0.0-0-0-0-preview-guard-stops.9d82529

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 ADDED
@@ -0,0 +1,468 @@
1
+ import {
2
+ deserializeEntry,
3
+ deserializeEntryMetadata,
4
+ deserializeName,
5
+ deserializeWorkflowError,
6
+ deserializeWorkflowOutput,
7
+ deserializeWorkflowState,
8
+ serializeEntry,
9
+ serializeEntryMetadata,
10
+ serializeName,
11
+ serializeWorkflowError,
12
+ serializeWorkflowOutput,
13
+ serializeWorkflowState,
14
+ } from "../schemas/serde.js";
15
+ import type { EngineDriver, KVWrite } from "./driver.js";
16
+ import {
17
+ buildEntryMetadataKey,
18
+ buildEntryMetadataPrefix,
19
+ buildHistoryKey,
20
+ buildHistoryPrefix,
21
+ buildHistoryPrefixAll,
22
+ buildNameKey,
23
+ buildNamePrefix,
24
+ buildWorkflowErrorKey,
25
+ buildWorkflowOutputKey,
26
+ buildWorkflowStateKey,
27
+ compareKeys,
28
+ parseEntryMetadataKey,
29
+ parseNameKey,
30
+ } from "./keys.js";
31
+ import { isLocationPrefix, locationToKey } from "./location.js";
32
+ import type {
33
+ Entry,
34
+ EntryKind,
35
+ EntryMetadata,
36
+ Location,
37
+ Storage,
38
+ WorkflowEntryMetadataSnapshot,
39
+ WorkflowHistoryEntry,
40
+ WorkflowHistorySnapshot,
41
+ } from "./types.js";
42
+
43
+ export const MAX_KV_BATCH_ENTRIES = 128;
44
+ export const MAX_KV_BATCH_PAYLOAD_BYTES = 976 * 1024;
45
+
46
+ /**
47
+ * Create an empty storage instance.
48
+ */
49
+ export function createStorage(): Storage {
50
+ return {
51
+ nameRegistry: [],
52
+ flushedNameCount: 0,
53
+ history: { entries: new Map() },
54
+ entryMetadata: new Map(),
55
+ output: undefined,
56
+ state: "pending",
57
+ flushedState: undefined,
58
+ error: undefined,
59
+ flushedError: undefined,
60
+ flushedOutput: undefined,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Create a snapshot of workflow history for observers.
66
+ */
67
+ export function createHistorySnapshot(
68
+ storage: Storage,
69
+ ): WorkflowHistorySnapshot {
70
+ const entryMetadata = new Map<string, WorkflowEntryMetadataSnapshot>();
71
+ for (const [id, metadata] of storage.entryMetadata) {
72
+ const { dirty, ...rest } = metadata;
73
+ entryMetadata.set(id, rest);
74
+ }
75
+
76
+ const entries: WorkflowHistoryEntry[] = [];
77
+ const entryKeys = Array.from(storage.history.entries.keys()).sort();
78
+ for (const key of entryKeys) {
79
+ const entry = storage.history.entries.get(key);
80
+ if (!entry) continue;
81
+ const { dirty, ...rest } = entry;
82
+ entries.push(rest);
83
+ }
84
+
85
+ return {
86
+ nameRegistry: [...storage.nameRegistry],
87
+ entries,
88
+ entryMetadata,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Generate a UUID v4.
94
+ */
95
+ export function generateId(): string {
96
+ return crypto.randomUUID();
97
+ }
98
+
99
+ /**
100
+ * Create a new entry.
101
+ */
102
+ export function createEntry(location: Location, kind: EntryKind): Entry {
103
+ return {
104
+ id: generateId(),
105
+ location,
106
+ kind,
107
+ dirty: true,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Create or get metadata for an entry.
113
+ */
114
+ export function getOrCreateMetadata(
115
+ storage: Storage,
116
+ entryId: string,
117
+ ): EntryMetadata {
118
+ let metadata = storage.entryMetadata.get(entryId);
119
+ if (!metadata) {
120
+ metadata = {
121
+ status: "pending",
122
+ attempts: 0,
123
+ lastAttemptAt: 0,
124
+ createdAt: Date.now(),
125
+ rollbackCompletedAt: undefined,
126
+ rollbackError: undefined,
127
+ dirty: true,
128
+ };
129
+ storage.entryMetadata.set(entryId, metadata);
130
+ }
131
+ return metadata;
132
+ }
133
+
134
+ /**
135
+ * Load storage from the driver.
136
+ */
137
+ export async function loadStorage(driver: EngineDriver): Promise<Storage> {
138
+ const storage = createStorage();
139
+
140
+ // Load name registry
141
+ const nameEntries = await driver.list(buildNamePrefix());
142
+ // Sort by index to ensure correct order
143
+ nameEntries.sort((a, b) => compareKeys(a.key, b.key));
144
+ for (const entry of nameEntries) {
145
+ const index = parseNameKey(entry.key);
146
+ storage.nameRegistry[index] = deserializeName(entry.value);
147
+ }
148
+ // Track how many names are already persisted
149
+ storage.flushedNameCount = storage.nameRegistry.length;
150
+
151
+ // Load history entries
152
+ const historyEntries = await driver.list(buildHistoryPrefixAll());
153
+ for (const entry of historyEntries) {
154
+ const parsed = deserializeEntry(entry.value);
155
+ parsed.dirty = false;
156
+ // Use locationToKey to match how context.ts looks up entries
157
+ const key = locationToKey(storage, parsed.location);
158
+ storage.history.entries.set(key, parsed);
159
+ }
160
+
161
+ // Load entry metadata so observers can reconstruct workflow state after
162
+ // the actor wakes and rebuilds storage from persisted history.
163
+ const metadataEntries = await driver.list(buildEntryMetadataPrefix());
164
+ for (const entry of metadataEntries) {
165
+ const entryId = parseEntryMetadataKey(entry.key);
166
+ const metadata = deserializeEntryMetadata(entry.value);
167
+ metadata.dirty = false;
168
+ storage.entryMetadata.set(entryId, metadata);
169
+ }
170
+
171
+ // Load workflow state
172
+ const stateValue = await driver.get(buildWorkflowStateKey());
173
+ if (stateValue) {
174
+ storage.state = deserializeWorkflowState(stateValue);
175
+ storage.flushedState = storage.state;
176
+ }
177
+
178
+ // Load output if present
179
+ const outputValue = await driver.get(buildWorkflowOutputKey());
180
+ if (outputValue) {
181
+ storage.output = deserializeWorkflowOutput(outputValue);
182
+ storage.flushedOutput = storage.output;
183
+ }
184
+
185
+ // Load error if present
186
+ const errorValue = await driver.get(buildWorkflowErrorKey());
187
+ if (errorValue) {
188
+ storage.error = deserializeWorkflowError(errorValue);
189
+ storage.flushedError = storage.error;
190
+ }
191
+
192
+ return storage;
193
+ }
194
+
195
+ /**
196
+ * Load metadata for an entry (lazy loading).
197
+ */
198
+ export async function loadMetadata(
199
+ storage: Storage,
200
+ driver: EngineDriver,
201
+ entryId: string,
202
+ ): Promise<EntryMetadata> {
203
+ // Check if already loaded
204
+ const existing = storage.entryMetadata.get(entryId);
205
+ if (existing) {
206
+ return existing;
207
+ }
208
+
209
+ // Load from driver
210
+ const value = await driver.get(buildEntryMetadataKey(entryId));
211
+ if (value) {
212
+ const metadata = deserializeEntryMetadata(value);
213
+ metadata.dirty = false;
214
+ storage.entryMetadata.set(entryId, metadata);
215
+ return metadata;
216
+ }
217
+
218
+ // Create new metadata
219
+ return getOrCreateMetadata(storage, entryId);
220
+ }
221
+
222
+ /**
223
+ * Pending deletions collected by collectLoopPruning to be included
224
+ * in the next flush alongside the state write.
225
+ */
226
+ export interface PendingDeletions {
227
+ prefixes: Uint8Array[];
228
+ keys: Uint8Array[];
229
+ ranges: { start: Uint8Array; end: Uint8Array }[];
230
+ }
231
+
232
+ /**
233
+ * Flush all dirty data to the driver. Optionally includes pending
234
+ * deletions so that history pruning happens alongside the
235
+ * state write.
236
+ */
237
+ export async function flush(
238
+ storage: Storage,
239
+ driver: EngineDriver,
240
+ onHistoryUpdated?: () => void,
241
+ pendingDeletions?: PendingDeletions,
242
+ ): Promise<void> {
243
+ const writes: KVWrite[] = [];
244
+ const dirtyEntries: Entry[] = [];
245
+ const dirtyMetadata: EntryMetadata[] = [];
246
+ let historyUpdated = false;
247
+
248
+ // Flush only new names (those added since last flush)
249
+ for (
250
+ let i = storage.flushedNameCount;
251
+ i < storage.nameRegistry.length;
252
+ i++
253
+ ) {
254
+ const name = storage.nameRegistry[i];
255
+ if (name !== undefined) {
256
+ writes.push({
257
+ key: buildNameKey(i),
258
+ value: serializeName(name),
259
+ });
260
+ historyUpdated = true;
261
+ }
262
+ }
263
+
264
+ // Flush dirty entries
265
+ for (const [, entry] of storage.history.entries) {
266
+ if (entry.dirty) {
267
+ writes.push({
268
+ key: buildHistoryKey(entry.location),
269
+ value: serializeEntry(entry),
270
+ });
271
+ dirtyEntries.push(entry);
272
+ historyUpdated = true;
273
+ }
274
+ }
275
+
276
+ // Flush dirty metadata
277
+ for (const [id, metadata] of storage.entryMetadata) {
278
+ if (metadata.dirty) {
279
+ writes.push({
280
+ key: buildEntryMetadataKey(id),
281
+ value: serializeEntryMetadata(metadata),
282
+ });
283
+ dirtyMetadata.push(metadata);
284
+ historyUpdated = true;
285
+ }
286
+ }
287
+
288
+ // Flush workflow state if changed
289
+ if (storage.state !== storage.flushedState) {
290
+ writes.push({
291
+ key: buildWorkflowStateKey(),
292
+ value: serializeWorkflowState(storage.state),
293
+ });
294
+ }
295
+
296
+ // Flush output if changed
297
+ if (
298
+ storage.output !== undefined &&
299
+ storage.output !== storage.flushedOutput
300
+ ) {
301
+ writes.push({
302
+ key: buildWorkflowOutputKey(),
303
+ value: serializeWorkflowOutput(storage.output),
304
+ });
305
+ }
306
+
307
+ // Flush error if changed (compare by message since objects aren't reference-equal)
308
+ const errorChanged =
309
+ storage.error !== undefined &&
310
+ (storage.flushedError === undefined ||
311
+ storage.error.name !== storage.flushedError.name ||
312
+ storage.error.message !== storage.flushedError.message);
313
+ if (errorChanged) {
314
+ writes.push({
315
+ key: buildWorkflowErrorKey(),
316
+ value: serializeWorkflowError(storage.error!),
317
+ });
318
+ }
319
+
320
+ if (writes.length > 0) {
321
+ if (driver.atomicBatch) {
322
+ await driver.batch(writes);
323
+ } else {
324
+ for (const chunk of splitBatchWrites(writes)) {
325
+ await driver.batch(chunk);
326
+ }
327
+ }
328
+ }
329
+
330
+ // Apply pending deletions after the batch write. These are collected
331
+ // by collectLoopPruning so pruning happens alongside the state write.
332
+ 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);
345
+ historyUpdated = true;
346
+ }
347
+ }
348
+
349
+ // Update flushed tracking after successful write
350
+ for (const entry of dirtyEntries) {
351
+ entry.dirty = false;
352
+ }
353
+ for (const metadata of dirtyMetadata) {
354
+ metadata.dirty = false;
355
+ }
356
+ storage.flushedNameCount = storage.nameRegistry.length;
357
+ storage.flushedState = storage.state;
358
+ storage.flushedOutput = storage.output;
359
+ storage.flushedError = storage.error;
360
+
361
+ if (historyUpdated && onHistoryUpdated) {
362
+ onHistoryUpdated();
363
+ }
364
+ }
365
+
366
+ function splitBatchWrites(writes: KVWrite[]): KVWrite[][] {
367
+ const chunks: KVWrite[][] = [];
368
+ let chunk: KVWrite[] = [];
369
+ let chunkBytes = 0;
370
+
371
+ for (const write of writes) {
372
+ const writeBytes = write.key.byteLength + write.value.byteLength;
373
+ if (writeBytes > MAX_KV_BATCH_PAYLOAD_BYTES) {
374
+ throw new Error(
375
+ `KV batch write is ${writeBytes} bytes, exceeding the ${MAX_KV_BATCH_PAYLOAD_BYTES} byte limit`,
376
+ );
377
+ }
378
+
379
+ if (
380
+ chunk.length >= MAX_KV_BATCH_ENTRIES ||
381
+ (chunk.length > 0 &&
382
+ chunkBytes + writeBytes > MAX_KV_BATCH_PAYLOAD_BYTES)
383
+ ) {
384
+ chunks.push(chunk);
385
+ chunk = [];
386
+ chunkBytes = 0;
387
+ }
388
+
389
+ chunk.push(write);
390
+ chunkBytes += writeBytes;
391
+ }
392
+
393
+ if (chunk.length > 0) {
394
+ chunks.push(chunk);
395
+ }
396
+
397
+ return chunks;
398
+ }
399
+
400
+ /**
401
+ * Delete entries with a given location prefix (used for loop forgetting).
402
+ * Also cleans up associated metadata from both memory and driver.
403
+ */
404
+ export async function deleteEntriesWithPrefix(
405
+ storage: Storage,
406
+ driver: EngineDriver,
407
+ prefixLocation: Location,
408
+ onHistoryUpdated?: () => void,
409
+ ): Promise<void> {
410
+ const deletions = collectDeletionsForPrefix(storage, prefixLocation);
411
+
412
+ // Apply deletions to driver
413
+ await driver.deletePrefix(deletions.prefixes[0]!);
414
+ await Promise.all(deletions.keys.map((key) => driver.delete(key)));
415
+
416
+ if (deletions.keys.length > 0 && onHistoryUpdated) {
417
+ onHistoryUpdated();
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Remove entries matching a location prefix from memory and collect
423
+ * the driver-level deletion operations. The returned PendingDeletions
424
+ * can be applied immediately or batched with a flush.
425
+ */
426
+ export function collectDeletionsForPrefix(
427
+ storage: Storage,
428
+ prefixLocation: Location,
429
+ ): PendingDeletions {
430
+ const pending: PendingDeletions = {
431
+ prefixes: [buildHistoryPrefix(prefixLocation)],
432
+ keys: [],
433
+ ranges: [],
434
+ };
435
+
436
+ for (const [key, entry] of storage.history.entries) {
437
+ if (isLocationPrefix(prefixLocation, entry.location)) {
438
+ pending.keys.push(buildEntryMetadataKey(entry.id));
439
+ storage.entryMetadata.delete(entry.id);
440
+ storage.history.entries.delete(key);
441
+ }
442
+ }
443
+
444
+ return pending;
445
+ }
446
+
447
+ /**
448
+ * Get an entry by location.
449
+ */
450
+ export function getEntry(
451
+ storage: Storage,
452
+ location: Location,
453
+ ): Entry | undefined {
454
+ const key = locationToKey(storage, location);
455
+ return storage.history.entries.get(key);
456
+ }
457
+
458
+ /**
459
+ * Set an entry by location.
460
+ */
461
+ export function setEntry(
462
+ storage: Storage,
463
+ location: Location,
464
+ entry: Entry,
465
+ ): void {
466
+ const key = locationToKey(storage, location);
467
+ storage.history.entries.set(key, entry);
468
+ }