Skip to content

DurabilityFanout

Durable acceptance and independently retried message-target deliveries.

Import DurabilityFanout from durability. Use it when every accepted message has independently retryable delivery obligations. See the email delivery composition.

How it uses your Durable Object

DurabilityFanout adds no queue service of its own. It uses the Durable Object it is constructed in:

  • ctx.storage.sql holds every accepted message and one delivery row per target, so the shard that accepted a batch is the only one that delivers it.
  • The object’s one alarm, owned by the shared DurabilityScheduler, is set in the same transaction as every write that adds or reschedules a delivery. Your class’s alarm() must delegate to scheduler.alarm().
  • RPC in both directions: targets are called outside any storage transaction, and each ack(), retry(), or deadLetter() is an RPC back into the object that settles one target’s row.
  • The live instance holds only the running pass per target, the settlement closures of in-flight messages, and a cached pending count. Eviction loses them; the rows and the alarm bring the work back.

How DurabilityFanout uses your Durable Object

Shows EmailDelivery, DurabilityFanout. Focuses on DurabilityFanout.One Durable ObjectEmailDeliveryyour Durable ObjectDurabilityFanoutin memoryenqueue()

1/9 · Fanout belongs to the object instance

The scheduler is built from this.ctx, and the fanout attaches to it with two static targets: email and archive. Target IDs and their callbacks are fixed at construction.

Open source ↗
import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
import { defineTransform, timeout } from '@durability/transforms';
import {
  DurabilityFanout,
  DurabilityScheduler,
  type DurabilityFanoutMessage,
  type FanoutEnqueueOptions,
  type FanoutInput,
  type StoredMessage,
} from 'durability';
import { DurabilityRouting, type RoutingSession } from 'durability/routing';

type Env = {
  DEAD_LETTERS: R2Bucket;
  EMAIL_API: Fetcher;
  EMAIL_CONSUMER: Service<EmailConsumer>;
};

type Email = {
  to: string;
  subject: string;
  body: string;
  customerId: string;
  region?: DurableObjectLocationHint;
};
type Batch = { messages: FanoutInput<Email>[]; options?: FanoutEnqueueOptions };
type Context = { lane?: string };

export class EmailConsumer extends WorkerEntrypoint<Env> {
  async consume(messages: DurabilityFanoutMessage<Email>[]) {
    for (const message of messages) {
      // The upstream must deduplicate successful side effects across redeliveries.
      // eslint-disable-next-line no-await-in-loop
      const response = await this.env.EMAIL_API.fetch(
        'https://email-api/send',
        {
          method: 'POST',
          headers: { 'Idempotency-Key': message.id },
          body: JSON.stringify(message.body),
        }
      );
      if (response.ok) {
        // eslint-disable-next-line no-await-in-loop
        await message.ack();
      } else if (response.status === 400) {
        // eslint-disable-next-line no-await-in-loop
        await message.deadLetter();
      } else if (response.status === 429) {
        // eslint-disable-next-line no-await-in-loop
        await message.retry(30_000);
      }
      // Unsettled messages retry; exhausting the budget dead-letters this target only.
    }
  }
}

// One bucket, two prefixes: every message is archived, failures are parked.
const archivePrefix = 'email-archive/';
const deadLetterPrefix = 'email-dlq/';
const replayWindowMs = 30 * 24 * 60 * 60 * 1_000;

/** The application owns this DO and composes its capabilities explicitly. */
export class EmailDelivery extends DurableObject<Env> {
  private readonly scheduler = new DurabilityScheduler({ context: this.ctx });
  private readonly routing = new DurabilityRouting();
  private readonly fanout = new DurabilityFanout<Email>({
    scheduler: this.scheduler,
    routing: this.routing,
    targets: {
      email: {
        deliver: (messages) => this.env.EMAIL_CONSUMER.consume(messages),
      },
      // A storage destination is just a callback. Keying each object by the
      // delivery identity and refusing to overwrite makes retried writes
      // idempotent, so a redrive deletes exactly what it re-enqueued.
      archive: {
        storage: async (message) => {
          await this.env.DEAD_LETTERS.put(
            `${archivePrefix}${encodeURIComponent(message.id)}`,
            JSON.stringify(message),
            { onlyIf: { etagDoesNotMatch: '*' } }
          );
        },
      },
    },
    dlq: async (message) => {
      await this.env.DEAD_LETTERS.put(
        `${deadLetterPrefix}${encodeURIComponent(message.id)}`,
        JSON.stringify(message),
        { onlyIf: { etagDoesNotMatch: '*' } }
      );
    },
    maxBatchSize: 25,
    attemptTimeoutMs: 30_000,
    retries: { maxAttempts: 5 },
  });

