Skip to content

DurabilityScheduler

Share one physical alarm and concurrency budget across capabilities.

Import DurabilityScheduler from durability. Construct one per object when composing alarm-owning capabilities. A scheduler coordinates that object’s work, not work across different objects.

How it uses your Durable Object

The scheduler owns the parts of the Durable Object that its helpers would otherwise compete for:

  • The object’s one alarm (ctx.storage.setAlarm). Helper writes that add or reschedule pending work run in a storage transaction that also sets the alarm to the earliest pending row of every attached table. Your class’s alarm() must delegate to scheduler.alarm().
  • ctx.storage.sql stays with the helpers: each creates and registers its own tables, and the scheduler only reads their pending times.
  • The live instance holds the concurrency permits and their FIFO waiters, the early timer for work due before the alarm, and the list of attached tables. Eviction discards them; the alarm and the rows bring the work back.

How DurabilityScheduler uses your Durable Object

Shows Subscription, Object alarm, Scheduler, Early timer. Focuses on Scheduler.One Durable ObjectSubscriptionyour Durable ObjectObject alarmstorage.setAlarmSchedulerin memoryEarly timersetTimeoutalarm(info)

1/8 · One scheduler per object instance

The class constructs one DurabilityScheduler from this.ctx. Its engine is the only code that sets the object’s alarm and its early timer, and it starts with alarmConcurrency permits, here 5, which every attached helper shares.

Open source ↗
/**
 * `DurabilityAlarms` + `DurabilityScheduler` example: recurring named alarms
 * that trigger durable operations, sharing one physical Durable Object alarm.
 *
 * Each subscription object schedules a `renew` alarm. When it fires, the
 * handler registers a durable `chargeInvoice` operation (retried on failure
 * independently of the alarm) and schedules the next occurrence. Scheduling a
 * name again replaces its pending occurrence, so `subscribe` is repeatable.
 */
import { DurableObject } from 'cloudflare:workers';
import {
  Durability,
  DurabilityAlarms,
  DurabilityScheduler,
  type DurableCall,
} from 'durability';

type Env = {
  SUBSCRIPTIONS: DurableObjectNamespace<Subscription>;
  PAYMENTS: Fetcher;
};

const renewalPeriodMs = 30 * 24 * 60 * 60 * 1000;

type ChargeInput = { customerId: string; periodStart: number };

export class Subscription extends DurableObject<Env> {
  // One object uses both helpers, so they must share a scheduler: it owns the
  // single physical alarm and one concurrency pool for all handlers.
  private readonly scheduler = new DurabilityScheduler({
    context: this.ctx,
    alarmConcurrency: 5,
  });

  private readonly durability = new Durability({
    scheduler: this.scheduler,
    handlers: {
      chargeInvoice: async ({ id, payload }: DurableCall<ChargeInput>) => {
        const response = await this.env.PAYMENTS.fetch(
          'https://payments/charge',
          {
            method: 'POST',
            headers: { 'Idempotency-Key': id },
            body: JSON.stringify(payload),
          }
        );
        if (!response.ok) {
          throw new Error(`Charge failed with ${response.status}`);
        }
      },
    },
    retries: { maxAttempts: 8 },
  });

  private readonly alarms = new DurabilityAlarms({
    scheduler: this.scheduler,
    handlers: {
      renew: async ({ idempotencyKey, scheduledTime }) => {
        const customerId = await this.ctx.storage.get<string>('customerId');
        if (customerId === undefined) {
          return; // Cancelled; let this occurrence complete without renewing.
        }
        // The alarm's idempotencyKey is stable across its retries, so a
        // retried occurrence cannot double-charge.
        await this.durability.chargeInvoice({
          id: idempotencyKey,
          payload: { customerId, periodStart: scheduledTime },
        });
        await this.alarms.renew(scheduledTime + renewalPeriodMs);
      },
    },
  });

  async subscribe(customerId: string) {
    await this.ctx.storage.put('customerId', customerId);
    await this.alarms.renew(Date.now() + renewalPeriodMs);
  }

  async cancel() {
    await this.ctx.storage.delete('customerId');
  }

  /** Destructive retention cleanup for settled records older than 90 days. */
  async purgeHistory() {
    const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
    return {
      operations: await this.durability.purgeBefore(cutoff),
      alarms: await this.alarms.purgeBefore(cutoff),
    };
  }

  alarm(info?: AlarmInvocationInfo) {
    return this.scheduler.alarm(info);
  }
}

export default {
  async fetch(request, env): Promise<Response> {
    const url = new URL(request.url);
    const [, customerId, action] = url.pathname.split('/');
    if (!customerId || request.method !== 'POST') {
      return new Response('Expected POST /:customerId/(subscribe|cancel)', {
        status: 404,
      });
    }
    const subscription = env.SUBSCRIPTIONS.getByName(customerId);

    if (action === 'subscribe') {
      await subscription.subscribe(customerId);
      return new Response('subscribed', { status: 201 });
    }
    if (action === 'cancel') {
      await subscription.cancel();
      return new Response('cancelled');
    }
    return new Response('Not found', { status: 404 });
  },
} satisfies ExportedHandler<Env>;

Behind the scenes

Durable state: the scheduler creates no job table of its own. Attached helpers provide their tables and due-work callbacks; the engine consults them to reconcile the object’s one physical alarm. A duplicate participant table is rejected.

