跳到內容

ch17-do-alarms

取得並執行

這個範例可以獨立 clone 執行,不依賴其他章節。

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch17-do-alarms
cd ch17-do-alarms
npm install

可用指令

npm run dev	# wrangler dev
npm run typecheck	# tsc --noEmit
npm run cf-typegen	# wrangler types --env-interface CloudflareBindings

說明

Companion example for docs/17-do-alarms.md.

Terminal window
npm install && npm run dev
Terminal window
B=localhost:8787
curl -s "$B/reset"
curl -s "$B/armtwice"
curl -s "$B/deleteall?o=da"
curl -s "$B/hit?n=5"
sleep 4 && curl -s "$B/state"

2026-08-01 · wrangler@4.114.0 · workerd@1.20260722.1

{"first":1785557688457,"afterFirst":1785557688457,
"second":1785557718457,"afterSecond":1785557718457,
"afterDelete":null,"overwritten":true}
{"alarmBefore":1785557748545,"alarmAfter":null,"cleared":true}

Default for compatibility dates from 2026-02-24 (delete_all_deletes_alarm). Combined with chapter 15’s finding that deleteAll() also drops the SQL tables, it is more destructive than most people expect.

{"results":[{"armedNew":true,"alarmAt":1785557630671},
{"armedNew":false,"alarmAt":1785557630671},
{"armedNew":false,"alarmAt":1785557630671},
{"armedNew":false,"alarmAt":1785557630671},
{"armedNew":false,"alarmAt":1785557630671}]}
{"alarm":null,"pending":0,
"events":[{"kind":"alarm","note":"retryCount=0 isRetry=false mode=ok"},
{"kind":"flush","note":"n=5"}]}

The getAlarm() === null guard is the whole pattern — without it, every hit pushes the flush further out and it never runs under sustained traffic.

An alarm handler that always throws, polled for 200s:

t+20s: attempts=4 alarmPending=True [retryCount=0,1,2,3]
t+40s: attempts=5 alarmPending=True [retryCount=0..4]
t+80s: attempts=6 alarmPending=True [retryCount=0..5]
t+140s: attempts=7 alarmPending=False [retryCount=0..6]
t+200s: attempts=7 alarmPending=False (no further change)

1 initial attempt plus 6 retries, cumulative delay 2+4+8+16+32+64 = 126s. Then the alarm is dropped with no error event, no dead letter and no notification. Log on retryCount >= 6 — it is the only signal that a scheduled job is about to vanish permanently.

Failing twice then succeeding:

retryCount=0 isRetry=false
retryCount=1 isRetry=true
retryCount=2 isRetry=true <- succeeded, alarm cleared

alarmInfo is an optional parameter — read it as alarmInfo?.retryCount ?? 0.

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch17-do-alarms",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-24",
  "observability": { "enabled": true },
  "durable_objects": { "bindings": [{ "name": "AGG", "class_name": "Aggregator" }] },
  "exports": { "Aggregator": { "type": "durable-object", "storage": "sqlite" } }
}

package.json

{ "name": "ch17-do-alarms", "private": true, "type": "module",
  "scripts": { "dev": "wrangler dev", "typecheck": "tsc --noEmit",
    "cf-typegen": "wrangler types --env-interface CloudflareBindings" },
  "devDependencies": { "typescript": "^5.9.0", "wrangler": "^4.114.0" } }

src/index.ts

import { DurableObject } from "cloudflare:workers";

