同時予約を調整する

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

はじめに

ワークショップの空席が4席しかないのに、10人がほぼ同時に Reserve をクリックすることがあります。すべてのリクエストが最初に reserved = 0 を読み取り、少し待ってから reserved = 1 を書き込むと、成功した予約が失われます。別の壊れた設計では、ワークショップの定員を超える予約を承認してしまう可能性があります。完了していない非同期タスク同士がこのように重なって実行されることを interleaving と呼びます。

この実験では、検証済みのワークショップ名ごとに1つの Durable Object を選択します。そのオブジェクトは、定員を保持する行と、すべての試行を記録する永続レコードを管理します。予約メソッドは、定員チェック、カウンター更新、試行の記録を1つの同期 SQLite トランザクション内で実行します。同時に呼び出されても、処理途中の状態を観測することはありません。

この実験では、次の3つの関連する境界を学びます。

  • Concurrency は、同じ期間に複数の処理が進行していることを意味します。複数の JavaScript スレッドは必要ありません。
  • Atomicity は、他の処理から見た状態が、完全に変更された状態か、変更前の状態のどちらかになることを意味します。
  • Safe initialization は、存在しない行を作成しつつ、すでに予約を含む行を上書きしない初期化です。

ローカルとクラウドで同時リクエストを送信し、承認数と拒否数を永続状態と比較します。その後、ローカルランタイムを再起動し、クラウド Worker を再デプロイして、Dashboard を確認し、使い捨てのリソースをすべて削除します。

このコースを直接開始する前に、Connect LabEx to Your Cloudflare Account を完了してください。新しい VM ごとに Wrangler の認証が必要です。O01~O02 で扱った、安定した Durable Object 名、RPC、SQLite ベースの状態について、すでに理解していることを前提とします。

Cloudflare は現在、Workers Free で SQLite ベースの Durable Objects をサポートしています。この実験では、使い捨てのクラス名前空間を1つ、短時間だけ使用する名前付きオブジェクトを複数、上限付きのリクエストバッチを作成します。セットアップでは /home/labex/project/concurrent-reservations に Node.js 22.22.0 とプロジェクトローカルの Wrangler 4.132.0 をインストールします。Cloudflare の認証、名前空間の作成、Worker のデプロイ、予約の作成は行いません。

VM を認証してワークショップ名前空間を設定する

このステップでは、新しい VM を認証し、SQLite ベースの Durable Object クラスを1つ宣言します。ワークショップ名ごとに、この名前空間内の別のオブジェクトを選択します。

プロジェクトに移動し、固定された Wrangler のバージョンを確認して、デバイス認証を開始します。

cd /home/labex/project/concurrent-reservations
npx wrangler --version
npx wrangler login --device --browser=false

Wrangler のバージョンとして 4.132.0 が表示されます。表示された Cloudflare URL をブラウザーで開き、短いコードを入力して、対象の学習用アカウントを確認し、認証を許可します。Wrangler が成功を報告してから戻ってください。パスワードやトークンを実験環境に貼り付けないでください。

