4 min readResonate HQJust published

Infinite monitoring workflow in TypeScript on Resonate

How an unbounded health-check loop stays durable across step failures using ctx.run plus ctx.sleep, with no periodic history-reset step.

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

A monitoring workflow needs to run for days, weeks, or months — checking services, firing alerts, sleeping between checks — and survive step-level failures without re-running work it has already finished. On Resonate, the workflow is a generator function that loops while yielding to ctx.run for each health-check step and ctx.sleep between iterations, with each yield producing an independent durable checkpoint. The example shows the same workflow code running in two modes: a happy-path run of 8 iterations, and a crash-mode run where the monitoring step throws mid-iteration and the SDK retries the step in place without re-executing earlier iterations.

The shape of the solution

export function* healthMonitor(
  ctx: Context,
  config: MonitorConfig,
  shouldCrash: boolean,
): Generator<any, MonitorResult, any> {
  const alerts: Alert[] = [];
  const startTime = Date.now();
  let iteration = 0;
 
  // Loop forever (capped at maxIterations for the demo).
  // In production, set maxIterations = Infinity.
  // No continueAsNew required — Resonate has no history limit.
  while (iteration < config.maxIterations) {
    iteration++;
 
    // Check all services in this iteration
    const crashThisIteration = shouldCrash && iteration === 3;
    const result = yield* ctx.run(
      checkAllServices,
      config.services,
      iteration,
      crashThisIteration,
    );
 
    // Collect any alerts
    for (const alert of result.alerts) {
      alerts.push(alert);
    }
 
    // Sleep between checks (durable — survives crashes)
    if (iteration < config.maxIterations) {
      yield* ctx.sleep(config.intervalMs);
    }
  }
 
  return {
    iterations: iteration,
    alerts,
    uptime: Date.now() - startTime,
  };
}
// from example-infinite-workflow-ts/src/workflow.ts:37-77

The workflow is registered and invoked from the entry point with a string id:

const resonate = new Resonate();
resonate.register(healthMonitor);
 
// ...
 
const result = await resonate.run(
  `monitor/${Date.now()}`,
  healthMonitor,
  config,
  shouldCrash,
);
// from example-infinite-workflow-ts/src/index.ts:8-9, 37-42

The durable primitives in play

  • resonate.register(healthMonitor) — registers the generator as a workflow the runtime can replay. src/index.ts:9.
  • resonate.run(id, healthMonitor, config, shouldCrash) — starts a durable invocation keyed by id. The example builds the id as `monitor/${Date.now()}` (src/index.ts:38), so a fresh id is minted per process; a stable id would attach to the existing invocation on a subsequent call rather than starting a fresh one, but the demo does not exercise that path.
  • yield* ctx.run(checkAllServices, ...) — runs the health-check step as a durable child. The step's argument list and return value are persisted; if the step throws, the SDK retries it without re-running prior iterations. src/workflow.ts:54-59.
  • yield* ctx.sleep(config.intervalMs) — durable timer. The deadline is persisted by the SDK so it can be honored independently of the calling loop. src/workflow.ts:68.
  • Generator-local state (alerts, iteration, startTime) — deterministically reconstructed on replay: the SDK re-executes the generator from the top and substitutes cached results at each prior yield*, so locals end up with the same values they had before. src/workflow.ts:42-44, 62-64, 72-76.

What the SDK handles vs. what you write

You write a plain TypeScript generator with a while loop, a counter, an alerts array, and two yield points. There is no checkpoint object, no periodic history-reset step, no manual state serialization between iterations, and no retry loop around the health-check step.

The SDK handles the durability mechanics around those yields. Each yield* ctx.run(...) is a checkpoint: its argument list and return value are persisted, so on replay the SDK feeds the cached result back into the generator instead of re-invoking checkAllServices. Each yield* ctx.sleep(ms) is a durable timer: the deadline is persisted, so the SDK can honor it independently of when the calling generator next resumes. When checkAllServices throws, the SDK catches it and re-invokes the step (the runtime line Function 'checkAllServices' failed with '...' (retrying in ...) in README.md:88 comes from the SDK, not user code); iterations that already completed are not re-executed because their results are already in the durable promise store.

The result: the only thing the loop body has to know is that yielding is how it makes progress. Durability is what the SDK adds; the loop body is unchanged.

Failure modes covered

The shipped demo instantiates Resonate in embedded mode (new Resonate() at src/index.ts:8), which keeps the promise store in process memory (README.md:142-145). The bullets below scope each failure mode to what the demo actually exercises versus what the same workflow code does when run against a Resonate server backend, which the example does not stand up.

  • The health-check step throws. Demonstrated by the demo. checkAllServices throws on iteration 3 on its first attempt in crash mode (src/workflow.ts:105-108). The SDK catches the throw and re-invokes the step in the same process. The module-level attemptMap (src/workflow.ts:90, 99-100) makes the second invocation take the success branch. Iterations 1 and 2 are not re-checked because their ctx.run results are already checkpointed.
  • Alerts must persist across iterations. Demonstrated by the demo. The generator-local alerts array is reconstructed on replay because the SDK re-executes the generator with the cached ctx.run results substituted at each prior yield, so alerts.push(alert) (src/workflow.ts:62-64) produces the same array contents it had before the retry.
  • The workflow runs longer than any single event-history budget. Demonstrated by the demo's design. There is no per-workflow history-size cap to hit: each yield is an independent durable promise resolution. The loop body never has to "reset" itself with a periodic state-extraction step. src/workflow.ts:46-49.
  • Process crashes mid-sleep or between iterations. Not demonstrated by this demo. The shipped example uses embedded mode, so a real process crash drops the in-memory store. Run the same workflow code against a Resonate server (README.md:142-145) and the sleep deadline plus the per-iteration ctx.run results survive in the server-side promise store; on restart, the SDK replays the generator, fast-forwards through cached ctx.run calls, and resumes at the next pending yield rather than at the top of the loop. The workflow source does not change between embedded and server modes — only the new Resonate() instantiation.

When to reach for this pattern

  • If you need a workflow that runs for days, weeks, or months and must survive worker crashes without losing its position (paired with a Resonate server backend for cross-restart durability).
  • If a periodic check (health monitor, polling integration, scheduled reconciliation) needs durable backoff between iterations rather than setInterval in a regular process.
  • If you would otherwise be reaching for a periodic state-extraction step to reset an event history before it grows past a system-defined cap — on Resonate, that step does not exist; just keep looping.
  • If iterations should be independently retried on failure without re-running prior iterations.
  • If the workflow accumulates local state (counters, alert lists, last-seen tokens) across iterations and you don't want to externalize that state into a database row just to make it survivable.

Sources