予約の有効期限をスケジュールする

CloudflareBeginner
オンラインで実践に進む

はじめに

一時的な予約は、誰も再びアプリケーションにアクセスしなくても自動的に解放される必要があります。Worker は alarm が発生する前にアイドル状態になったり再起動したりする可能性があるため、メモリ上の JavaScript タイマーは安全ではありません。これに対して Durable Object alarm は、オブジェクトの永続状態とともに、将来の起動時刻を 1 つ保存します。その時刻になると、Cloudflare がオブジェクトを起動し、その alarm() メソッドを呼び出します。

Alarm は 少なくとも 1 回実行(at-least-once) されます。Cloudflare は失敗したハンドラーを再試行するため、同じ処理が複数回試みられる可能性があります。そのため、有効期限処理は べき等(idempotent) でなければなりません。つまり、複数回実行しても、1 回だけ実行した場合と同じ最終状態になる必要があります。この実験では条件付き SQLite 更新を使い、held 状態の予約だけを expired に変更します。カウンターも同じ更新で増加するため、再実行時には増加しません。

この実験では、予約ごとに名前付き Durable Object を 1 つ使用します。そのため、各予約が、そのオブジェクトで利用できる 1 つの alarm スロットを所有します。短時間および長時間の予約をスケジュールし、片方の alarm が発生する前にローカルランタイムを再起動します。その後、有効期限処理を意図的に 2 回再実行し、Cloudflare 上で実際の alarm をテストし、Dashboard を確認してから再デプロイし、リソースを削除します。

このコースを直接開始する前に、LabEx を Cloudflare アカウントに接続する を完了してください。新しい VM ごとに Wrangler の認証が必要です。O01~O03 で扱った、安定したオブジェクト名、RPC、SQLite ベースの状態管理、同時実行数の制限について、すでに理解していることを前提とします。

同じオブジェクトに対して setAlarm() をもう一度呼び出すと、そのオブジェクトの alarm は置き換えられます。他の名前付きオブジェクトは、それぞれ独自の alarm を保持します。セットアップでは、Node.js 22.22.0 とプロジェクトローカルの Wrangler 4.132.0 を /home/labex/project/reservation-expiry にインストールします。Cloudflare の認証、alarm の作成、Worker のデプロイは行いません。

VM を認証し、alarm の名前空間を宣言する

このステップでは、新しい VM を認証し、スケジュールされた予約を管理する SQLite ベースの Durable Object クラスを 1 つ宣言します。

プロジェクトに移動し、固定された Wrangler のバージョンを確認して、この新しい VM を認証します。

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

Wrangler のバージョンとして 4.132.0 が表示されます。表示された Cloudflare URL をブラウザーで開き、短いコードを入力して、使用する学習用アカウントを確認し、認証を許可してください。パスワードやトークンをターミナルまたは実験環境に貼り付けないでください。

安全な ID 情報だけを読み取り、確認したアカウントを選択して、一時利用用の一意な 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 に基づいてオブジェクトを選択できます。クラスの export によって、選択された各オブジェクトに専用の SQLite ストレージと 1 つの alarm スロットが与えられます。デプロイするまで、Cloudflare 上には何も作成されません。

永続的でべき等な有効期限処理を実装する

このステップでは、永続的な予約状態、alarm のスケジュール、再実行しても安全な有効期限遷移を実装します。

アプリケーションを作成します。重要なのは HTTP の処理ではなく、条件付きの UPDATE です。

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() を使うと、スケジュールを確認できます。期限になると、1 つの SQL 文が heldexpired に変更し、カウンターを増やします。再試行時にはすでに expired になっているため、WHERE status = 'held' の条件に一致する行は 0 件です。

/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

ローカル再起動後も alarm が動作することを確認する

このステップでは、ローカルで 2 つの予約を作成し、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 時間の予約を作成します。長い方の予約により、alarm の期限が来る前に 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 が表示されます。最初の alarm の期限が来る前にランタイムを停止し、同じローカルの Durable Object ストレージを再び開きます。

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