安全な識別情報を読み取り、確認済みのアカウント 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-o03-$(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": "WORKSHOPS", "class_name": "WorkshopReservations" }
    ]
  },
  "exports": {
    "WorkshopReservations": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

WORKSHOPS は、フロントエンドとなる Worker の名前空間バインディングです。クラスのエクスポートにより、名前付きワークショップごとに専用の SQLite データベースが用意されます。デプロイするまで、クラウド上のリソースは作成されません。

アトミックな予約遷移を実装する

このステップでは、永続的な定員テーブルと試行テーブルを作成し、1回のアトミックな予約遷移を実装します。

コンストラクターは、Cloudflare がインメモリのクラスインスタンスを作成または再起動するたびに実行されます。CREATE TABLE IF NOT EXISTS は、存在しないスキーマを安全に再作成します。INSERT ... ON CONFLICT DO NOTHING は、4席の定員行が存在しない場合にだけ挿入します。既存の reserved 値を0に戻すことはありません。

Worker のエントリーポイントを作成します。

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

export class WorkshopReservations extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS workshop_state (
          singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
          capacity INTEGER NOT NULL CHECK (capacity > 0),
          reserved INTEGER NOT NULL CHECK (reserved >= 0 AND reserved <= capacity)
        )
      `);
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS reservation_attempts (
          request_id TEXT PRIMARY KEY,
          seats INTEGER NOT NULL CHECK (seats > 0),
          status TEXT NOT NULL CHECK (status IN ('accepted', 'rejected')),
          reserved_after INTEGER NOT NULL,
          created_at INTEGER NOT NULL
        )
      `);
      this.ctx.storage.sql.exec(`
        INSERT INTO workshop_state (singleton, capacity, reserved)
        VALUES (1, 4, 0)
        ON CONFLICT(singleton) DO NOTHING
      `);
    });
  }

  reserve(requestId, seats) {
    return this.ctx.storage.transactionSync(() => {
      const previous = this.ctx.storage.sql.exec(
        `SELECT request_id AS requestId, seats, status, reserved_after AS reserved
         FROM reservation_attempts WHERE request_id = ?`,
        requestId
      ).toArray()[0];
      if (previous) {
        const state = this.ctx.storage.sql.exec(
          `SELECT capacity FROM workshop_state WHERE singleton = 1`
        ).one();
        return { ...previous, capacity: state.capacity, replayed: true };
      }

      const updated = this.ctx.storage.sql.exec(
        `UPDATE workshop_state
         SET reserved = reserved + ?
         WHERE singleton = 1 AND reserved + ? <= capacity
         RETURNING capacity, reserved`,
        seats,
        seats
      ).toArray();
      const accepted = updated.length === 1;
      const state = accepted ? updated[0] : this.ctx.storage.sql.exec(
        `SELECT capacity, reserved FROM workshop_state WHERE singleton = 1`
      ).one();
      const status = accepted ? "accepted" : "rejected";

      this.ctx.storage.sql.exec(
        `INSERT INTO reservation_attempts
         (request_id, seats, status, reserved_after, created_at)
         VALUES (?, ?, ?, ?, ?)`,
        requestId,
        seats,
        status,
        state.reserved,
        Date.now()
      );
      return { requestId, seats, status, capacity: state.capacity, reserved: state.reserved, replayed: false };
    });
  }

  getStatus() {
    return this.ctx.storage.sql.exec(`
      SELECT
        s.capacity,
        s.reserved,
        COUNT(CASE WHEN a.status = 'accepted' THEN 1 END) AS acceptedRequests,
        COUNT(CASE WHEN a.status = 'rejected' THEN 1 END) AS rejectedRequests,
        COALESCE(SUM(CASE WHEN a.status = 'accepted' THEN a.seats ELSE 0 END), 0) AS acceptedSeats
      FROM workshop_state AS s
      LEFT JOIN reservation_attempts AS a ON 1 = 1
      WHERE s.singleton = 1
      GROUP BY s.capacity, s.reserved
    `).one();
  }
}

function json(data, status = 200) {
  return Response.json(data, { status });
}

function workshopRoute(pathname) {
  const match = pathname.match(/^\/workshops\/([^/]+)\/(reservations|status)$/);
  if (!match) return { error: "not_found", status: 404 };
  let workshop;
  try {
    workshop = decodeURIComponent(match[1]);
  } catch {
    return { error: "invalid_workshop_name", status: 400 };
  }
  if (!/^[a-z][a-z0-9-]{0,31}$/.test(workshop)) {
    return { error: "invalid_workshop_name", status: 400 };
  }
  return { workshop, action: match[2] };
}

