Skip to content

Commit f051a22

Browse files
abcxffclaude
andcommitted
fix(workflows): clear pruned loop history via batched deletes
Loop pruning deleted each metadata key with its own driver.delete via an unbounded Promise.all. Once a prune exceeded ~128 entries the fan-out overran the actor SQLite coordinator's 128-permit admission cap and failed long-lived ctx.loop workflows with 'SQLite transaction queue is full'. Route pruning through batchDelete, chunking keys at MAX_KV_BATCH_ENTRIES (the engine's KV_TX_MAX_ROWS per-commit cap) and running prefix/range sweeps and key batches concurrently, so permit cost drops from N to ceil(N/128). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 220df0e commit f051a22

2 files changed

Lines changed: 141 additions & 14 deletions

File tree

packages/workflows/src/storage.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ import type {
4343
export const MAX_KV_BATCH_ENTRIES = 128;
4444
export const MAX_KV_BATCH_PAYLOAD_BYTES = 976 * 1024;
4545

46+
/** Max delete ops (one transaction/permit each) run at once, under the 128-permit cap. */
47+
export const MAX_CONCURRENT_DELETES = 64;
48+
4649
/**
4750
* Create an empty storage instance.
4851
*/
@@ -330,18 +333,8 @@ export async function flush(
330333
// Apply pending deletions after the batch write. These are collected
331334
// by collectLoopPruning so pruning happens alongside the state write.
332335
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) {
345338
historyUpdated = true;
346339
}
347340
}
@@ -397,6 +390,44 @@ function splitBatchWrites(writes: KVWrite[]): KVWrite[][] {
397390
return chunks;
398391
}
399392

393+
/**
394+
* Split delete keys into batches within one KV transaction (KV_TX_MAX_ROWS).
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+
400431
/**
401432
* Delete entries with a given location prefix (used for loop forgetting).
402433
* Also cleans up associated metadata from both memory and driver.
@@ -410,8 +441,7 @@ export async function deleteEntriesWithPrefix(
410441
const deletions = collectDeletionsForPrefix(storage, prefixLocation);
411442

412443
// Apply deletions to driver
413-
await driver.deletePrefix(deletions.prefixes[0]!);
414-
await Promise.all(deletions.keys.map((key) => driver.delete(key)));
444+
await runDeletes(driver, deletions);
415445

416446
if (deletions.keys.length > 0 && onHistoryUpdated) {
417447
onHistoryUpdated();

packages/workflows/tests/storage.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { beforeEach, describe, expect, it } from "vitest";
22
import type { KVWrite } from "../src/driver.js";
33
import {
4+
deleteEntriesWithPrefix,
5+
MAX_CONCURRENT_DELETES,
46
MAX_KV_BATCH_ENTRIES,
57
MAX_KV_BATCH_PAYLOAD_BYTES,
68
} from "../src/storage.js";
@@ -201,3 +203,98 @@ describe("Workflow Engine Storage flush", () => {
201203
expect(metadata.dirty).toBe(false);
202204
});
203205
});
206+
207+
describe("Workflow Engine Storage delete fan-out", () => {
208+
// Records batchDelete sizes to assert keys are coalesced, not deleted one-by-one.
209+
class BatchDeleteRecordingDriver extends InMemoryDriver {
210+
batchSizes: number[] = [];
211+
singleDeletes = 0;
212+
213+
override async batchDelete(keys: Uint8Array[]): Promise<void> {
214+
this.batchSizes.push(keys.length);
215+
await super.batchDelete(keys);
216+
}
217+
218+
override async delete(key: Uint8Array): Promise<void> {
219+
this.singleDeletes++;
220+
await super.delete(key);
221+
}
222+
}
223+
224+
it("clears a large history prefix in transaction-sized delete batches", async () => {
225+
const driver = new BatchDeleteRecordingDriver();
226+
driver.latency = 1;
227+
const storage = createStorage();
228+
const loopLocation = appendName(storage, emptyLocation(), "loop");
229+
230+
// Span several batches so chunking is exercised.
231+
const entryCount = MAX_KV_BATCH_ENTRIES * 3 + 7;
232+
for (let i = 0; i < entryCount; i++) {
233+
const location = appendName(storage, loopLocation, `iter-${i}`);
234+
const entry = createEntry(location, {
235+
type: "step",
236+
data: { output: i },
237+
});
238+
setEntry(storage, location, entry);
239+
}
240+
241+
await deleteEntriesWithPrefix(storage, driver, loopLocation);
242+
243+
// All keys deleted via transaction-sized batches, no per-key fan-out.
244+
expect(driver.singleDeletes).toBe(0);
245+
expect(driver.batchSizes).toHaveLength(Math.ceil(entryCount / MAX_KV_BATCH_ENTRIES));
246+
for (const size of driver.batchSizes) {
247+
expect(size).toBeLessThanOrEqual(MAX_KV_BATCH_ENTRIES);
248+
}
249+
expect(driver.batchSizes.reduce((a, b) => a + b, 0)).toBe(entryCount);
250+
expect(storage.history.entries.size).toBe(0);
251+
});
252+
253+
// Tracks concurrent delete ops so the test can assert the fan-out stays bounded.
254+
class ConcurrencyTrackingDriver extends InMemoryDriver {
255+
inFlight = 0;
256+
peakInFlight = 0;
257+
258+
async #track<T>(op: Promise<T>): Promise<T> {
259+
this.inFlight++;
260+
this.peakInFlight = Math.max(this.peakInFlight, this.inFlight);
261+
try {
262+
return await op;
263+
} finally {
264+
this.inFlight--;
265+
}
266+
}
267+
268+
override batchDelete(keys: Uint8Array[]): Promise<void> {
269+
return this.#track(super.batchDelete(keys));
270+
}
271+
272+
override deletePrefix(prefix: Uint8Array): Promise<void> {
273+
return this.#track(super.deletePrefix(prefix));
274+
}
275+
}
276+
277+
it("bounds concurrent delete ops for a prune larger than the cap", async () => {
278+
const driver = new ConcurrencyTrackingDriver();
279+
driver.latency = 1;
280+
const storage = createStorage();
281+
const loopLocation = appendName(storage, emptyLocation(), "loop");
282+
283+
// Enough keys to yield more batches than MAX_CONCURRENT_DELETES.
284+
const entryCount = MAX_CONCURRENT_DELETES * MAX_KV_BATCH_ENTRIES + 1;
285+
for (let i = 0; i < entryCount; i++) {
286+
const location = appendName(storage, loopLocation, `iter-${i}`);
287+
const entry = createEntry(location, {
288+
type: "step",
289+
data: { output: i },
290+
});
291+
setEntry(storage, location, entry);
292+
}
293+
294+
await deleteEntriesWithPrefix(storage, driver, loopLocation);
295+
296+
expect(driver.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_DELETES);
297+
expect(driver.peakInFlight).toBe(MAX_CONCURRENT_DELETES);
298+
expect(storage.history.entries.size).toBe(0);
299+
});
300+
});

0 commit comments

Comments
 (0)