  enqueue(batch: Batch) {
    return this.fanout.enqueue(batch.messages, batch.options);
  }

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

export const routing = DurabilityRouting.client({
  target: EmailDelivery,
  // Vite injects exportName from this application class and Wrangler's config.
  invoke: (stub, batch: Batch) => stub.enqueue(batch),
  sharding: (batch, context: Context | undefined) => ({
    shard:
      context?.lane ??
      `customer-${batch.messages[0]?.body.customerId ?? 'unknown'}`,
    ...(batch.messages[0]?.body.region === undefined
      ? {}
      : { locationHint: batch.messages[0].body.region }),
  }),
});

const lane = defineTransform<
  RoutingSession<Batch, string[], Context>,
  Context
>().caller(
  (name: string) =>
    async ({ next }) =>
      next({ context: { lane: name } })
);

/**
 * Administrative redrive: read the parked entries, route each failed target
 * again, and delete only what was accepted. Reading is application policy, so
 * this walks the bucket directly.
 */
export async function redrive(env: Env, after: number): Promise<number> {
  // Entries older than the replay window are ignored here; an R2 lifecycle
  // rule on the prefix does the physical deletion.
  const cutoff = Math.max(after, Date.now() - replayWindowMs);
  let cursor: string | undefined;
  let accepted = 0;
  do {
    // eslint-disable-next-line no-await-in-loop
    const page = await env.DEAD_LETTERS.list({
      prefix: deadLetterPrefix,
      limit: 100,
      ...(cursor === undefined ? {} : { cursor }),
    });
    for (const object of page.objects) {
      // eslint-disable-next-line no-await-in-loop
      const stored = await env.DEAD_LETTERS.get(object.key);
      if (!stored) {
        continue;
      }
      // This Worker wrote the entry; an application reading foreign data should
      // validate it instead of casting.
      // eslint-disable-next-line no-await-in-loop
      const entry = (await stored.json()) as StoredMessage<Email>;
      if (entry.storedAt < cutoff) {
        continue;
      }
      // The entry ID distinguishes this redrive from the original registration,
      // so it cannot collide with siblings still delivering.
      // eslint-disable-next-line no-await-in-loop
      const result = await routing.push({
        messages: [
          { id: entry.messageId, body: entry.body, deduplicationKey: entry.id },
        ],
        options: { targets: [entry.target] },
      });
      if (!result.success) {
        continue;
      }
      // Never delete on rejection or transport uncertainty. A crash between
      // acceptance and deletion can still duplicate work: this is at-least-once.
      // eslint-disable-next-line no-await-in-loop
      await env.DEAD_LETTERS.delete(object.key);
      accepted += 1;
    }
    cursor = page.truncated ? page.cursor : undefined;
  } while (cursor !== undefined);
  return accepted;
}

export default {
  async fetch(request): Promise<Response> {
    if (request.method !== 'POST') {
      return new Response('POST an email array', { status: 405 });
    }
    // Demo input only: authenticate and validate untrusted payloads in an application.
    const messages = (await request.json<Email[]>()).map((body) => ({ body }));
    const result = await routing
      .with(lane, 'vip')
      .with(timeout, 5_000)
      .push({ messages });
    return Response.json(result, { status: result.success ? 202 : 409 });
  },
} satisfies ExportedHandler<Env>;

Behind the scenes

Durable state: durability_fanout_messages stores a manifest per active deduplication key. durability_fanout_deliveries stores one obligation per message-target pair, with sequence, phase, attempt, and due time. A sequence table orders first deliveries; retry memos are shared with other capabilities.

enqueue() validates the whole batch, then transactionally inserts manifests and the frozen target set and reconciles the alarm. Due delivery runs in bounded batches per target under the scheduler’s shared permits. Consumer RPC happens outside the acceptance transaction.

Settlement: ack() removes one obligation and its memos. When no obligations remain, the manifest is removed too. A normal consumer return without settlement is not acknowledgement unless ackOnReturn is enabled. retry() persists another eligibility time. Dead-letter intent is committed before external DLQ writing, so a storage outage retries the DLQ path rather than resending to the consumer.

After a crash: an outstanding target can receive the same delivery again. The consumer or storage sink must deduplicate the appropriate stable message/delivery identity. An already acknowledged sibling stays settled. Removed target IDs remain pending rather than being silently rerouted.

Lifetime: deduplication ends when the last obligation settles; this is not permanent operation history. There is no automatic archive/DLQ retention policy and no public fanout purge API.

Implementation: fanout.ts (enqueue, target delivery, settlement, and manifest removal); migrations.ts.

Routing and fanout

A queue is a composition, not another exported abstraction. An application owns its DO class and attaches the capabilities it needs:

import { DurableObject } from 'cloudflare:workers';
import {
  DurabilityFanout,
  DurabilityScheduler,
  type FanoutInput,
  type StoredMessage,
} from 'durability';
import { DurabilityRouting } from 'durability/routing';

export class Mailbox extends DurableObject<Env> {
  private readonly scheduler = new DurabilityScheduler({ context: this.ctx });
  private readonly routing = new DurabilityRouting();
  private readonly fanout = new DurabilityFanout<Job>({
    scheduler: this.scheduler,
    routing: this.routing,
    targets: {
      consumer: { deliver: (messages) => this.env.CONSUMER.consume(messages) },
      archive: { storage: (message) => this.store('archive/', message) },
    },
    dlq: (message) => this.store('dead-letters/', message),
  });

