ch17-do-alarms
在 GitHub 上檢視·4 個檔案·6.7 KB
取得並執行
這個範例可以獨立 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說明
ch17 — Durable Object alarms
Section titled “ch17 — Durable Object alarms”Companion example for docs/17-do-alarms.md.
npm install && npm run devB=localhost:8787curl -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"Verified findings
Section titled “Verified findings”2026-08-01 · wrangler@4.114.0 · workerd@1.20260722.1
One alarm per object; setAlarm overwrites
Section titled “One alarm per object; setAlarm overwrites”{"first":1785557688457,"afterFirst":1785557688457, "second":1785557718457,"afterSecond":1785557718457, "afterDelete":null,"overwritten":true}deleteAll() clears the alarm
Section titled “deleteAll() clears the alarm”{"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.
Debounce: five hits, one flush
Section titled “Debounce: five hits, one flush”{"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.
Retries: 7 attempts, then silence
Section titled “Retries: 7 attempts, then silence”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=falseretryCount=1 isRetry=trueretryCount=2 isRetry=true <- succeeded, alarm clearedalarmInfo 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"] }