A batch of mixed-priority jobs needs to run such that critical work always finishes before lower-priority work, with a bounded number of jobs in flight at any time, and a crash mid-batch must not re-run jobs that already succeeded. With Resonate, the workflow is a generator function whose ctx.beginRun calls each produce a durable promise, so every yield* is a checkpoint and the SDK runtime is responsible for replay-on-crash. The example app processes 8 jobs across 4 priority tiers (critical, high, normal, low) with MAX_CONCURRENT = 2 per tier, and includes a crash-mode flag that throws once inside a normal-tier job to demonstrate that completed critical and high jobs are not re-driven.
The shape of the solution
export function* processQueue(
ctx: Context,
jobs: Job[],
crashJobId: string | null,
): Generator<any, QueueResult, any> {
const sorted = [...jobs].sort(
(a, b) => PRIORITY_WEIGHT[a.priority] - PRIORITY_WEIGHT[b.priority],
);
const allResults: JobResult[] = [];
let queuePosition = 1;
// Process tier by tier (critical first, low last)
const tiers = groupByTier(sorted);
const tierWeights = [...tiers.keys()].sort((a, b) => a - b);
for (const tierWeight of tierWeights) {
const tierJobs = tiers.get(tierWeight)!;
// Within each tier: run up to MAX_CONCURRENT jobs in parallel
for (let i = 0; i < tierJobs.length; i += MAX_CONCURRENT) {
const chunk = tierJobs.slice(i, i + MAX_CONCURRENT);
// Fan-out: start all jobs in this chunk simultaneously
const futures = [];
for (const job of chunk) {
const future = yield* ctx.beginRun(executeJob, job, queuePosition++, crashJobId);
futures.push(future);
}
// Fan-in: wait for this chunk to complete before starting next
for (const future of futures) {
const result = yield* future;
allResults.push(result);
}
}
}
return {
totalJobs: jobs.length,
completedJobs: allResults.length,
processingOrder: allResults.map((r) => `${r.id} [${r.priority}]`),
};
}
// from example-priority-queue-ts/src/workflow.ts:38There is no priority queue data structure, no in-flight counter, and no scheduler thread. The structure of the nested loop is the schedule: sort by tier weight, group, then for each tier chunk fan-out and fan-in.
The durable primitives in play
resonate.register(processQueue)— registers the generator as a Resonate function so the runtime can drive it.src/index.ts:10.resonate.run("queue/${Date.now()}", processQueue, jobs, crashJobId)— invokes the top-level generator with a deterministic id used as the durable promise id.src/index.ts:47.ctx.beginRun(executeJob, job, queuePosition++, crashJobId)— fan-out primitive. Schedules a child function as a Local Function Invocation (LFI) and returns a future without awaiting it.src/workflow.ts:64. In the SDK at the pinned version0.10.0,beginRunis an alias forlfi(resonate-sdk-ts/src/context.ts:278).yield* future— fan-in primitive. Suspends the parent generator until the child's durable promise resolves; the resolved value is whatyield*evaluates to.src/workflow.ts:70.- Generator-function workflow shape —
function*atsrc/workflow.ts:38. Everyyield*is the checkpoint boundary: the parent's progress through the loop is persisted as child-promise completions, so replay skips finished children.
What the SDK handles vs. what you write
| You write | The SDK handles |
|---|---|
The generator function processQueue with the tier-iteration loop. | Persisting a durable promise for each ctx.beginRun call before the child runs. |
ctx.beginRun(executeJob, ...) to start each child, yield* future to wait. | Resolving the future from the child's stored result, including on replay after a crash. |
MAX_CONCURRENT = 2 and the chunking arithmetic to cap in-flight count per tier. | Driving up to MAX_CONCURRENT children concurrently because ctx.beginRun returns immediately without awaiting. |
The crash simulation in executeJob (throw new Error(...) on attempt 1). | Catching the thrown error, applying the retry policy, and re-invoking only the failed child. |
new Resonate() with no URL → embedded mode (no external server). | Falling back to LocalNetwork for in-process durable-promise storage when no RESONATE_URL is configured (resonate-sdk-ts/src/resonate.ts:170-173). |
You write the priority logic. The SDK provides the checkpoint, the retry, and the partial-progress guarantee.
Failure modes covered
- A single job throws mid-batch.
executeJobthrows once forcrashJobIdon attempt 1 (src/jobs.ts:66-70). The SDK runtime catches the error and retries that child function only. The parent generator's already-resolved futures for earlier-tier jobs are not re-driven, because their durable promises remain in the resolved state. README runtime log:README.md:103-123shows critical and high jobs completing, job-006 (normal) failing withRuntime. Function 'executeJob' failed with 'Error: job-006 crashed — simulated failure' (retrying in 2 secs), thenjob-006resuming as(retry 2)and the low-tier jobs running afterward. - Out-of-order job submission.
src/index.ts:19-28submitsjob-001 [low]beforejob-003 [critical]. The sort atsrc/workflow.ts:43-45and the tier-grouped iteration enforce that critical runs first regardless of submission order. - Tier starvation by low-priority batch work. Bounded by construction: the outer
for (const tierWeight of tierWeights)loop fully drains a tier before iterating to the next, and the inner fan-inyield* futureensures every chunk completes before advancing. Lower tiers literally cannot start a job until every higher-tier future is resolved. - Overload from too many concurrent children.
MAX_CONCURRENT = 2plus chunked fan-out caps in-flight children at 2 per tier. The fan-in loop holds the parent at the chunk boundary until both finish.
The simulation models "function throws and Resonate retries" rather than "host process is killed and another worker takes over" — the in-memory jobAttempts Map in src/jobs.ts:42 would reset across a real process restart. The durable-promise guarantee that completed children are not re-run comes from the SDK runtime, not from that counter.
When to reach for this pattern
- If you have a batch of work items that must be processed in priority-tier order — strict ordering between tiers, parallelism allowed within a tier.
- If you need a bounded in-flight count per tier (here, 2) but don't need a generalized scheduler with cancellation or drop semantics.
- If mid-batch crashes must not re-execute earlier work — you want per-item durable checkpoints, not a single coarse retry of the whole batch.
- If the priority-ordered fan-out is one stage of a larger workflow, not a free-standing queueing service.
- If you want the scheduling logic to live in plain code, reviewable as a generator function, rather than in a separate orchestration system.
If you need cancellation, drop policies, arbitrary global in-flight limits across tiers, or queue-state inspection, this is the wrong pattern — reach for a purpose-built queue, and use example-distributed-mutex-ts for the underlying serialized-access primitive.
Sources
- Example repo: https://github.com/resonatehq-examples/example-priority-queue-ts
- TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts (
@resonatehq/[email protected]pinned inpackage.jsonandbun.lock) Context.beginRundeclaration andlfialias at SDK v0.10.0: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts (declaration at lines 203–205, alias at line 278)Resonateconstructor URL resolution andLocalNetworkembedded-mode fallback at SDK v0.10.0: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts (constructor opens at line 101; theLocalNetworkfallback branch is at lines 170–173)- Resonate documentation: https://docs.resonatehq.io
- Related example — distributed mutex: https://github.com/resonatehq-examples/example-distributed-mutex-ts
- Related example — fan-out / fan-in: https://github.com/resonatehq-examples/example-fan-out-fan-in-ts