  private async store(prefix: string, message: StoredMessage<Job>) {
    await this.env.ARCHIVE.put(
      `${prefix}${encodeURIComponent(message.id)}`,
      JSON.stringify(message),
      { onlyIf: { etagDoesNotMatch: '*' } }
    );
  }

  enqueue(messages: FanoutInput<Job>[]) {
    return this.fanout.enqueue(messages);
  }

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

const routing = DurabilityRouting.client({
  target: Mailbox,
  invoke: (stub, messages: FanoutInput<Job>[]) => stub.enqueue(messages),
  sharding: (messages, context: { tenant?: string } | undefined) => ({
    shard: context?.tenant ?? messages[0]?.body.customerId ?? 'default',
    locationHint: 'weur',
  }),
});

const result = await routing.push([{ id: 'job:42', body: job }], {
  tenant: 'acme',
});

The application chooses the RPC method via invoke; routing does not know about messages or fanout. A single push selects one shard for the entire input. Static fanout target IDs identify independent delivery obligations; each accepted message snapshots its recipients. Targets added later do not receive old work, and a removed target’s work remains durable with an error and backoff until that ID is restored. Target callbacks/storage references are fixed at capability construction.

Acceptance, not completion

fanout.enqueue accepts a message or batch and returns:

type EnqueueResult<T> =
  | { success: true; value: T; load: LoadSnapshot }
  | {
      success: false;
      error: { name: string; message: string };
      load: LoadSnapshot;
    };

Fanout’s value is the message ID array; an application RPC can return its own value with the same envelope. Success means the entire batch and its target obligations were committed, not that consumers ran. Definite validation/conflict rejection returns success: false with no partial batch. Storage/commit uncertainty throws, as does an RPC transport failure: the caller must not infer non-acceptance. Routing never silently retries or switches shards on a failed call. The caller decides whether to retry on the same or a different shard.

id defaults to a UUID. deduplicationKey defaults to id; while a registration remains active, repeating that key with identical ID, payload, and recipient set does not redeliver acknowledged siblings. Reusing it for different work rejects the batch. After the final target settles, the message manifest is removed and the key may be reused. This is not permanent or cross-shard deduplication. Consumers must make side effects idempotent for at-least-once delivery.

Independent delivery

First deliveries are ordered by enqueue sequence per shard and target. Waiting retries do not block later first deliveries; completion order is not guaranteed. Each scheduler pass advances a bounded batch per target (maxBatchSize, default 10) with the shared concurrency pool. Different targets progress independently subject to that capacity.

Set batchDelayMs to hold first deliveries for up to that many milliseconds, so messages enqueued together reach the consumer as one batch instead of one call each. Each first attempt is rounded up to the next multiple of batchDelayMs, so messages enqueued within the same window become due at the same time. It delays delivery, not acceptance: enqueue still returns once the batch commits. Retries keep their policy delay.

Consumer callbacks may forward the batch to any ordinary WorkerEntrypoint RPC method. Messages include id, deliveryId, target, body, attempt, enqueuedAt, a memoize scoped to that message and target, so a retried delivery reuses work an earlier attempt finished, and callable settlement capabilities:

  • ack() settles this target only.
  • retry(10) schedules eligibility after 10 ms, overriding the policy delay. It is not a real-time delivery guarantee.
  • deadLetter() records durable terminal intent before writing to the DLQ. Exhaustion does the same automatically.
  • Returning, throwing, or timing out leaves unsettled messages eligible for retry. A second or late settlement call rejects with FanoutSettlementError.

Each settlement call is an RPC back into the Durable Object plus a storage transaction. For consumers that only ever acknowledge the whole batch, set ackOnReturn: true on the target: when deliver returns without throwing, every message the consumer did not settle explicitly is acknowledged in one transaction, without a call per message. Explicit retry() and deadLetter() calls still apply, and a throw or timeout acknowledges nothing.

targets: {
  consumer: {
    deliver: (messages) => this.env.CONSUMER.consume(messages),
    ackOnReturn: true,
  },
},
batchDelayMs: 100,

Await settlement calls. Timed-out RPCs cannot be forcibly cancelled, so side effects may overlap later attempts. Storage targets automatically acknowledge successful writes; failed writes retry independently of consumer targets. Their write contract must be idempotent by the supplied delivery identity.

DLQ storage and per-target redrive

Fanout only ever writes to storage, so a storage target and the dlq are both just a write function:

type MessageWrite<Body> = (message: StoredMessage<Body>) => Promise<void>;

No adapter class or storage interface ships with the library: R2, DO storage, D1, or an HTTP endpoint are all one callback. What separates an archive from a dead letter is the role in the flow, not the type — a storage target receives every message on the success path, while dlq receives only what a target gave up on. Both may write to the same bucket under different prefixes.

A stored entry carries id (unique per registration and target), messageId, target, body, enqueuedAt, storedAt, and attempts; only dead letters also carry failure: { reason, error }.

Two write invariants are the application’s to uphold:

  • Key each entry by id and refuse to overwrite it. id is stable across delivery attempts, so a retried write collapses onto the same entry (onlyIf: { etagDoesNotMatch: '*' } on R2) and a redrive deletes exactly what it re-enqueued. attempts and storedAt advance between attempts, so entries are not byte-identical.
  • Delete only after acceptance. Reading entries back for replay or redrive is application policy, so nothing in the library lists or removes them.

Retention is likewise external: filter on storedAt when selecting entries to redrive, and configure a lifecycle deletion rule on the prefix (for example 30 days on a DLQ prefix, unbounded for an archive) for physical expiry. A failed dead-letter write retains the terminal intent with a 60-second backoff rather than redelivering to the consumer. Omitting dlq drops terminal deliveries: nothing could ever accept them, and retrying would keep the object awake indefinitely. The terminal lifecycle event still reports each one.

Redrive is explicit application code, not queue sugar. Walk your own storage, select what to replay, then enqueue only the failed target:

const result = await routing.push({
  messages: [
    { id: entry.messageId, body: entry.body, deduplicationKey: entry.id },
  ],
  options: { targets: [entry.target] },
});
if (result.success) {
  await dlq.remove(entry.id);
}

Here the application RPC forwards messages and options to fanout.enqueue. A fresh registration resets attempts; using the DLQ entry ID as the key avoids colliding with the original message’s still-running siblings. Never remove an entry on rejection or transport uncertainty. A crash between re-enqueue and removal may still duplicate delivery.

Type to search…

↑↓ navigate↵ selectEsc close