Skip to content

DurabilityAlarms

Persist named reminders and replace their pending occurrences.

Import DurabilityAlarms from durability. Use it for named, replaceable occurrences rather than retained job histories. See recurring billing.

How it uses your Durable Object

DurabilityAlarms adds no storage or infrastructure of its own. It uses the Durable Object it is constructed in:

  • ctx.storage.sql holds one row per alarm name, so occurrences are as durable as the object and belong to it alone. Success deletes the row.
  • The object’s one alarm serves every name. The scheduler sets it to the earliest pending row of every attached helper in the same transaction that schedules an occurrence, and your class’s alarm() must delegate to the scheduler (or to alarms.alarm() when the helper has a private one).
  • The live instance holds the map of running occurrences and their abort signals, plus the scheduler’s permits and timer. Eviction discards them; a pending occurrence then runs again with the same idempotencyKey.
  • Other helpers on the same scheduler, such as Durability in the example below, share the alarm and the concurrency permits.

How DurabilityAlarms uses your Durable Object

Shows Subscription, DurabilityAlarms. Focuses on DurabilityAlarms.One Durable ObjectSubscriptionyour Durable ObjectDurabilityAlarmsin memoryrenew(at)

1/9 · The helper belongs to the object instance

DurabilityAlarms is constructed with the object’s shared DurabilityScheduler, which wraps this.ctx. It reads and writes only this object’s storage; another subscription object has its own rows and its own alarm.

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: durability_alarms holds one current occurrence per handler name, with a generation, scheduled time, attempt, retry time, status, and error. Occurrence-scoped intermediate results live in durability_memos.

Scheduling transactionally upserts a fresh generation, resets its attempt counter, removes the previous occurrence’s memos, and reconciles the physical alarm. It can replace a failed occurrence as well as a pending one. A currently executing handler is not forcibly stopped; its generation-fenced settlement cannot delete the replacement.

After eviction: a pending occurrence can run again. Its idempotencyKey derives from its stored generation and stays stable across retries; the name and attempt number are not a substitute for that key. A replacement occurrence gets a new key.

Cancellation and lifetime: success deletes the occurrence; no permanent result history remains. Failed rows remain until replacement or purge. Experimental cancel() deletes a pending occurrence and memos transactionally, then best-effort aborts a running attempt. It cannot undo an external effect or stop that handler from explicitly scheduling new work.

Implementation: durability-alarms.ts (DurabilityAlarmsCore) and scheduler.ts (Engine.startExecution).

Named alarms

import { DurableObject } from 'cloudflare:workers';
import { DurabilityAlarms } from 'durability';

export class Subscription extends DurableObject<Env> {
  private readonly alarms = new DurabilityAlarms({
    context: this.ctx,
    handlers: {
      renew: async ({ idempotencyKey, signal }) => {
        await this.env.BILLING.fetch('https://billing.internal/renew', {
          method: 'POST',
          headers: { 'Idempotency-Key': idempotencyKey },
          signal,
        });
      },
    },
    methods: { renew: { retryTimeouts: true, retries: { maxAttempts: 5 } } },
  });

  scheduleRenewal(at: number) {
    return this.alarms.renew(at);
  }

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

Scheduling a name again replaces its pending occurrence. If that name is already running, the current handler continues and the replacement runs afterward. Different names may run concurrently, but one name never has more than one active handler in the same instance.

Named alarms inside the scheduler’s alarmMinDelayMs window start from an in-memory timer at their requested time. The option accepts 1 to 15 seconds and defaults to 15 seconds; the scheduler never sets the physical alarm sooner than that delay; the physical alarm remains as a durable fallback for restarts. If concurrency capacity is full when the timer fires, execution waits in the shared FIFO queue. The scheduling method resolves after persistence and never waits for the handler to finish.

Each occurrence receives a new internal generation. Its idempotencyKey stays stable across retries, while attempt records which attempt the handler is running. The ordinal advances each time an attempt is claimed and is retry context, not a unique per-handler-entry token (see Identity and attempt numbers). A successful handler removes only the occurrence it executed, so it cannot delete a replacement scheduled while it was running.

A timeout aborts the handler’s signal and is terminal by default because the external outcome is unknown. Set retryTimeouts: true only when the handler’s side effects use the idempotency key or reconcile their outcome before retrying. Named alarms remain at-least-once across eviction or restart.

cancel (named alarm method)

alarms.<name>.cancel(): Promise<boolean>. This deletes the alarm’s pending occurrence and its memos and reconciles the physical alarm in one transaction, then aborts the signal of a running attempt of it. That attempt’s settlement can no longer retry it. It resolves true when a pending occurrence was deleted. Failed occurrences are left for purgeBefore.

Gives up: cancelling does not stop later scheduling. A call to the scheduling method after cancel, including one from the running handler before it returns, schedules a new occurrence.

Type to search…

↑↓ navigate↵ selectEsc close