安排预订过期

CloudflareBeginner
立即练习

简介

即使之后没有人再次访问应用,临时预订也应该自动释放。基于内存的 JavaScript 计时器并不安全,因为 Worker 可能在计时器触发前进入空闲状态或重启。Durable Object 闹钟则会将一个未来的唤醒时间与对象的持久化状态一起保存。到达该时间后,Cloudflare 会唤醒对象并调用它的 alarm() 方法。

闹钟采用至少一次执行语义:如果处理器执行失败,Cloudflare 会重试,因此同一个预期效果可能会被尝试多次。过期操作因此必须具备幂等性——执行多次后得到的最终状态,必须与执行一次相同。你将使用条件式 SQLite 更新,只允许状态为 held 的预订变为 expired;计数器也会在同一次更新中增加,因此重放时不会再次增加。

本实验为每个预订使用一个具名 Durable Object。因此,每个预订都拥有其对象可用的唯一闹钟槽位。你将安排短时和长时预订,在某个闹钟触发前重启本地运行时,主动重放两次过期路径,在 Cloudflare 上重复真实闹钟测试,检查 Dashboard,重新部署并完成清理。

在直接进入本课程之前,请完成将 LabEx 连接到 Cloudflare 账户每台全新的 VM 都需要单独完成 Wrangler 授权。你应该已经掌握 O01–O03 中介绍的稳定对象名称、RPC、基于 SQLite 的状态管理和有界并发。

再次调用 setAlarm() 会替换同一对象已有的闹钟;其他具名对象仍保留各自的闹钟。安装过程会在 /home/labex/project/reservation-expiry 中安装 Node.js 22.22.0 和项目本地的 Wrangler 4.132.0,但不会授权 Cloudflare、创建闹钟或部署 Worker。

授权 VM 并声明闹钟命名空间

在本步骤中,你将为全新的 VM 完成授权,并声明一个由 SQLite 支持、用于安排预订的 Durable Object 类。

进入项目目录,确认固定的 Wrangler 版本,并为这台全新的 VM 完成授权:

cd /home/labex/project/reservation-expiry
npx wrangler --version
npx wrangler login --device --browser=false

Wrangler 应显示 4.132.0。在浏览器中打开显示的 Cloudflare URL,输入短代码,确认目标学习账户并完成授权。不要将密码或令牌粘贴到终端或实验环境中。

只读取安全的身份字段,选择你已确认的账户,并生成唯一的一次性 Worker 名称:

WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"
RUN="labex-c10-o04-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"

如果你的专用学习账户使用其他显示名称,请替换为你确认的名称。创建配置文件:

cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/index.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true, "head_sampling_rate": 1 },
  "durable_objects": { "bindings": [
    { "name": "RESERVATIONS", "class_name": "ReservationExpiry" }
  ] },
  "exports": {
    "ReservationExpiry": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

RESERVATIONS 允许入口 Worker 根据预订 ID 选择对象。类导出会为每个被选中的对象提供私有 SQLite 存储和一个闹钟槽位。在部署之前,云端不会创建任何内容。

实现持久化且具备幂等性的过期处理

在本步骤中,你将实现持久化的预订状态、闹钟调度,以及能够安全应对重放的过期状态转换。

创建应用。关键部分是条件式 UPDATE,而不是 HTTP 处理逻辑:

cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";

const NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,38}[a-z0-9])$/;
const json = (body, status = 200) => Response.json(body, { status });

async function readBody(request) {
  try { return await request.json(); } catch { return null; }
}
function parsePath(pathname) {
  const match = pathname.match(/^\/reservations\/([^/]+)(?:\/(replay-alarm))?$/);
  if (!match || !NAME_PATTERN.test(match[1])) return null;
  return { reservationId: match[1], action: match[2] ?? null };
}

