ch14-durable-objects
對應 14. Durable Objects:邊緣上的 actor
在 GitHub 上檢視·4 個檔案·7.9 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch14-durable-objects
cd ch14-durable-objects
npm install可用指令
npm run dev # wrangler dev
npm run typecheck # tsc --noEmit
npm run cf-typegen # wrangler types --env-interface CloudflareBindings說明
ch14 — Durable Objects as actors
Section titled “ch14 — Durable Objects as actors”Companion example for docs/14-durable-objects.md.
npm install && npm run devB=localhost:8787curl -s "$B/addressing"curl -s "$B/identity"curl -s "$B/serialize"curl -s "$B/parallel"curl -s "$B/persisted"curl -s "$B/waituntil"curl -s "$B/viafetch"Verified findings
Section titled “Verified findings”2026-08-01 · wrangler@4.114.0 · workerd@1.20260722.1
Single-threaded does NOT mean no interleaving
Section titled “Single-threaded does NOT mean no interleaving”Three concurrent RPC calls to the same object, doing read → await → write:
{"calls":[{"before":0,"after":1},{"before":0,"after":1},{"before":0,"after":1}], "lostUpdates":true}All three read 0 and all three wrote 1. Three increments left one.
The input gate only closes during a storage operation
(docs: “While a storage operation is executing, no events shall be delivered
to the object except for storage completion events”). scheduler.wait() is
not a storage operation, so that await yields and events get delivered.
Rule: awaiting storage is safe; awaiting anything else interleaves.
Exercise: delete the await scheduler.wait(delayMs) from slowIncrement
and re-run /serialize — lostUpdates becomes false. That await is the
entire difference.
Additive writes do not lose data
Section titled “Additive writes do not lose data”Same three concurrent calls, but the counter is an INSERT:
{"calls":[{"before":0,"after":1},{"before":0,"after":2},{"before":0,"after":3}]}Reads all saw 0, but no write was lost — because INSERT is not
read-modify-write.
Different objects really are parallel
Section titled “Different objects really are parallel”$ time curl -s localhost:8787/parallel # 3 objects, 300ms eachreal 0m0.406s0.4s, not 0.9s. Serialization is per-object, which is why the partition key sets your scaling ceiling.
Addressing
Section titled “Addressing”{"idFromName":"71f1b2461b763e01aec0a7a6858d0fef683e94d2ce3e8cab502082b71d245f27", "sameNameSameId":true, "nameOnNamedId":"link:abc", "nameOnUniqueId":null, "uniqueIsDifferent":true, "idFromStringRoundTrips":true, "nameSurvivesIdFromString":null, "badIdFromString":{"threw":"TypeError: Invalid Durable Object ID: must be 64 hex digits"}}idFromName is deterministic. ctx.id.name is populated only for named
objects — and it does not survive an idFromString() round trip, even
though the id itself compares equal. Serialize the id and you lose the name.
waitUntil is a no-op inside a Durable Object
Section titled “waitUntil is a no-op inside a Durable Object”{"withWaitUntil":1,"bare":1}Identical. A bare fire-and-forget promise completes just as well — the
opposite of chapter 03, where the same pattern in a Worker was discarded.
Docs: “Unlike in Workers, waitUntil has no effect in Durable Objects.”
exports vs migrations
Section titled “exports vs migrations”Both present:
✘ [ERROR] `migrations` and `exports` are mutually exclusive. Choose one or the other to declare your Durable Object lifecycle, but not both.Neither present:
▲ [WARNING] you have configured `durable_objects` exported by this Worker (LinkCounter), but no live `exports` entry for them. ... Add the following configuration: { "exports": { "LinkCounter": { "type": "durable-object", "storage": "sqlite" } } }The warning fires on missing lifecycle config and now suggests exports.
migrations itself is not deprecated — but moving to exports is a
one-way door.
原始碼
wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch14-durable-objects",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"observability": { "enabled": true },
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "LinkCounter" }]
},
// Declarative lifecycle, wrangler >= 4.107.0. Replaces the legacy form:
// "migrations": [{ "tag": "v1", "new_sqlite_classes": ["LinkCounter"] }]
//
// The two are mutually exclusive, and moving to `exports` is a one-way door:
// once deployed with `exports`, a Worker cannot go back to `migrations`.
"exports": {
"LinkCounter": { "type": "durable-object", "storage": "sqlite" }
}
}package.json
{ "name": "ch14-durable-objects", "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 14 — Durable Objects as actors.
// ---------------------------------------------------------------------------
export class LinkCounter extends DurableObject<CloudflareBindings> {
// In-memory only. Reset whenever the object is evicted — see chapter 15
// for the storage that actually survives.
private inMemoryHits = 0;
private readonly bornAt = Date.now();
constructor(ctx: DurableObjectState, env: CloudflareBindings) {
super(ctx, env);
// The canonical use of blockConcurrencyWhile: async setup that must finish
// before ANY event is delivered.
ctx.blockConcurrencyWhile(async () => {
ctx.storage.sql.exec(
`CREATE TABLE IF NOT EXISTS hits (id INTEGER PRIMARY KEY, at INTEGER NOT NULL)`,
);
});
}
// --- RPC methods: any public method is callable as stub.method() ----------
/** Who am I? ctx.id.name is only populated for named objects. */
identity() {
return {
idHex: this.ctx.id.toString(),
name: this.ctx.id.name ?? null,
jurisdiction: this.ctx.id.jurisdiction ?? null,
bornAt: this.bornAt,
inMemoryHits: this.inMemoryHits,
};
}
/**
* Deliberately slow, to prove the single-threaded model.
* Two concurrent callers cannot interleave: the second one's read/modify/write
* starts only after the first has fully returned.
*/
async slowIncrement(delayMs: number): Promise<{ before: number; after: number }> {
const before = this.inMemoryHits;
await scheduler.wait(delayMs); // a NON-storage await: the gate is open here
this.inMemoryHits = before + 1;
return { before, after: this.inMemoryHits };
}
/** Same shape, but the counter lives in storage. */
async persistedIncrement(delayMs: number): Promise<{ before: number; after: number }> {
const row = this.ctx.storage.sql
.exec<{ n: number }>("SELECT COUNT(*) AS n FROM hits")
.one();
const before = row.n;
await scheduler.wait(delayMs);
this.ctx.storage.sql.exec("INSERT INTO hits (at) VALUES (?)", Date.now());
const after = this.ctx.storage.sql
.exec<{ n: number }>("SELECT COUNT(*) AS n FROM hits")
.one().n;
return { before, after };
}
/**
* Docs: "Unlike in Workers, waitUntil has no effect in Durable Objects."
* Compare with chapter 03, where a fire-and-forget promise in a Worker was
* silently discarded. `mode` lets us test both paths.
*/
async waitUntilProbe(mode: "waituntil" | "bare"): Promise<{ scheduled: boolean }> {
const marker = mode === "waituntil" ? -1 : -2;
const task = (async () => {
await scheduler.wait(1500);
this.ctx.storage.sql.exec("INSERT INTO hits (at) VALUES (?)", marker);
})();
if (mode === "waituntil") this.ctx.waitUntil(task);
return { scheduled: true };
}
async countMarkers(): Promise<{ withWaitUntil: number; bare: number }> {
const q = (m: number) =>
this.ctx.storage.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM hits WHERE at = ?", m).one().n;
return { withWaitUntil: q(-1), bare: q(-2) };
}
async reset(): Promise<void> {
this.inMemoryHits = 0;
this.ctx.storage.sql.exec("DELETE FROM hits");
}
// fetch() is RESERVED — it must take a Request and return a Response.
override async fetch(request: Request): Promise<Response> {
return Response.json({ via: "fetch", path: new URL(request.url).pathname });
}
}
const t = async (fn: () => Promise<unknown>): Promise<unknown> => {
try { return { ok: await fn() }; }
catch (e) { return { threw: String(e).slice(0, 200) }; }
};
export default {
async fetch(request: Request, env: CloudflareBindings): Promise<Response> {
const url = new URL(request.url);
const ns = env.COUNTER;
switch (url.pathname) {
// getByName(name) === get(idFromName(name)).
case "/addressing": {
const viaName = ns.idFromName("link:abc");
const viaName2 = ns.idFromName("link:abc");
const unique = ns.newUniqueId();
const roundTripped = ns.idFromString(viaName.toString());
return Response.json({
idFromName: viaName.toString(),
sameNameSameId: viaName.equals(viaName2),
nameOnNamedId: viaName.name ?? null,
nameOnUniqueId: unique.name ?? null,
uniqueIsDifferent: !unique.equals(viaName),
idFromStringRoundTrips: roundTripped.equals(viaName),
nameSurvivesIdFromString: roundTripped.name ?? null,
badIdFromString: await t(async () => ns.idFromString("not-a-valid-id").toString()),
});
}
case "/identity": {
const named = await ns.getByName("link:abc").identity();
const anon = await ns.get(ns.newUniqueId()).identity();
return Response.json({ named, anon });
}
// The headline: two concurrent callers cannot interleave.
case "/serialize": {
await ns.getByName("link:abc").reset();
const stub = ns.getByName("link:abc");
const [a, b, c] = await Promise.all([
stub.slowIncrement(300),
stub.slowIncrement(300),
stub.slowIncrement(300),
]);
return Response.json({ calls: [a, b, c], lostUpdates: a.after === b.after });
}
// Same test, but against DIFFERENT objects — these really are parallel.
case "/parallel": {
const results = await Promise.all(
["a", "b", "c"].map((k) => ns.getByName(`link:${k}`).slowIncrement(300)),
);
return Response.json({ results });
}
case "/persisted": {
await ns.getByName("link:abc").reset();
const stub = ns.getByName("link:abc");
const r = await Promise.all([
stub.persistedIncrement(200),
stub.persistedIncrement(200),
stub.persistedIncrement(200),
]);
return Response.json({ calls: r });
}
case "/waituntil": {
const stub = ns.getByName("link:wu");
await stub.reset();
await stub.waitUntilProbe("waituntil");
await stub.waitUntilProbe("bare");
await scheduler.wait(2500);
return Response.json(await stub.countMarkers());
}
// fetch() still works and is a different calling convention.
case "/viafetch": {
const res = await ns.getByName("link:abc").fetch(new Request("https://do/hello"));
return Response.json({ status: res.status, body: await res.json() });
}
default:
return new Response(
"try /addressing /identity /serialize /parallel /persisted /waituntil /viafetch\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"] }