Engine transactions reconcile that alarm alongside their storage changes. The next physical alarm is bounded by the earliest due work and now + alarmMinDelayMs. Near-due work can also start from an in-memory timer; experimental liveTimers extends timer scheduling further into the future.

In memory: timers, active execution locks, promises, and the FIFO concurrency pool. Eager operations and ordinary scheduled work share the default ten permits. Some capabilities, such as recurring schedule runs, explicitly maintain a separate execution limit; a shared scheduler is not a universal limit on all application work.

Recovery: a physical alarm reconstructs progress from durable participant state after eviction. Long alarm invocations hand off before the wall-time limit. If a pass itself fails, the scheduler tries to arm a fallback at alarmMinDelayMs; only failure to arm that fallback propagates to platform retries.

Never put two independent alarm-owning schedulers in one object: each can overwrite the other’s physical-alarm decision. Not every helper needs alarm work: the retained log uses the engine for storage operations but does not automatically schedule retention.

Implementation: scheduler.ts (Engine.transaction, reconcile, runAlarm, startExecution).

Using both in one Durable Object

A Durable Object has one physical alarm. When one object uses both helpers, create a DurabilityScheduler and pass it to each so they share the alarm, the storage, and the concurrency pool:

import { Durability, DurabilityAlarms, DurabilityScheduler } from 'durability';

export class ImageJobs extends DurableObject<Env> {
  private readonly scheduler = new DurabilityScheduler({
    context: this.ctx,
    alarmConcurrency: 10,
    alarmMinDelayMs: 15_000,
  });

  private readonly durability = new Durability({
    scheduler: this.scheduler,
    handlers: {
      resizeImage: async ({ payload }) =>
        this.env.IMAGES.resize(payload.imageId),
    },
  });

  private readonly alarms = new DurabilityAlarms({
    scheduler: this.scheduler,
    handlers: { cleanup: async () => this.env.IMAGES.cleanup() },
  });

  alarm(info?: AlarmInvocationInfo) {
    return this.scheduler.alarm(info);
  }
}

A helper configured with context instead of scheduler creates a private scheduler and accepts the scheduler options (alarmConcurrency, alarmMinDelayMs, alarmHandoffMs) in the same object. Two helpers on the same object must share one scheduler; otherwise each would reconcile the physical alarm against only its own records.

Delivery semantics and concurrency

Work is delivered at least once, as the delivery and safety contract describes. A process can stop after a side effect succeeds but before its completion record commits, so handlers should be idempotent and pass the call ID or alarm idempotencyKey to external services.

alarmConcurrency defaults to 10 and is one FIFO limit shared by eager operation handlers, timer-driven work, and physical-alarm work attached to the same scheduler. Newly registered operations start eagerly only when a permit is immediately available. Work due before the physical-alarm minimum enters the same queue when its in-memory timer fires.

Operation completion records are retained indefinitely so their IDs remain deduplicated. The scheduler owns the Durable Object’s alarm; compose unrelated scheduled work as named alarms instead of replacing the physical alarm.

When a run cannot finish

A Durable Object alarm whose handler throws is retried with backoff a limited number of times and then dropped. Every helper’s alarm() ends by reconciling the physical alarm, and a storage error there would otherwise propagate, so enough of them in a row would leave pending work with nothing armed. Instead, a run that fails arms the physical alarm directly at alarmMinDelayMs from now and returns, reporting durability.alarm.failed with the error; the next run reconciles properly. Only when even that setAlarm fails does the failure reach the platform and its retries.

Long-running alarm calls

Alarm invocations have a 15-minute wall-time limit. If work is still pending after alarmHandoffMs (default 14 minutes), the scheduler retains its promise in memory, arms a fallback after alarmMinDelayMs, and returns from the current invocation. The next alarm attaches to the same promise instead of starting the handler again. If the object is evicted during a handoff, the next alarm reconstructs and executes the persisted pending work.

liveTimers (scheduler)

liveTimers?: boolean. Due work starts from an in-memory timer at its exact scheduled time, however far beyond alarmMinDelayMs, and an alarm pass skips records that are already running instead of waiting for them, so a long handler no longer delays newly due work. The physical alarm stays armed at the usual bounded delay as the eviction fallback, and the handoff before the alarm wall-time limit is unchanged.

Gives up: every settled attempt runs one more reconciliation transaction to re-arm the timer, and timers do not survive eviction, so after one the work runs only when the physical alarm fires. Attempts interrupted by the eviction are counted as usual, so an operation with maxAttempts: 1 fails instead of running again.

onAlarmRetry (scheduler)

onAlarmRetry?: (info: AlarmRetryInfo) => void | Promise<void>, where info is { error: { name, message }, retryInMs, retryCount?, isRetry?, objectId? }. It is called each time an alarm run fails and the scheduler re-arms its own fallback alarm (see When a run cannot finish), so the error can go to an external service. retryInMs is alarmMinDelayMs; retryCount and isRetry come from the platform’s AlarmInvocationInfo when the invocation carried one, and objectId from the context.

The durability.alarm.failed report still happens. The hook is best effort: it runs after the fallback is armed, a returned promise is passed to waitUntil, and a throw or rejection is only reported as durability.alarm.retry_hook_failed, so it cannot change scheduling. It is not called when even the fallback cannot be armed, because the failure then reaches the platform.

const scheduler = new DurabilityScheduler({
  context: this.ctx,
  onAlarmRetry: (info) => this.env.ERRORS.send(info),
});

Type to search…

↑↓ navigate↵ selectEsc close