4 min readResonate HQJust published

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

A nested loop over priority tiers becomes the scheduler when every yield* is a durable Resonate checkpoint.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

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 shapefunction* 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 writeThe 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