function validReservation(value) {
  return value &&
    /^[a-z][a-z0-9-]{2,47}$/.test(value.requestId) &&
    Number.isInteger(value.seats) &&
    value.seats >= 1 && value.seats <= 4;
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (request.method === "GET" && url.pathname === "/health") {
      return json({ status: "ok" });
    }

    const parsed = workshopRoute(url.pathname);
    if (parsed.error) return json({ error: parsed.error }, parsed.status);

    if (request.method === "GET" && parsed.action === "status") {
      const stub = env.WORKSHOPS.getByName(parsed.workshop);
      const state = await stub.getStatus();
      return json({ workshop: parsed.workshop, ...state });
    }
    if (request.method === "POST" && parsed.action === "reservations") {
      let body;
      try {
        body = await request.json();
      } catch {
        return json({ error: "invalid_json" }, 400);
      }
      if (!validReservation(body)) return json({ error: "invalid_reservation" }, 400);
      const stub = env.WORKSHOPS.getByName(parsed.workshop);
      const result = await stub.reserve(body.requestId, body.seats);
      console.log(JSON.stringify({ event: "reservation_decided", workshop: parsed.workshop, requestId: body.requestId, status: result.status, reserved: result.reserved }));
      return json({ workshop: parsed.workshop, ...result }, result.status === "accepted" ? 201 : 409);
    }
    return json({ error: "method_not_allowed" }, 405);
  }
};
JS

transactionSync() は同期的なストレージ処理だけを受け付けます。条件付きの UPDATE は、要求された席数がまだ定員内に収まる場合だけカウンターを変更します。RETURNING は、その同じステートメントが生成した値を読み取ります。試行行も同じトランザクションでコミットされます。同じ requestId を再度送信すると、定員を二重に消費せず、最初の判定が返されます。

決定的なルーティングテストと実際のバンドルチェックを実行します。

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

テストが2件成功し、dry run も成功することを確認します。リモートリソースは作成されません。

10件のローカルリクエストを同時に送信する

このステップでは、4席のワークショップに対する10件の予約コマンドを同時に進行させます。xargs -P 10 は最大10個のシェルプロセスを同時に起動します。プロセスが終了する順序は意図的に決まっていません。

永続化ディレクトリを明示してローカルランタイムを起動します。

npx wrangler dev --port 8787 --persist-to .labex/local-state > .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/health && break
  sleep 1
done

同じ安定したオブジェクトに対して、1席ずつの試行を10件送信します。各プロセスは別々のレスポンスファイルに書き込むため、同時実行されたターミナル出力が混ざることはありません。

rm -f .labex/local-response-*.json
seq 1 10 | xargs -P 10 -I{} sh -c '
  curl --silent \
    --request POST http://127.0.0.1:8787/workshops/launch-day/reservations \
    --header "content-type: application/json" \
    --data "{\"requestId\":\"request-$1\",\"seats\":1}" \
    > ".labex/local-response-$1.json"
' _ {}

予約が拒否された場合、HTTP 409 は想定されるアプリケーションレスポンスです。curl --silent を使っても JSON 本文は保存されるため、ワークショップが満員になった場合でも、通信エラーとして扱わずにすべての判定を確認できます。すべての判定を1つの配列として確認します。

jq -s 'sort_by(.requestId)' .labex/local-response-*.json
jq -s '{
  accepted: map(select(.status == "accepted")) | length,
  rejected: map(select(.status == "rejected")) | length,
  highestReserved: map(.reserved) | max
}' .labex/local-response-*.json

どのリクエスト ID が承認されるかは、到着順が保証されないため変わることがあります。ただし、不変条件は変わりません。承認は必ず4件、拒否は6件で、reserved が4を超えるレスポンスはありません。

オブジェクトから永続化された合計値を読み取ります。

curl --silent http://127.0.0.1:8787/workshops/launch-day/status | jq

capacity4reserved4、承認済みリクエストが4件、拒否されたリクエストが6件、承認済みの席数が4になることを確認します。レスポンスファイルは個々の結果を示し、ステータス行はその合計が永続状態と一致していることを証明します。