// ---------------------------------------------------------------------------
// Chapter 17 — Alarms: scheduling, retries, and debouncing.
// ---------------------------------------------------------------------------
export class Aggregator extends DurableObject<CloudflareBindings> {
  constructor(ctx: DurableObjectState, env: CloudflareBindings) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => this.ensureSchema());
  }

  private ensureSchema() {
    this.ctx.storage.sql.exec(
      `CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, kind TEXT NOT NULL, at INTEGER NOT NULL, note TEXT) STRICT`,
    );
    this.ctx.storage.sql.exec(
      `CREATE TABLE IF NOT EXISTS pending (id INTEGER PRIMARY KEY, n INTEGER NOT NULL) STRICT`,
    );
    this.ctx.storage.sql.exec(`INSERT OR IGNORE INTO pending (id, n) VALUES (1, 0)`);
  }

  private log(kind: string, note = "") {
    this.ctx.storage.sql.exec(
      "INSERT INTO events (kind, at, note) VALUES (?, ?, ?)", kind, Date.now(), note,
    );
  }

  // --- Debounce: many writes, one flush ------------------------------------
  /**
   * The canonical alarm pattern. Every hit bumps a counter and ARMS an alarm
   * only if one is not already set — so a thousand hits still produce one flush.
   */
  async hit(): Promise<{ armedNew: boolean; alarmAt: number | null }> {
    this.ctx.storage.sql.exec("UPDATE pending SET n = n + 1 WHERE id = 1");
    const existing = await this.ctx.storage.getAlarm();
    if (existing === null) {
      const at = Date.now() + 2000;
      this.ctx.storage.setAlarm(at);
      return { armedNew: true, alarmAt: at };
    }
    return { armedNew: false, alarmAt: existing };
  }

  // --- Failure injection ----------------------------------------------------
  private get mode(): string {
    return (this.ctx.storage.kv.get("mode") as string | undefined) ?? "ok";
  }
  async setMode(mode: "ok" | "throw" | "throw-twice"): Promise<void> {
    this.ctx.storage.kv.put("mode", mode);
  }

  async armIn(ms: number): Promise<number> {
    const at = Date.now() + ms;
    this.ctx.storage.setAlarm(at);
    return at;
  }

  /** setAlarm overwrites — only one alarm per object. */
  async armTwice(): Promise<unknown> {
    const first = Date.now() + 60_000;
    this.ctx.storage.setAlarm(first);
    const afterFirst = await this.ctx.storage.getAlarm();
    const second = Date.now() + 90_000;
    this.ctx.storage.setAlarm(second);
    const afterSecond = await this.ctx.storage.getAlarm();
    await this.ctx.storage.deleteAlarm();
    const afterDelete = await this.ctx.storage.getAlarm();
    return { first, afterFirst, second, afterSecond, afterDelete, overwritten: afterSecond === second };
  }

  /** Does deleteAll() clear the alarm? Default since compat date 2026-02-24. */
  async deleteAllVsAlarm(): Promise<unknown> {
    this.ensureSchema();
    this.ctx.storage.setAlarm(Date.now() + 120_000);
    const before = await this.ctx.storage.getAlarm();
    await this.ctx.storage.deleteAll();
    const after = await this.ctx.storage.getAlarm();
    this.ensureSchema();     // deleteAll dropped the tables (chapter 15)
    return { alarmBefore: before, alarmAfter: after, cleared: after === null };
  }

  async alarm(alarmInfo?: { retryCount: number; isRetry: boolean }): Promise<void> {
    const mode = this.mode;
    this.log(
      "alarm",
      `retryCount=${alarmInfo?.retryCount ?? "undefined"} isRetry=${alarmInfo?.isRetry ?? "undefined"} mode=${mode}`,
    );

    if (mode === "throw") throw new Error("deliberate alarm failure");
    if (mode === "throw-twice" && (alarmInfo?.retryCount ?? 0) < 2) {
      throw new Error(`deliberate failure #${(alarmInfo?.retryCount ?? 0) + 1}`);
    }

    // Success path: flush the debounced counter.
    const { n } = this.ctx.storage.sql
      .exec<{ n: number }>("SELECT n FROM pending WHERE id = 1").one();
    if (n > 0) {
      this.log("flush", `n=${n}`);
      this.ctx.storage.sql.exec("UPDATE pending SET n = 0 WHERE id = 1");
    }
  }

  async state(): Promise<unknown> {
    return {
      alarm: await this.ctx.storage.getAlarm(),
      pending: this.ctx.storage.sql.exec<{ n: number }>("SELECT n FROM pending WHERE id = 1").one().n,
      events: this.ctx.storage.sql
        .exec<{ kind: string; at: number; note: string }>("SELECT kind, at, note FROM events ORDER BY id")
        .toArray(),
    };
  }

  async reset(): Promise<void> {
    this.ensureSchema();
    this.ctx.storage.sql.exec("DELETE FROM events");
    this.ctx.storage.sql.exec("UPDATE pending SET n = 0 WHERE id = 1");
    this.ctx.storage.kv.put("mode", "ok");
    await this.ctx.storage.deleteAlarm();
  }
}

export default {
  async fetch(request: Request, env: CloudflareBindings): Promise<Response> {
    const url = new URL(request.url);
    const name = url.searchParams.get("o") ?? "demo";
    const a = env.AGG.getByName(name);

    switch (url.pathname) {
      case "/reset":     await a.reset(); return Response.json({ ok: true });
      case "/hit": {
        const n = Number(url.searchParams.get("n") ?? 1);
        const results = [];
        for (let i = 0; i < n; i++) results.push(await a.hit());
        return Response.json({ results });
      }
      case "/state":     return Response.json(await a.state());
      case "/armtwice":  return Response.json(await a.armTwice());
      case "/deleteall": return Response.json(await a.deleteAllVsAlarm());
      case "/mode":      await a.setMode(url.searchParams.get("m") as never); return Response.json({ ok: true });
      case "/arm":       return Response.json({ at: await a.armIn(Number(url.searchParams.get("ms") ?? 1000)) });
      default:
        return new Response("try /reset /hit?n=5 /state /armtwice /deleteall /mode?m=throw /arm?ms=1000\n", { status: 404 });
    }
  },
} satisfies ExportedHandler<CloudflareBindings>;

tsconfig.json

{ "compilerOptions": { "target": "esnext", "lib": ["esnext"], "module": "esnext",
  "moduleResolution": "bundler", "types": ["./worker-configuration.d.ts"],
  "strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true },
  "include": ["src/**/*.ts", "worker-configuration.d.ts"] }