export class ReservationExpiry extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS reservation (
          singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
          status TEXT NOT NULL CHECK (status IN ('held', 'expired')),
          created_at INTEGER NOT NULL,
          expires_at INTEGER NOT NULL,
          expired_at INTEGER,
          expiration_count INTEGER NOT NULL DEFAULT 0
        )
      `);
    });
  }

  row() {
    return this.ctx.storage.sql.exec(`
      SELECT status, created_at, expires_at, expired_at, expiration_count
      FROM reservation WHERE singleton = 1
    `).one();
  }

  async createReservation(ttlSeconds) {
    const createdAt = Date.now();
    const expiresAt = createdAt + ttlSeconds * 1000;
    this.ctx.storage.sql.exec(`
      INSERT INTO reservation
        (singleton, status, created_at, expires_at, expired_at, expiration_count)
      VALUES (1, 'held', ?, ?, NULL, 0)
      ON CONFLICT(singleton) DO UPDATE SET
        status = 'held', created_at = excluded.created_at,
        expires_at = excluded.expires_at, expired_at = NULL,
        expiration_count = 0
    `, createdAt, expiresAt);
    await this.ctx.storage.setAlarm(expiresAt);
    return this.getStatus();
  }

  async getStatus() {
    const record = this.row();
    if (!record) return { status: "missing", alarmAt: await this.ctx.storage.getAlarm() };
    return {
      status: record.status,
      createdAt: record.created_at,
      expiresAt: record.expires_at,
      expiredAt: record.expired_at,
      expirationCount: record.expiration_count,
      alarmAt: await this.ctx.storage.getAlarm()
    };
  }

  async processExpiry(now = Date.now()) {
    const result = this.ctx.storage.sql.exec(`
      UPDATE reservation
      SET status = 'expired', expired_at = ?,
          expiration_count = expiration_count + 1
      WHERE status = 'held' AND expires_at <= ?
    `, now, now);
    const changed = result.rowsWritten === 1;
    if (changed) await this.ctx.storage.deleteAlarm();
    return { ...(await this.getStatus()), changed };
  }

  async alarm(alarmInfo) {
    console.log(JSON.stringify({
      event: "reservation-alarm",
      isRetry: alarmInfo?.isRetry ?? false,
      retryCount: alarmInfo?.retryCount ?? 0
    }));
    await this.processExpiry(Date.now());
  }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === "/") return json({ service: "reservation-expiry" });
    const parsed = parsePath(url.pathname);
    if (!parsed) return json({ error: "Use a lowercase reservation ID containing 3-40 letters, digits, or hyphens." }, 400);

    let body = null;
    if (request.method === "POST") {
      body = await readBody(request);
      if (!body) return json({ error: "Send a JSON request body." }, 400);
    }
    if (!parsed.action && request.method === "POST") {
      if (!Number.isInteger(body.ttlSeconds) || body.ttlSeconds < 5 || body.ttlSeconds > 3600) {
        return json({ error: "ttlSeconds must be an integer from 5 through 3600." }, 400);
      }
    } else if (parsed.action === "replay-alarm" && request.method === "POST") {
      if (!Number.isSafeInteger(body.now) || body.now < 1) return json({ error: "now must be a positive integer timestamp." }, 400);
    } else if (parsed.action || request.method !== "GET") {
      return json({ error: "Method not allowed." }, 405);
    }

    const stub = env.RESERVATIONS.getByName(parsed.reservationId);
    if (request.method === "GET") return json({ reservationId: parsed.reservationId, ...(await stub.getStatus()) });
    if (parsed.action === "replay-alarm") return json({ reservationId: parsed.reservationId, ...(await stub.processExpiry(body.now)) });
    return json({ reservationId: parsed.reservationId, ...(await stub.createReservation(body.ttlSeconds)) }, 201);
  }
};
JS

setAlarm(expiresAt) 会保存一个绝对 Unix 时间戳。getAlarm() 让你能够查看调度结果。到达过期时间后,一条 SQL 语句会将 held 改为 expired,并增加计数器。重试时状态已经是 expired,因此 WHERE status = 'held' 条件不会匹配任何行。

/replay-alarm 路由是专门用于测试的接口:它使用明确的时钟调用 alarm() 使用的同一个方法。这样无需人为制造 Cloudflare 故障,就能立即验证重放安全性。

运行确定性的路由测试和 Wrangler 构建:

NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run --outdir /tmp/o04-dry-run

证明闹钟能够跨本地重启保留

在本步骤中,你将安排两个本地预订,重启 Wrangler,并观察只有到期的预订会过期。

启动 Wrangler 的本地 Durable Object 运行时并等待它就绪:

rm -rf .wrangler/state
npx wrangler dev --local --ip 127.0.0.1 --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/ >/dev/null && break
  sleep 1
done
curl --silent --fail http://127.0.0.1:8787/ | jq

创建一个 30 秒的预订和另一个互不相关、持续 1 小时的预订。较长的短时预订会留出足够时间,让你在第一个闹钟到期前停止 Wrangler:

curl --silent --fail --request POST http://127.0.0.1:8787/reservations/local-expiring \
  --header 'content-type: application/json' --data '{"ttlSeconds":30}' | jq
curl --silent --fail --request POST http://127.0.0.1:8787/reservations/local-safe \
  --header 'content-type: application/json' --data '{"ttlSeconds":3600}' | jq

两个响应都应显示 heldexpirationCount: 0 和一个数值型 alarmAt。在第一个闹钟到期前停止运行时,然后重新打开同一份本地持久化存储:

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler dev --local --ip 127.0.0.1 --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/ >/dev/null && break
  sleep 1
done
for attempt in $(seq 1 50); do
  STATE="$(curl --silent --fail http://127.0.0.1:8787/reservations/local-expiring)"
  test "$(printf '%s' "$STATE" | jq -r .status)" = expired && break
  sleep 1
done
printf '%s\n' "$STATE" | jq
curl --silent --fail http://127.0.0.1:8787/reservations/local-safe | jq

短时预订应恰好过期一次,其闹钟为 null;互不相关的对象仍保持 held,并保留自己的闹钟。这就是持久化闹钟与 setTimeout() 的区别。

重放过期处理,但不重复应用状态变更

在本步骤中,你将使用同一个逻辑截止时间调用两次过期处理路径,并比较两次结果。

创建一个较长的预订,避免其真实闹钟与这次确定性检查竞争。记录保存的截止时间:

REPLAY="$(curl --silent --fail --request POST http://127.0.0.1:8787/reservations/replay-proof \
  --header 'content-type: application/json' --data '{"ttlSeconds":180}')"
printf '%s\n' "$REPLAY" | jq
REPLAY_NOW="$(printf '%s\n' "$REPLAY" | jq '.expiresAt + 1')"

使用刚刚超过截止时间的时钟,两次调用同一个过期处理方法:

curl --silent --fail --request POST http://127.0.0.1:8787/reservations/replay-proof/replay-alarm \
  --header 'content-type: application/json' --data "{\"now\":$REPLAY_NOW}" \
  | tee .labex/replay-first.json | jq
curl --silent --fail --request POST http://127.0.0.1:8787/reservations/replay-proof/replay-alarm \
  --header 'content-type: application/json' --data "{\"now\":$REPLAY_NOW}" \
  | tee .labex/replay-second.json | jq

第一次响应中的 changed 应为 true,第二次应为 false。两次响应的最终状态都是 expired,且 expirationCount: 1。这就是实际的幂等性:即使平台无法确定前一次尝试是否已经完成,重试也仍然安全。

在 Cloudflare 上运行真实闹钟

在本步骤中,你将部署一次性命名空间,并独立验证真实的 Cloudflare 闹钟。

停止本地运行时,并部署一次性 Worker:

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
DEPLOY_OUTPUT="$(npx wrangler deploy 2>&1 | tee /dev/tty)"
APP_URL="$(printf '%s\n' "$DEPLOY_OUTPUT" | grep -Eo 'https://[a-z0-9.-]+\.workers\.dev' | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL" | tee .labex/app-url

Worker 路由和命名空间可能不会同时可用。先轮询一个无副作用的读取请求,然后创建一个短时预订和一个长时预订:

for attempt in $(seq 1 30); do
  curl --silent --fail "$APP_URL/" >/dev/null && break
  sleep 1
done
curl --silent --fail --request POST "$APP_URL/reservations/cloud-expiring" \
  --header 'content-type: application/json' --data '{"ttlSeconds":12}' | jq
curl --silent --fail --request POST "$APP_URL/reservations/cloud-safe" \
  --header 'content-type: application/json' --data '{"ttlSeconds":3600}' | jq
for attempt in $(seq 1 50); do
  CLOUD_STATE="$(curl --silent --fail "$APP_URL/reservations/cloud-expiring")"
  test "$(printf '%s' "$CLOUD_STATE" | jq -r .status)" = expired && break
  sleep 1
done
printf '%s\n' "$CLOUD_STATE" | jq
curl --silent --fail "$APP_URL/reservations/cloud-safe" | jq

Cloudflare 上的短时预订必须恰好过期一次;互不相关的长时预订仍应有效。下面的检查还会创建本次运行独有的新对象,并分别重复真实闹钟测试和重放测试。

检查命名空间、绑定和闹钟日志

在本步骤中,你将把运行时证据与 Dashboard 视图对应起来,并验证重新部署后的状态。

在 Cloudflare Dashboard 中打开 Workers & Pages,选择名称与 $RUN 中保存的值完全一致的 Worker,然后打开 Settings > BindingsRESERVATIONS 行应指向 ReservationExpiry。绑定是入口 Worker 进入命名空间的路由,不是某一个单独的预订。

RESERVATIONS Durable Object 绑定指向 ReservationExpiry

打开 Durable Objects,选择与同一个 Worker 关联的命名空间,并确认它使用 SQLite 存储。该命名空间包含本实验创建的所有具名预订对象。

所属的 ReservationExpiry 命名空间使用 SQLite 存储

打开该命名空间的 Logs 标签页,选择一条详情中报告 eventType: "alarm" 的记录。测试事件还应报告 entrypoint: "ReservationExpiry"outcome: "ok",这表明计划中的唤醒调用了你编写的类。处理器自身记录的 retryCountisRetry 字段有助于诊断故障;但正确性仍然来自持久化的条件状态,而不是假设第一次尝试一定成功。

成功的闹钟事件显示 ReservationExpiry 入口

Dashboard 数据可能会在请求完成后才到达。运行时和 API 检查仍然是权威依据。这些图片来自经过测试的一次性运行,仅用于帮助你定位界面;你的后缀、时间戳和流量总量会有所不同。

重新部署未修改的代码,并证明两种状态都能保留:

npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/reservations/cloud-expiring" | jq
curl --silent --fail "$APP_URL/reservations/cloud-safe" | jq

删除闹钟命名空间

在本步骤中,你将在 VM 仍处于授权状态时,删除准确的一次性命名空间和 Worker。保留授权直到后端检查完成,LabEx 才能区分已验证的删除结果与网络或身份验证失败。

确认 $RUNlabex-c10-o04- 开头。创建一个无状态的清理入口:

cat > src/cleanup.js <<'JS'
export default {
  fetch() {
    return Response.json({ status: "cleanup" }, { status: 410 });
  }
};
JS

为同一个 Worker 和账户创建清理配置。state: "deleted" 墓碑只会删除本实验的类命名空间,包括其中的一次性对象和仍在等待触发的长时闹钟:

ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/cleanup.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "exports": {
    "ReservationExpiry": { "type": "durable-object", "state": "deleted" }
  }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc

协调输出应报告 Deleted: ReservationExpiry。删除剩余的无状态 Worker。由于删除操作无法撤销,Wrangler 会要求确认;只有在显示的名称与 $RUN 的值完全一致后,才确认删除:

npx wrangler delete --config wrangler.cleanup.jsonc

出现提示时,输入 y 并按 Enter 键。命令结束时应显示 Successfully deleted,后面跟着你生成的 Worker 名称。

在本步骤末尾的检查完成前,保持这台 VM 的授权状态。确认 Wrangler 仍报告已通过身份验证的会话:

npx wrangler whoami --json | jq '{loggedIn, authType}'

JSON 必须包含 "loggedIn": true。现在,LabEx 可以查询所选账户,并证明 Worker 及其 Durable Object 命名空间都已不存在。网络错误或身份验证错误不能证明清理成功。

撤销本 VM 的 Wrangler 授权

在本步骤中,在确认云资源已删除后,你将撤销仅存储在这台全新 VM 中的 OAuth 授权。

wrangler logout 会删除本地授权。结构化的 whoami --json 检查很重要,因为普通的人类可读输出可能存在歧义;loggedIn 字段才是权威结果:

npx wrangler logout
npx wrangler whoami --json

最终 JSON 必须包含 "loggedIn": false。这不会删除或退出你在浏览器中的 Cloudflare 学习账户,只会阻止这台 VM 继续发出经过身份验证的 Wrangler 请求。

总结

你为每个具名 Durable Object 构建了一个一次性预订,并为每个对象配置了一个持久化闹钟。你观察到计划中的过期处理能够跨本地运行时重启继续执行,证明了另一个预订仍然有效,并使用条件式 SQLite 状态转换保证重复执行过期处理的安全性。你还在 Cloudflare 上重复验证了真实闹钟行为,检查了绑定、命名空间和日志,验证了重新部署后的状态,并在退出登录前删除了准确的一次性命名空间。

可复用的设计原则是:持久化地安排未来工作,假设工作可能会被重复尝试,并保存足够的状态,让效果本身判断是否已经发生。