定員をリセットせずに再起動する

このステップでは、Wrangler を停止してインメモリのクラスインスタンスを削除し、同じデータベースを使って新しいランタイムを起動します。初期化によって定員が復元されないことを確認します。

プロセスを停止して再起動します。

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler dev --port 8787 --persist-to .labex/local-state > .labex/dev-restarted.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/health && break
  sleep 1
done

別の判定を行う前に、ワークショップの状態を読み取ります。

curl --silent http://127.0.0.1:8787/workshops/launch-day/status | jq

reserved: 4 と報告され続ける必要があります。コンストラクターは再度実行されましたが、ON CONFLICT DO NOTHING によって既存の行が保持されています。

最初のリクエスト ID を再送信し、ワークショップが満員の状態で新しいリクエストを1件送信します。

curl --silent --request POST http://127.0.0.1:8787/workshops/launch-day/reservations \
  --header 'content-type: application/json' \
  --data '{"requestId":"request-1","seats":1}' | jq
curl --silent --request POST http://127.0.0.1:8787/workshops/launch-day/reservations \
  --header 'content-type: application/json' \
  --data '{"requestId":"request-after-restart","seats":1}' | jq
curl --silent http://127.0.0.1:8787/workshops/launch-day/status | jq

再送信のレスポンスには replayed: true が含まれ、新しい試行は追加されません。新しい ID は1件の予約として拒否されます。最終的な合計は、承認済みの席数が4のまま、拒否されたリクエストが7件になります。

クラウドで同時実行を試す

このステップでは、ローカルランタイムを停止して名前空間をデプロイし、Cloudflare に対して上限付きの同時実行テストを繰り返します。

ローカルプロセスを停止してデプロイします。

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"

Worker のルートと、新しく調整された Durable Object 名前空間は、異なるタイミングで利用可能になることがあります。まず、想定する JSON の形式で実際のオブジェクト読み取りが成功するまでポーリングし、その後、別のオブジェクトを作成する前に、テスト済みの短い安定待ち時間を設けます。

for attempt in $(seq 1 30); do
  if curl --silent --fail "$APP_URL/workshops/readiness/status" |
    jq -e '.capacity == 4 and .reserved == 0' >/dev/null; then
    break
  fi
  sleep 1
done
curl --silent --fail "$APP_URL/workshops/readiness/status" |
  jq -e '.capacity == 4 and .reserved == 0'
sleep 5

cloud-launch に対して同時に10件のクラウド試行を送信します。

rm -f .labex/cloud-response-*.json
seq 1 10 | xargs -P 10 -I{} sh -c '
  curl --silent \
    --request POST "$0/workshops/cloud-launch/reservations" \
    --header "content-type: application/json" \
    --data "{\"requestId\":\"cloud-request-$1\",\"seats\":1}" \
    > ".labex/cloud-response-$1.json"
' "$APP_URL" {}

レスポンスの合計と永続状態を比較します。

jq -s '{
  accepted: map(select(.status == "accepted")) | length,
  rejected: map(select(.status == "rejected")) | length,
  highestReserved: map(.reserved) | max
}' .labex/cloud-response-*.json
curl --silent "$APP_URL/workshops/cloud-launch/status" | jq

クラウドでもローカルと同じ不変条件になります。承認は4件、拒否は6件、reserved: 4 です。独立したチェックを実行します。このチェックでは、使用中のバインディングと名前空間を検査し、cloud-launch を検証した後、別の実行用に一意なワークショップへ12件のリクエストを同時送信します。

python3 .labex/verify.py deployed

再デプロイして予約の調整を確認する

このステップでは、変更していない Worker を再デプロイします。これによりインメモリのクラスインスタンスが置き換えられ、コンストラクターが再度実行される可能性があります。永続化された定員行は満員のまま維持される必要があります。

