Priority queue with bounded per-tier concurrency in TypeScript on Resonate

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:38

There 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 version 0.10.0, beginRun is an alias for lfi (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 what yield* evaluates to. src/workflow.ts:70.

Generator-function workflow shape — function* at src/workflow.ts:38. Every yield* 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. executeJob throws once for crashJobId on 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-123 shows critical and high jobs completing, job-006 (normal) failing with Runtime. Function 'executeJob' failed with 'Error: job-006 crashed — simulated failure' (retrying in 2 secs), then job-006 resuming as (retry 2) and the low-tier jobs running afterward.

Out-of-order job submission. src/index.ts:19-28 submits job-001 [low] before job-003 [critical]. The sort at src/workflow.ts:43-45 and 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-in yield* future ensures 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 = 2 plus 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/sdk@0.10.0 pinned in package.json and bun.lock)

Context.beginRun declaration and lfi alias 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)

Resonate constructor URL resolution and LocalNetwork embedded-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; the LocalNetwork fallback 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