跳到內容

ch14-durable-objects

取得並執行

這個範例可以獨立 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

說明

Companion example for docs/14-durable-objects.md.

Terminal window
npm install && npm run dev
Terminal window
B=localhost:8787
curl -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"

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 /serializelostUpdates becomes false. That await is the entire difference.

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.

$ time curl -s localhost:8787/parallel # 3 objects, 300ms each
real 0m0.406s

0.4s, not 0.9s. Serialization is per-object, which is why the partition key sets your scaling ceiling.

{"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.”

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"] }