再デプロイし、新しいリクエストで cloud-launch を読み取ります。

npx wrangler deploy
curl --silent "$APP_URL/workshops/cloud-launch/status" | jq

同じ capacity 4reserved 4、承認済みリクエスト4件、拒否されたリクエスト6件になることを確認します。このクラウド再起動チェックは、ローカル再起動と同じ結論になります。安全な初期化は不足している状態を作成しますが、確立済みの状態を上書きすることはありません。

Cloudflare Dashboard を開き、同じアカウントを選択します。Workers & Pages に移動し、正確な labex-c10-o03-... Worker を開いて Bindings を選択します。WORKSHOPS がテスト対象の WorkshopReservations 名前空間を参照していることを確認します。

承認された Worker は WORKSHOPS を WorkshopReservations Durable Object 名前空間に接続しています

スクリーンショットに表示されているサフィックスは、承認済みの作成実行に属します。生成されたサフィックスは異なります。バインディング名、タイプ、対象クラスが一致すべき項目です。

名前空間を開き、Overview を選択します。Storage: SQL は、定員テーブルと試行テーブルを管理するバックエンドを示します。

WorkshopReservations 名前空間の概要で SQL ストレージを確認できます

次に Logs を開きます。成功した WorkshopReservations.jsrpc 行は、同時バッチと検証スクリプトが実行したオブジェクトメソッド呼び出しです。readinesscloud-launch、実行ごとに一意な検証用ワークショップは意図的に分離されているため、複数のオブジェクト ID が表示されます。ログには呼び出しとエラーが表示されます。定員が守られたことを示す正式な根拠は、引き続き HTTP の合計値です。

分離された Durable Object ID 全体に、成功した予約 RPC 呼び出しが表示されています

再デプロイ後に、独立したクラウドチェックをもう一度実行します。

python3 .labex/verify.py deployed

予約名前空間を削除してログアウトする

このステップでは、使い捨ての名前空間とそのワークショップデータベースを完全に削除し、残りの Worker を削除して、この VM の認証を取り消します。

$RUNlabex-c10-o03- で始まることを確認します。状態を持たないクリーンアップ用エントリーポイントを作成します。

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

まったく同じ Worker とアカウントを対象に、クリーンアップ用の設定を作成します。state: "deleted" のトゥームストーンによって、WorkshopReservations クラスの名前空間だけが完全に削除されます。

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": {
    "WorkshopReservations": { "type": "durable-object", "state": "deleted" }
  }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc

調整結果に Deleted: WorkshopReservations と表示されることを確認します。残りの状態を持たない Worker を削除し、生成した名前と完全に一致するものだけが対象であることを確認します。

npx wrangler delete --config wrangler.cleanup.jsonc

ログアウトする前に、認証済みの状態で存在しないことを確認します。

python3 .labex/verify.py deleted

PASS: deleted が表示されてから、VM の認証を取り消し、構造化された状態を確認します。

npx wrangler logout
npx wrangler whoami --json

最後の JSON には "loggedIn": false が含まれている必要があります。ネットワークエラーや認証エラーは、クリーンアップが完了した証拠にはなりません。

まとめ

ワークショップ名ごとに1つの Durable Object を選択する、上限付きの予約サービスを構築しました。同期 SQLite トランザクションによって、定員チェック、カウンターの変更、試行の記録を1つの分割できない遷移にまとめました。10件の同時呼び出しは任意の順序で完了できますが、承認された席数は必ず4で、定員を超えるレスポンスはありません。

また、ON CONFLICT DO NOTHING によって初期化を安全にし、安定したリクエスト IDを再送信しても二重予約されないことを確認しました。ローカルの再起動後とクラウドの再デプロイ後も、同じ永続化された合計値が維持されることを証明しました。最後に、Dashboard のバインディング、SQL 名前空間、RPC ログとランタイムの結果を関連付け、ログアウトする前に対象の名前空間と Worker を完全に削除しました。