简介
即使之后没有人再次访问应用,临时预订也应该自动释放。基于内存的 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
两个响应都应显示 held、expirationCount: 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 > Bindings。RESERVATIONS 行应指向 ReservationExpiry。绑定是入口 Worker 进入命名空间的路由,不是某一个单独的预订。

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

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

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 才能区分已验证的删除结果与网络或身份验证失败。
确认 $RUN 以 labex-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 上重复验证了真实闹钟行为,检查了绑定、命名空间和日志,验证了重新部署后的状态,并在退出登录前删除了准确的一次性命名空间。
可复用的设计原则是:持久化地安排未来工作,假设工作可能会被重复尝试,并保存足够的状态,让效果本身判断是否已经发生。



