Координация параллельных бронирований

CloudflareBeginner
Практиковаться сейчас

Введение

На семинаре осталось четыре места, но десять человек могут почти одновременно нажать Reserve. Если каждый запрос сначала считывает reserved = 0, затем делает паузу и записывает reserved = 1, приложение теряет успешно оформленные бронирования. Другая ошибочная реализация может подтвердить больше мест, чем есть у семинара. Такое перекрытие незавершённых асинхронных задач называется чередованием (interleaving).

В этой лабораторной работе каждое проверенное имя семинара выбирает отдельный Durable Object. Этот объект хранит строку с вместимостью и устойчивую запись для каждой попытки. Метод бронирования выполняет проверку вместимости, обновление счётчика и запись попытки внутри одной синхронной транзакции SQLite. Параллельные вызовы могут поступать одновременно, но ни один из них не увидит незавершённое изменение состояния.

Вы изучите три связанные границы:

  • Параллелизм означает, что несколько операций выполняются в один и тот же период; для этого не нужны несколько потоков JavaScript.
  • Атомарность означает, что другие операции видят либо полное изменение состояния, либо ничего.
  • Безопасная инициализация создаёт отсутствующую строку, не перезаписывая строку, в которой уже есть бронирования.

Вы отправите параллельные локальные и облачные тестовые запросы, сравните число принятых и отклонённых запросов с устойчивым состоянием, перезапустите локальную среду, повторно развернёте облачный Worker, изучите Dashboard и удалите все временные ресурсы.

Прежде чем напрямую переходить к этому курсу, выполните Подключение LabEx к вашей учётной записи Cloudflare. Для каждой новой виртуальной машины требуется отдельная авторизация Wrangler. Предполагается, что вы уже понимаете стабильные имена Durable Objects, RPC и состояние на основе SQLite из O01–O02.

Cloudflare в настоящее время поддерживает Durable Objects на основе SQLite в Workers Free. В этой лабораторной работе создаются одно временное пространство имён класса, несколько небольших именованных объектов и ограниченные пакеты запросов. В процессе настройки устанавливаются Node.js 22.22.0 и локальный для проекта Wrangler 4.132.0 в /home/labex/project/concurrent-reservations; авторизация в Cloudflare, создание пространства имён, развёртывание Worker и бронирование мест не выполняются.

Авторизация виртуальной машины и настройка пространства имён семинаров

На этом этапе вы авторизуете новую виртуальную машину и объявите один Durable Object на основе SQLite. Каждое имя семинара будет выбирать отдельный объект в этом пространстве имён.

Перейдите в каталог проекта, проверьте зафиксированную версию Wrangler и запустите авторизацию устройства:

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

Ожидается Wrangler 4.132.0. Откройте показанный URL Cloudflare в браузере, введите короткий код, подтвердите нужную учебную учётную запись и разрешите доступ. Возвращайтесь к лабораторной работе только после сообщения Wrangler об успешном завершении. Никогда не вставляйте пароль или токен в лабораторную работу.

Прочитайте безопасные поля идентификации, выберите подтверждённый идентификатор учётной записи, не выводя его на экран, и сгенерируйте уникальное имя 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. До развёртывания облачный ресурс не создаётся.

Реализация атомарного перехода бронирования

На этом этапе вы создадите устойчивые таблицы вместимости и попыток, а затем реализуете один атомарный переход бронирования.

Конструктор выполняется каждый раз, когда Cloudflare создаёт или перезапускает экземпляр класса в памяти. CREATE TABLE IF NOT EXISTS безопасно восстанавливает отсутствующую схему. Инструкция INSERT ... ON CONFLICT DO NOTHING вставляет строку вместимостью четыре места только при её отсутствии; она никогда не сбрасывает существующее значение reserved в ноль.

Создайте точку входа 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

Ожидаются два пройденных теста и успешный пробный запуск. Удалённый ресурс не создаётся.

Параллельная отправка десяти локальных запросов

На этом этапе десять команд бронирования будут одновременно выполняться для одного семинара вместимостью четыре места. xargs -P 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

Отправьте десять попыток по одному месту в один и тот же стабильный объект. Каждый процесс записывает отдельный файл ответа, поэтому параллельный вывод терминала не смешивается:

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, поэтому можно проверить каждое решение, не считая заполненный семинар ошибкой передачи данных. Просмотрите все решения как один массив:

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