短い予約は 1 回だけ期限切れになり、その alarm は null になります。無関係なオブジェクトは独自の alarm を保持したまま held の状態です。これが、永続的な alarm と setTimeout() の違いです。

有効期限処理を再実行しても 2 回適用されないことを確認する

このステップでは、同じ論理的な期限を使って有効期限処理を 2 回呼び出し、両方の結果を比較します。

決定的なテスト中に実際の alarm が競合しないよう、長時間の予約を作成します。保存された期限を取得してください。

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')"

期限の直後を示す時刻を指定して、同じ有効期限メソッドを 2 回呼び出します。

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

1 回目のレスポンスには changed: true、2 回目のレスポンスには changed: false が表示されます。どちらも最終的に expired になり、expirationCount: 1 になります。これが実際のべき等性です。プラットフォームが先行する試行の完了を判断できない場合でも、再試行して安全に処理できます。

Cloudflare 上で実際の alarm を実行する

このステップでは、一時的な名前空間をデプロイし、Cloudflare の実際の alarm が独立して動作することを確認します。

ローカルランタイムを停止し、一時利用の 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 のルートと名前空間は、少し異なるタイミングで利用可能になる場合があります。安全な読み取りリクエストを繰り返してから、短い予約と長い予約を 1 つずつ作成します。

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 上の短い予約は、正確に 1 回だけ期限切れになる必要があります。無関係な長い予約は有効なままです。以下のチェックでは、実行ごとに一意な新しいオブジェクトも作成し、実際の alarm テストと再実行テストを独立して繰り返します。

名前空間、バインディング、alarm のログを確認する

このステップでは、ランタイムの実行結果を Dashboard の表示と照合し、再デプロイ後も状態が保持されることを確認します。

Cloudflare Dashboard で Workers & Pages を開き、$RUN に保存されている名前と完全に一致する Worker を選択して、Settings > Bindings を開きます。RESERVATIONS の行が ReservationExpiry を指していることを確認してください。バインディングは入口となる Worker から名前空間へ入る経路であり、個別の予約 1 件を表すものではありません。

RESERVATIONS Durable Object binding points to ReservationExpiry

Durable Objects を開き、同じ Worker に関連付けられた名前空間を選択して、SQLite ストレージが使用されていることを確認します。この名前空間は、この実験で作成されたすべての名前付き予約オブジェクトの集合です。

The owned ReservationExpiry namespace uses SQLite storage

名前空間の Logs タブを開き、詳細に eventType: "alarm" と表示される行を選択します。テストしたイベントには entrypoint: "ReservationExpiry"outcome: "ok" も表示されます。これにより、スケジュールされた起動が、作成したクラスに到達したことを確認できます。障害の診断では、ハンドラー独自の retryCountisRetry のログフィールドが役立ちます。ただし、正しさを支えるのは永続的な条件付き状態であり、最初の試行が必ず成功すると仮定してはいけません。

A successful alarm event names the ReservationExpiry entrypoint

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

alarm の名前空間を削除する

このステップでは、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" の tombstone により、この実験のクラス名前空間だけが削除されます。そこには、一時的なオブジェクトと、残っている長時間の alarm が含まれます。

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 キーを押します。コマンドの最後に、生成された Worker 名に続いて Successfully deleted と表示されます。

このステップの最後に行うチェックまで、この 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 ごとに一時的な予約を 1 つ作成し、各オブジェクトに永続的な alarm を 1 つ設定しました。スケジュールされた有効期限処理がローカルランタイムの再起動後も動作すること、別の予約が有効なまま維持されることを確認し、条件付き SQLite 遷移によって有効期限処理を繰り返しても安全になることを実証しました。Cloudflare 上でも実際の alarm の動作を繰り返し、バインディング、名前空間、ログを確認しました。さらに、再デプロイ後の状態を検証し、ログアウトする前に対象となる一時的な名前空間を削除しました。

再利用できる設計原則は次のとおりです。将来の処理は永続的にスケジュールし、同じ処理が再び試みられることを前提にし、処理がすでに実行済みかどうかを処理自身が判断できるだけの状態を保存してください。