A bulk import of N records has to make forward progress across crashes: if the worker dies mid-import, the next run must resume at the first uncompleted batch and must not re-process batches that already succeeded. The Resonate shape of the solution is to chunk the input into batches and wrap each batch in ctx.run(...) inside a plain for-loop; every call is a durable promise, so on replay the completed batches return their stored result and the loop advances to the first uncompleted one. The example runs in embedded mode under Bun and ships both a happy-path entrypoint and a --crash entrypoint that forces batch 3 to throw on its first attempt.
The shape of the solution
export function* importRecords(
ctx: Context,
records: Record[],
batchSize: number,
crashAtBatch: number,
): Generator<any, ProcessingResult, any> {
const batches = chunkArray(records, batchSize);
const batchResults: BatchResult[] = [];
for (let i = 0; i < batches.length; i++) {
const batch = batches[i]!;
// Each yield* is a durable checkpoint. On crash+resume,
// completed batches are returned from cache — not re-processed.
const result = yield* ctx.run(processBatchChunk, i, batch, crashAtBatch);
batchResults.push(result);
}
const totalProcessed = batchResults.reduce((s, b) => s + b.processed, 0);
const totalSkipped = batchResults.reduce((s, b) => s + b.skipped, 0);
return {
totalRecords: records.length,
totalProcessed,
totalSkipped,
batchCount: batches.length,
batches: batchResults,
};
}
// from example-batch-processor-ts/src/workflow.ts:37-64The workflow is a generator function (function*), not async. yield* ctx.run(processBatchChunk, ...) creates a durable child promise for that batch, suspends the workflow until the child resolves, and resumes with the returned BatchResult. The loop index i is not stored anywhere by user code — it is rediscovered by replay, because each completed iteration's child promise is already resolved in the promise store.
The durable primitives in play
new Resonate()— constructs an embedded-mode client. With no constructor arguments, the SDK runs against an in-process promise store; no external server is required.src/index.ts:9.resonate.register(importRecords)— registers the top-level workflow so the SDK can claim and execute it (and so a replay can look it up by name + version).src/index.ts:10.resonate.run(id, fn, ...args)— starts the workflow under a caller-supplied promise id (`import/${Date.now()}`). The id is the durable handle for the whole run.src/index.ts:40-46.ctx.run(fn, ...args)— alias forctx.lfc(Local Function Call). Creates a durable promise for the child call and yields back the resolved valueT, not aFuture<T>. Each call is checkpointed independently. SDK definition:resonate-sdk-ts/src/context.ts:214-215, alias at:283. Used in:src/workflow.ts:50.yield* ctx.run(...)— the iterator protocol onLFC<T>yields the LFC itself once and is fed back the resolvedT. SDK:resonate-sdk-ts/src/context.ts:72-76. The rejection path is driven from outside the iterator: the coroutine decorator callsthis.generator.throw(value.error)when a child promise rejected, which propagates the error out of the user'syield* ctx.run(...)site. SDK:resonate-sdk-ts/src/decorator.ts:218-219.- Default retry policy on the batch step —
processBatchChunkis anasync function, so itsLFCis created withnew Exponential()as the retry policy. SDK:resonate-sdk-ts/src/context.ts:432(opts.retryPolicy ?? (util.isGeneratorFunction(func) ? new Never() : new Exponential())). The example does not pass a custom policy toctx.run, so the default applies. - SDK surface —
package.json:11pins"@resonatehq/sdk": "^0.10.0". On the 0.10 line,Contextexposesrun/beginRun/rpc/beginRpcas aliases of the underlyinglfc/lfi/rfc/rfioperations (resonate-sdk-ts/src/context.ts:283-286);detachedis a separate verb that returns anRFIin"detached"mode (:226-227, implementation at:545-567).
What the SDK handles vs. what you write
| SDK handles | You write |
|---|---|
Creating one durable promise per ctx.run(...) call and persisting it in the promise store | The ctx.run(processBatchChunk, i, batch, crashAtBatch) call inside the loop |
Suspending the generator on yield* ctx.run(...) and resuming with the stored value on replay | The for-loop body that consumes each batch result |
| Storing each batch's return value as soon as it resolves so a replay does not re-execute the batch function | The pure batch function processBatchChunk that produces that return value |
Retrying an async function passed to ctx.run that threw, under the default Exponential policy | The actual failure mode (throw new Error("Batch 3 failed — simulated DB timeout") on attempt 1) |
Discovering "where the loop is" on resume — by reading each ctx.run child promise and skipping completed ones | Nothing — there is no progress table, no cursor, no resume-from-N parameter |
The workflow body reads like a plain for-loop. The progress tracking, persistence, per-batch retry, and replay-skip logic are not in the code you write — they are in the SDK.
Failure modes covered
processBatchChunkthrows on its first attempt at the target batch.src/processor.ts:70-73throwsnew Error(`Batch ${batchIndex} failed — simulated DB timeout`)whencrashAtBatch === batchIndex && attempt === 1. The SDK retries the async function passed toctx.rununder the defaultExponentialpolicy. The README's crash-mode runtime log captures the SDK message verbatim:Runtime. Function 'processBatchChunk' failed with 'Error: Batch 3 failed — simulated DB timeout' (retrying in 2 secs)(README:94).- The workflow function suspends and resumes mid-loop. Because every iteration's
ctx.run(...)is its own durable promise, a resume of the workflow re-enters thefor-loop fromi = 0, but eachyield*for an already-completed batch returns its storedBatchResultfrom the promise store instead of re-invokingprocessBatchChunk. The loop advances to the first uncompleted batch with no batch re-processing.
The example runs in embedded mode (no Resonate server). Claims about cross-process replay against a shared promise store are properties of the SDK + server, not features the example itself wires up. The outer promise id is `import/${Date.now()}` (src/index.ts:41), so every invocation of the script produces a fresh root id — the example never demonstrates two runs sharing an id, and run-deduplication is a separate Resonate property the post does not claim. Database idempotency is also out of scope — processBatchChunk simulates a DB write with await sleep(150) and a counted-attempts Map (src/processor.ts:30, 68); a real implementation would need provider-side idempotency keys to make re-issued batches safe end-to-end.
When to reach for this pattern
- If you're importing a large record set in fixed-size chunks and a crash anywhere through the import must not restart from record 0.
- If each chunk is independently committable (writing a chunk does not require the others to have finished) — this is the prerequisite for treating each chunk as its own checkpoint.
- If you want straight-line for-loop code instead of an external progress table, cursor, or resume-from-N parameter — the loop index is rediscovered by replay rather than stored by you.
- If the batch size is a tuning knob you want to control directly — more records per batch means fewer durable promises (less storage) but more re-work on a mid-batch crash.
- If a single retry policy per batch (the SDK's default
Exponentialon async functions) is acceptable; if you need bespoke policy per batch type, pass it viactx.run's options.
Sources
- Example repo: https://github.com/resonatehq-examples/example-batch-processor-ts
- TypeScript SDK repo: https://github.com/resonatehq/resonate-sdk-ts
- SDK source for the primitives used:
resonate-sdk-ts/src/context.ts:214-215—rundeclarationresonate-sdk-ts/src/context.ts:283-286—run/rpc/beginRun/beginRpcalias bindingsresonate-sdk-ts/src/context.ts:226-227, 545-567—detached(separate verb, returnsRFIin"detached"mode — not an alias)resonate-sdk-ts/src/context.ts:45-77—LFC<T>and its iterator (yield*returnsT)resonate-sdk-ts/src/context.ts:403-436—lfcimplementation, default retry policyresonate-sdk-ts/src/decorator.ts:213-237—safeGeneratorNext(rejection propagation:this.generator.throw(value.error)at:219)
- Resonate documentation: https://docs.resonatehq.io
- Files cited in this post:
src/workflow.ts:37-64— theimportRecordsgeneratorsrc/processor.ts:52-87— theprocessBatchChunkstep (async function, retried by SDK)src/processor.ts:30, 68, 70-73— attempts counter, simulated DB latency, crash injectionsrc/index.ts:9-10, 40-46— embedded client, registration, run invocationpackage.json:11— SDK pinREADME.md:94— crash-mode runtime log line