Точные идентификаторы принятых запросов могут различаться, поскольку порядок поступления не гарантирован. Инвариант неизменен: приняты ровно четыре запроса, отклонены шесть, и ни один ответ не сообщает о более чем четырёх занятых местах.

Прочитайте устойчивые итоги из объекта:

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

Ожидаются capacity со значением 4, reserved со значением 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 сохранил существующую строку.

Повторно отправьте первый идентификатор запроса, затем отправьте один новый запрос, пока семинар заполнен:

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, и новая попытка не добавится. Новый идентификатор будет отклонён один раз. В итогах по-прежнему четыре принятых места, а число отклонённых запросов станет равно семи.

Проверка облачного параллелизма

На этом этапе вы остановите локальную среду, развернёте пространство имён и повторите ограниченный тест параллелизма в 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:

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

Облачный инвариант совпадает с локальным результатом: четыре принятых запроса, шесть отклонённых, reserved: 4. Запустите независимую проверку. Она изучает принадлежащую вам привязку и пространство имён, проверяет cloud-launch, а затем отправляет двенадцать параллельных запросов в отдельный семинар с уникальным именем:

python3 .labex/verify.py deployed

Повторное развёртывание и проверка координации бронирований

На этом этапе вы повторно развернёте неизменённый Worker. Это может заменить экземпляр класса в памяти, поэтому конструктор может выполниться снова. Устойчивая строка вместимости должна остаться заполненной.

Повторно разверните Worker и прочитайте состояние cloud-launch новым запросом:

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

Ожидаются прежние значения: вместимость 4, занято 4, четыре принятых запроса и шесть отклонённых запросов. Эта проверка облачного перезапуска приводит к тому же выводу, что и локальная: безопасная инициализация создаёт отсутствующее состояние, но никогда не перезаписывает уже установленное.

Откройте Cloudflare Dashboard и выберите ту же учётную запись. Перейдите в Workers & Pages, откройте Worker с точным именем labex-c10-o03-... и выберите Bindings. Убедитесь, что WORKSHOPS указывает на проверенное пространство имён WorkshopReservations.

Принятый Worker подключает WORKSHOPS к пространству имён Durable Object WorkshopReservations

Суффикс на снимке экрана относится к принятому запуску создания лабораторной работы. У вас сгенерируется другой суффикс; совпадать должны имя привязки, тип и целевой класс.

Откройте пространство имён и выберите Overview. Значение Storage: SQL указывает на хранилище, в котором находятся таблицы вместимости и попыток.

Обзор пространства имён WorkshopReservations подтверждает хранилище SQL

Теперь откройте Logs. Успешные строки WorkshopReservations.jsrpc — это вызовы методов объекта, выполненные параллельными пакетами и средством проверки. Отображается несколько идентификаторов объектов, поскольку семинары readiness, cloud-launch и уникальные семинары средства проверки намеренно изолированы. В журналах видны вызовы и ошибки; HTTP-итоги остаются главным подтверждением того, что вместимость соблюдена.

Успешные RPC-вызовы бронирования отображаются для изолированных идентификаторов Durable Objects

После повторного развёртывания ещё раз запустите независимую облачную проверку:

python3 .labex/verify.py deployed

Удаление пространства имён бронирований и выход из системы

На этом этапе вы безвозвратно удалите временное пространство имён и его базы данных семинаров, удалите оставшийся Worker и отзовёте авторизацию этой виртуальной машины.

Убедитесь, что $RUN начинается с labex-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 отзовите авторизацию виртуальной машины и проверьте структурированное состояние:

npx wrangler logout
npx wrangler whoami --json

В итоговом JSON должно содержаться "loggedIn": false. Ошибка сети или авторизации не подтверждает выполнение очистки.

Итоги

Вы создали сервис бронирования с ограниченной вместимостью, в котором каждое имя семинара выбирает один Durable Object. Синхронная транзакция SQLite объединила проверку вместимости, изменение счётчика и запись попытки в один неделимый переход. Десять параллельных вызовов могли завершаться в любом порядке, но были приняты ровно четыре места, и ни один ответ не превысил вместимость.

Вы также сделали инициализацию безопасной с помощью ON CONFLICT DO NOTHING, повторно использовали один стабильный идентификатор запроса без двойного бронирования и подтвердили те же устойчивые итоги после локального перезапуска и повторного развёртывания в облаке. Наконец, вы сопоставили данные среды выполнения с привязкой, пространством имён SQL и журналами RPC в Dashboard, а затем удалили точные пространство имён и Worker перед выходом из системы.