Введение
Временное бронирование должно освобождаться само, даже если приложение больше никто не открывает. Таймер JavaScript в памяти ненадёжен: Worker может перейти в режим ожидания или перезапуститься до срабатывания таймера. Будильник Durable Object вместо этого сохраняет одно будущее время пробуждения вместе с постоянным состоянием объекта. Когда это время наступает, Cloudflare пробуждает объект и вызывает его метод alarm().
Будильники работают по принципу как минимум один раз: Cloudflare повторно запускает обработчик, если тот завершился с ошибкой, поэтому один и тот же эффект может быть применён повторно. Операция истечения срока должна быть идемпотентной — многократный запуск должен приводить к тому же конечному состоянию, что и однократный запуск. Вы будете использовать условное обновление SQLite, чтобы только бронирование со статусом held могло перейти в expired; счётчик увеличивается в том же обновлении и не увеличивается при повторном запуске.
В этой лабораторной работе для каждого бронирования используется отдельный именованный Durable Object. Поэтому каждое бронирование владеет единственным доступным слотом будильника своего объекта. Вы запланируете короткие и длинные блокировки, перезапустите локальную среду до срабатывания одного из будильников, намеренно дважды повторите путь истечения срока, снова проверите настоящий будильник в Cloudflare, изучите Dashboard, выполните повторное развёртывание и удалите созданные ресурсы.
Перед непосредственным началом этого курса пройдите лабораторную работу Connect LabEx to Your Cloudflare Account. Для каждой новой виртуальной машины требуется отдельная авторизация Wrangler. Предполагается, что вы уже понимаете стабильные имена объектов, RPC, состояние на основе SQLite и ограничение параллелизма из O01–O03.
Повторный вызов setAlarm() заменяет будильник для того же объекта; другие именованные объекты сохраняют собственные будильники. В процессе настройки Node.js 22.22.0 и локальная для проекта версия Wrangler 4.132.0 устанавливаются в /home/labex/project/reservation-expiry. Настройка не выполняет авторизацию в Cloudflare, не создаёт будильник и не развёртывает Worker.
Авторизуйте виртуальную машину и объявите пространство имён будильников
На этом шаге вы авторизуете новую виртуальную машину и объявите один класс Durable Object с хранилищем SQLite для планируемых бронирований.
Перейдите в каталог проекта, проверьте закреплённую версию Wrangler и авторизуйте эту виртуальную машину:
cd /home/labex/project/reservation-expiry
npx wrangler --version
npx wrangler login --device --browser=false
Ожидаемая версия Wrangler — 4.132.0. Откройте показанный URL Cloudflare в браузере, введите короткий код, подтвердите нужный учебный аккаунт и авторизуйте его. Никогда не вставляйте пароль или токен в терминал или лабораторную среду.
Прочитайте только безопасные поля идентификации, выберите подтверждённый аккаунт и создайте уникальное имя временного 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 выбрать объект по идентификатору бронирования. Экспорт класса предоставляет каждому выбранному объекту собственное хранилище 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 и увидите, что истекает только та блокировка, срок которой наступил.
Запустите локальную среду Durable Object Wrangler и дождитесь её готовности:
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 секунд и независимую блокировку на один час. Более длительная короткая блокировка оставляет достаточно времени, чтобы остановить 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
Короткая блокировка должна перейти в expired ровно один раз, а её будильник должен иметь значение 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, во втором — changed: 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
Короткая облачная блокировка должна истечь ровно один раз; независимая длинная блокировка должна остаться действительной. Следующая проверка также создаёт новые объекты с уникальными именами запуска и независимо повторяет тесты настоящего будильника и повторного запуска.
Изучите пространство имён, привязку и журналы будильников
На этом шаге вы сопоставите свидетельства работы среды с представлениями Dashboard и проверите состояние после повторного развёртывания.
Откройте Workers & Pages в Cloudflare Dashboard, выберите Worker с точным именем, сохранённым в $RUN, и откройте 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
Удалите пространство имён будильников
На этом шаге вы удалите точное временное пространство имён и 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.
Оставьте эту виртуальную машину авторизованной для проверки в конце шага. Убедитесь, что Wrangler по-прежнему сообщает об аутентифицированной сессии:
npx wrangler whoami --json | jq '{loggedIn, authType}'
JSON должен содержать "loggedIn": true. Теперь LabEx может обратиться к выбранному аккаунту и подтвердить отсутствие Worker и его пространства имён Durable Object. Сетевая или аутентификационная ошибка не доказывает, что очистка выполнена.
Отзовите авторизацию Wrangler для этой виртуальной машины
На этом шаге вы отзовёте OAuth-авторизацию, сохранённую только на этой новой виртуальной машине, после подтверждения удаления облачных ресурсов.
wrangler logout удаляет локальные данные авторизации. Структурированная проверка whoami --json важна, поскольку обычный человекочитаемый вывод может быть неоднозначным; поле loggedIn содержит достоверный результат:
npx wrangler logout
npx wrangler whoami --json
Итоговый JSON должен содержать "loggedIn": false. Это не удаляет ваш учебный аккаунт Cloudflare и не завершает его сеанс в браузере; команда лишь запрещает этой виртуальной машине выполнять дальнейшие аутентифицированные запросы Wrangler.
Итоги
Вы создали отдельное временное бронирование для каждого именованного Durable Object и назначили каждому объекту один постоянный будильник. Вы увидели, что запланированное истечение срока переживает перезапуск локальной среды, подтвердили, что отдельное бронирование остаётся действительным, и использовали условный переход SQLite, чтобы повторное истечение было безопасным. Вы повторили работу настоящего будильника в Cloudflare, изучили привязку, пространство имён и журналы, проверили состояние после повторного развёртывания и удалили точное временное пространство имён перед выходом из системы.
Главное правило, которое можно применять повторно: храните будущую работу надёжно, исходите из того, что она может быть запущена повторно, и сохраняйте достаточно состояния, чтобы сама операция могла определить, была ли она уже выполнена.



