A pipeline that asks an image-generation provider for several style variations of the same prompt has two requirements that fight each other: every variation should run in parallel so wall time is max(individual times) rather than sum, and a transient failure in one variation must not cancel or block the others — it has to be retried independently. The Resonate shape of the solution is ctx.beginRun(generateImage, prompt, style, ...) per style (fan-out, non-blocking) and a second loop of yield* future over the returned handles (fan-in, blocking per-future), with each generateImage call as its own durable promise that the SDK retries on its own. The example runs in embedded mode under Bun, ships a happy-path script and a --crash script that forces the abstract style to throw on its first attempt, and uses a simulated provider so the example runs without API keys.
The shape of the solution
export function* runImagePipeline(
ctx: Context,
prompt: string,
crashMode: boolean,
): Generator<any, PipelineResult, any> {
// Note: code outside ctx.run() re-executes on each replay step.
// Log messages live in generateImage() (inside ctx.run) so they print once.
// Fan-out: start all generations simultaneously
// beginRun() returns a handle immediately — doesn't wait for completion
const futures = [];
for (const style of STYLES) {
const future = yield* ctx.beginRun(generateImage, prompt, style, crashMode);
futures.push({ style, future });
}
// Fan-in: wait for each result
// If any generation fails, Resonate retries it. The others continue.
const images: GeneratedImage[] = [];
for (const { future } of futures) {
const image = yield* future;
images.push(image);
}
return { prompt, images, totalMs: images.reduce((s, i) => s + i.durationMs, 0) };
}
// from example-ai-image-pipeline-ts/src/workflow.ts:31-56The workflow is a generator function (function*), not async. The first loop builds a list of Future<GeneratedImage> handles by yielding each ctx.beginRun(...) once; the second loop drains those futures with yield* future, which suspends the workflow until each branch resolves and resumes with the GeneratedImage value. STYLES is the literal tuple ["photorealistic", "cartoon", "abstract"] (src/workflow.ts:23).
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 Resonate server is required.src/index.ts:8.resonate.register(runImagePipeline)— registers the top-level workflow so the SDK can claim and replay it by name.src/index.ts:9.resonate.run(id, fn, ...args)— starts the workflow under a caller-supplied id (`pipeline-${Date.now()}`). The id is the durable handle for the whole run.src/index.ts:26, 32.ctx.beginRun(fn, ...args)— alias forctx.lfi(Local Function Invocation). Creates a durable child promise for the call and yields back aFuture<T>rather than the resolved value, so the call does not block the caller. SDK definition:resonate-sdk-ts/src/context.ts:203-205, alias at:278. Used insrc/workflow.ts:43.yield* future— drains aFuture<T>. The future's iterator yields itself once and is fed back the resolvedT; on rejection it re-throws. SDK:resonate-sdk-ts/src/context.ts:172-184. Used insrc/workflow.ts:51.- Default retry policy on each generator call —
generateImageis anasync function, so itsLFIis created withnew Exponential()as the retry policy. SDK:resonate-sdk-ts/src/context.ts:384(opts.retryPolicy ?? (util.isGeneratorFunction(func) ? new Never() : new Exponential())). The README's crash-mode log lineRuntime. Function 'generateImage' failed with 'Error: Image generation service timeout for style 'abstract'' (retrying in 2 secs)(README.md:81) is the SDK's own message under this default.
What the SDK handles vs. what you write
| SDK handles | You write |
|---|---|
Creating one durable promise per ctx.beginRun(...) call and persisting it in the promise store | The for (const style of STYLES) { ... ctx.beginRun(generateImage, prompt, style, crashMode) ... } loop |
Returning a Future<T> synchronously so the caller can keep launching siblings before any has resolved | The futures.push({ style, future }) collection step |
Suspending the generator on yield* future and resuming with the stored value on replay | The second for-loop body that consumes each future |
Retrying a registered async function that threw, under the default Exponential policy, independently per branch | The actual failure mode (throw new Error("Image generation service timeout for style 'abstract'") on attempt 1) |
| Isolating the failure of one branch from the others — sibling futures continue to settle while the failing one is being retried | Nothing — there is no error-handling code in the workflow, no per-branch try/catch, no Promise.allSettled |
The workflow body is two loops and a return statement. The parallelism, per-branch retry, replay-on-resume, and failure-isolation across siblings are not in the code you write — they are in the SDK.
Failure modes covered
- One generator throws on its first attempt while the others are still running.
src/providers.ts:43-46throwsnew Error("Image generation service timeout for style 'abstract'")whencrashOnFirstAttempt && style === "abstract" && attempt === 1. BecausegenerateImageis an async function registered viactx.beginRun, the SDK applies the defaultExponentialretry policy (SDKcontext.ts:384) and re-invokes only that branch's function. The README's crash-mode transcript showscartoonandphotorealisticcompleting whileabstractretries (README.md:82-85). - The same pipeline is started twice. The outer promise id is
`pipeline-${Date.now()}`(src/index.ts:26), so two runs started at different millisecond timestamps get separate ids and run independently. A secondresonate.run(...)call under the same id would resolve against the existing run rather than starting a parallel one.
Not exercised by this example. Workflow-level replay-on-resume — where a process crashes mid-fan-in, the workflow re-enters from the top on the next run, and the SDK looks up each child promise by id to return already-resolved Future<T>s for branches that completed before the crash — is a property of the SDK + a persistent promise store. The example uses new Resonate() with no args, which selects LocalNetwork (an in-process, in-memory store — resonate-sdk-ts/src/resonate.ts:85 "If no URL is resolved, a local in-memory network is used"; :171 new LocalNetwork(...)). A process crash drops that store, so the example only demonstrates branch-level retry inside a single process run. To exercise workflow-level replay-on-resume, point the client at a Resonate server (new Resonate({ url: ... }) or RESONATE_URL).
Provider-side idempotency (the real concern when swapping the simulated generateImage for a paid API) is also out of scope — the simulated provider counts attempts in a module-level Record<string, number> (src/providers.ts:10) and a real implementation would need provider-side idempotency keys to avoid double-billing on a retry.
When to reach for this pattern
- If you're calling N independent providers (or N variants of one provider) for the same logical request and want wall time to be
max(individual times)rather thansum. - If a transient failure in one branch must retry independently without cancelling, restarting, or blocking the siblings.
- If the branches are homogeneous in shape (same function, different arguments) — the fan-out collapses to a
for-loop overctx.beginRun(...)calls. - If you want straight-line code instead of
Promise.allSettled+ per-promise retry wrappers + a per-branch progress table. - If the set of branches is bounded and known at workflow start — for an unbounded or streamed set, you'd reach for a different shape (recursive workflow, schedule).
- If a single retry policy per branch (the SDK's default
Exponentialon async functions) is acceptable; if you need bespoke policy per branch, pass it viactx.beginRun's options.
Sources
- Example repo: https://github.com/resonatehq-examples/example-ai-image-pipeline-ts
- TypeScript SDK repo: https://github.com/resonatehq/resonate-sdk-ts
- SDK pin:
package.json:13—"@resonatehq/sdk": "^0.10.0"(post-brand-rename surface;run/beginRun/rpc/beginRpc/detachedare public aliases of the underlyinglfc/lfi/rfc/rfi—context.ts:203-221, 278-279) - SDK source for the primitives used:
resonate-sdk-ts/src/context.ts:11-40—LFI<T>and its iterator (yield*returnsFuture<T>)resonate-sdk-ts/src/context.ts:137-184—Future<T>class; iterator method at:172-184(yield* futurereturnsT)resonate-sdk-ts/src/context.ts:203-205—beginRundeclarationresonate-sdk-ts/src/context.ts:278—beginRun = this.lfi.bind(this)aliasresonate-sdk-ts/src/context.ts:368-387—lfiimplementation, default retry policyresonate-sdk-ts/src/resonate.ts:85, 147, 171—LocalNetworkselection when no URL resolves
- Resonate documentation: https://docs.resonatehq.io
- Files cited in this post:
src/workflow.ts:23—STYLEStuplesrc/workflow.ts:31-56— therunImagePipelinegeneratorsrc/providers.ts:23-57— thegenerateImagestep (async function, retried by SDK)src/providers.ts:10, 43-46— attempts counter and crash injectionsrc/index.ts:8-9, 26, 32— embedded client, registration, run invocationREADME.md:81-85— crash-mode runtime log and parallel-retry sequence
