Диагностика утечки состояния между комнатами

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

Введение

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

В этой лабораторной работе вы развернёте небольшой журнал комнат с корректными историями planning и support, воспроизведёте ошибочный релиз, который направляет каждую комнату к объекту planning, и с помощью диагностики маршрута найдёте несоответствие. Вы исправите только сопоставление имён, повторно развернёте приложение и подтвердите, что обе исходные истории сохранены. Затем предоставленная проверка WebSocket выполнит параллельные обновления, отключит и снова подключит соединения, а также подтвердит изоляцию новых комнат.

Если вы перешли к этому курсу напрямую, сначала выполните Подключение LabEx к вашей учётной записи Cloudflare. В этой лабораторной работе объясняется, как использовать терминал виртуальной машины LabEx, авторизовать устройство Wrangler, подтвердить учётную запись и явно настроить идентификатор учётной записи. Для этой новой виртуальной машины авторизацию нужно пройти отдельно.

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

На этом шаге вы авторизуете новую виртуальную машину, подтвердите выделенную учебную учётную запись и объявите одно пространство имён Durable Object с хранилищем SQLite.

cd /home/labex/project/room-routing
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-o07-$(openssl rand -hex 6)"
printf '%s\n' "$RUN" | tee .labex/run-name
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": "ROOMS", "class_name": "RoomJournal" }
  ] },
  "exports": {
    "RoomJournal": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

ROOMS — это привязка пространства имён: через неё можно обращаться ко множеству объектов RoomJournal. Имя, выбранное приложением и переданное в getByName(), определяет, база данных SQLite какого объекта и его активные соединения получат вызов.

Создайте журнал с явными именами объектов

На этом шаге вы реализуете класс, хранящий состояние, и оставите выбор идентификатора в одной небольшой функции маршрутизации. Такое разделение важно при диагностике: хранилище может работать правильно, даже если вызывающая сторона выбирает не тот объект.

Создайте первоначальную корректную функцию сопоставления. Проверенное имя комнаты уже является стабильным детерминированным именем объекта:

cat > src/router.js <<'JS'
export function objectNameFor(room) {
  return room;
}
JS
cat > test/router.test.mjs <<'JS'
import test from "node:test";
import assert from "node:assert/strict";
import { objectNameFor } from "../src/router.js";

test("each validated room keeps its own object identity", () => {
  assert.equal(objectNameFor("planning"), "planning");
  assert.equal(objectNameFor("support"), "support");
  assert.notEqual(objectNameFor("planning"), objectNameFor("support"));
});
JS

Создайте Worker и Durable Object. ctx.id.name возвращает стабильное имя, по которому выполняется обращение к этому объекту. Страница записывает в журнал только запрошенные и выбранные условные имена комнат; текст журнала намеренно не выводится в логи.

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

const ROOM = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
const EVENT = /^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/;
const json = (value, status = 200) => Response.json(value, { status });
const safeRoom = value => ROOM.test(value || "") ? value : null;

export class RoomJournal extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS events (
        sequence INTEGER PRIMARY KEY AUTOINCREMENT,
        event_id TEXT NOT NULL UNIQUE,
        text TEXT NOT NULL
      )`);
    });
  }

  state() {
    return {
      objectName: this.ctx.id.name,
      events: this.ctx.storage.sql.exec(
        "SELECT sequence, event_id AS eventId, text FROM events ORDER BY sequence"
      ).toArray()
    };
  }

  append(eventId, text) {
    if (!EVENT.test(eventId || "") || typeof text !== "string" || text.length < 1 || text.length > 80) {
      throw new Error("invalid_event");
    }
    this.ctx.storage.sql.exec("INSERT OR IGNORE INTO events (event_id, text) VALUES (?, ?)", eventId, text);
    return this.state();
  }

  async fetch(request) {
    if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return json({ error: "upgrade_required" }, 426);
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    this.ctx.acceptWebSocket(server);
    server.send(JSON.stringify({ type: "ready", ...this.state() }));
    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(socket, raw) {
    try {
      const message = JSON.parse(raw);
      if (message.type !== "append") throw new Error("invalid_event");
      const state = this.append(message.eventId, message.text);
      const frame = JSON.stringify({ type: "event", ...state });
      for (const peer of this.ctx.getWebSockets()) peer.send(frame);
    } catch {
      socket.send(JSON.stringify({ type: "error", error: "invalid_event" }));
    }
  }
}

async function roomState(env, room) {
  return env.ROOMS.getByName(objectNameFor(room)).state();
}

function inspectPage(planning, support) {
  const rows = [planning, support].map(([requested, state]) => `<tr><td>${requested}</td><td>${state.objectName}</td><td>${state.events.map(x => x.eventId).join(", ")}</td></tr>`).join("");
  return `<!doctype html><html lang="en"><meta charset="utf-8"><title>Room routing inspector</title>
  <style>body{font:18px system-ui;max-width:900px;margin:48px auto;color:#17212b}h1{color:#5b8c00}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccd5df;padding:14px;text-align:left}th{background:#eef7dc}.ok{padding:12px;background:#eef7dc;border-left:5px solid #78aa00}</style>
  <h1>Room routing inspector</h1><p class="ok">Each requested room resolves to the matching Durable Object name.</p>
  <table><thead><tr><th>Requested room</th><th>Object name</th><th>Preserved event IDs</th></tr></thead><tbody>${rows}</tbody></table></html>`;
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === "/inspect") {
      const states = await Promise.all(["planning", "support"].map(async room => [room, await roomState(env, room)]));
      return new Response(inspectPage(...states), { headers: { "content-type": "text/html; charset=utf-8" } });
    }
    const debug = url.pathname.match(/^\/debug\/route\/([^/]+)$/);
    if (debug) {
      const room = safeRoom(debug[1]);
      if (!room) return json({ error: "invalid_room" }, 400);
      return json({ requestedRoom: room, objectName: objectNameFor(room) });
    }
    const match = url.pathname.match(/^\/rooms\/([^/]+)\/(events|connect)$/);
    if (!match) return json({ error: "not_found" }, 404);
    const room = safeRoom(match[1]);
    if (!room) return json({ error: "invalid_room" }, 400);
    const objectName = objectNameFor(room);
    console.log(JSON.stringify({ event: "routing_decision", requestedRoom: room, objectName, operation: match[2] }));
    const stub = env.ROOMS.getByName(objectName);
    if (match[2] === "connect") return stub.fetch(request);
    if (request.method === "GET") return json(await stub.state());
    if (request.method === "POST") {
      try {
        const body = await request.json();
        return json(await stub.append(body.eventId, body.text), 201);
      } catch (error) {
        return json({ error: error.message === "invalid_event" ? "invalid_event" : "invalid_json" }, 400);
      }
    }
    return json({ error: "method_not_allowed" }, 405);
  }
};
JS
npm test

Тест напрямую проверяет границу идентичности. Durable Object использует имя, назначенное средой выполнения, для диагностики и сохраняет строки журнала в SQLite перед их возвратом.

Разверните две корректные истории комнат

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

rm -f .labex/deploy.log .labex/app-url .labex/baseline.json
npx wrangler deploy | tee .labex/deploy.log
APP_URL="$(grep -Eo 'https://[^ ]+\.workers\.dev' .labex/deploy.log | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL" | tee .labex/app-url
for attempt in $(seq 1 30); do READY="$(curl --silent "$APP_URL/debug/route/planning" || true)"; test "$(jq -r '.objectName // empty' <<<"$READY" 2>/dev/null)" = planning && break; sleep 2; done
test "$(jq -r .objectName <<<"$READY")" = planning
sleep 5
curl --silent --fail -X POST "$APP_URL/rooms/planning/events" -H 'content-type: application/json' --data '{"eventId":"plan-start","text":"Planning kickoff"}' >/dev/null
curl --silent --fail -X POST "$APP_URL/rooms/support/events" -H 'content-type: application/json' --data '{"eventId":"support-start","text":"Support handoff"}' >/dev/null
jq -n --argjson planning "$(curl --silent --fail "$APP_URL/rooms/planning/events")" --argjson support "$(curl --silent --fail "$APP_URL/rooms/support/events")" '{planning:$planning,support:$support}' | tee .labex/baseline.json

Два поля objectName должны различаться. В planning должна быть только запись plan-start, а в support — только support-start. Имя Worker является одноразовым, но истории этих объектов должны пережить регрессию релиза и исправление.

Воспроизведите и проследите неисправный релиз

На этом шаге вы смоделируете регрессию релиза, предоставленную вместе с лабораторной работой. Неисправная функция игнорирует свой аргумент и всегда возвращает planning. Тест идентичности должен завершиться ошибкой; сохранение этой контролируемой ошибки делает дефект наблюдаемым ещё до развертывания.

cp fixtures/router-bug.js src/router.js
rm -f .labex/bug-test.log .labex/bug.json
set -o pipefail
if npm test 2>&1 | tee .labex/bug-test.log; then TEST_STATUS=0; else TEST_STATUS=$?; fi
set +o pipefail
printf '%s\n' "$TEST_STATUS" > .labex/bug-test-status
test "$TEST_STATUS" -ne 0
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
for attempt in $(seq 1 30); do
  BUG_ROUTE="$(curl --silent "$APP_URL/debug/route/support" || true)"
  BUG_READ="$(curl --silent "$APP_URL/rooms/support/events" || true)"
  test "$(jq -r '.objectName // empty' <<<"$BUG_ROUTE" 2>/dev/null)" = planning && test "$(jq -r '.objectName // empty' <<<"$BUG_READ" 2>/dev/null)" = planning && break
  sleep 2
done
test "$(jq -r .objectName <<<"$BUG_ROUTE")" = planning
test "$(jq -r .objectName <<<"$BUG_READ")" = planning
jq -n \
  --argjson planningRoute "$(curl --silent --fail "$APP_URL/debug/route/planning")" \
  --argjson supportRoute "$BUG_ROUTE" \
  --argjson supportRead "$BUG_READ" \
  '{planningRoute:$planningRoute,supportRoute:$supportRoute,supportRead:$supportRead}' | tee .labex/bug.json

Диагностика разделяет запрошенную комнату и выбранное имя объекта. Теперь запрос для support возвращает objectName: planning, а его чтение показывает plan-start. Вы не удаляли и не перезаписывали исходный объект support; неисправный релиз лишь перестал обращаться к нему.

Исправьте сопоставление и подтвердите изоляцию после переподключения

На этом шаге вы исправите только сопоставление идентификаторов. Сбрасывать хранилище или повторно воспроизводить данные не нужно, поскольку исходные именованные объекты по-прежнему существуют.

cat > src/router.js <<'JS'
export function objectNameFor(room) {
  return room;
}
JS
npm test
cat > tools/isolation.mjs <<'JS'
import WebSocket from "ws";
const [base, prefix] = process.argv.slice(2);
const wsBase = base.replace(/^http/, "ws");
const rooms = [`${prefix}-planning`, `${prefix}-support`];
const open = room => new Promise((resolve, reject) => {
  const ws = new WebSocket(`${wsBase}/rooms/${room}/connect`);
  const inbox = [];
  ws.on("message", raw => { const value = JSON.parse(raw); inbox.push(value); if (value.type === "ready") resolve({ ws, inbox, ready:value }); });
  ws.on("error", reject);
});
const waitFor = (client, eventId) => new Promise((resolve, reject) => {
  const timer = setTimeout(() => reject(new Error("event timeout")), 5000);
  const inspect = value => { if (value.type === "event" && value.events.some(x => x.eventId === eventId)) { clearTimeout(timer); client.ws.off("message", listener); resolve(value); } };
  const listener = raw => inspect(JSON.parse(raw));
  client.ws.on("message", listener); client.inbox.forEach(inspect);
});
const close = client => new Promise(resolve => { client.ws.once("close", resolve); client.ws.close(1000, "reconnect"); });
const [planning, support] = await Promise.all(rooms.map(open));
planning.ws.send(JSON.stringify({ type:"append", eventId:`${prefix}-plan`, text:"Plan update" }));
support.ws.send(JSON.stringify({ type:"append", eventId:`${prefix}-support`, text:"Support update" }));
await Promise.all([waitFor(planning, `${prefix}-plan`), waitFor(support, `${prefix}-support`)]);
await Promise.all([close(planning), close(support)]);
const [planningAgain, supportAgain] = await Promise.all(rooms.map(open));
const result = { planning:planningAgain.ready, support:supportAgain.ready };
console.log(JSON.stringify(result, null, 2));
await Promise.all([close(planningAgain), close(supportAgain)]);
JS
rm -f .labex/repaired.json .labex/reconnect.json
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
for attempt in $(seq 1 30); do REPAIRED_READY="$(curl --silent "$APP_URL/debug/route/support" || true)"; test "$(jq -r '.objectName // empty' <<<"$REPAIRED_READY" 2>/dev/null)" = support && break; sleep 2; done
test "$(jq -r .objectName <<<"$REPAIRED_READY")" = support
sleep 5
jq -n --argjson planning "$(curl --silent --fail "$APP_URL/rooms/planning/events")" --argjson support "$(curl --silent --fail "$APP_URL/rooms/support/events")" '{planning:$planning,support:$support}' | tee .labex/repaired.json
node tools/isolation.mjs "$APP_URL" cloud | tee .labex/reconnect.json

После исправления чтение находит оба исходных идентификатора событий в соответствующих исходных объектах. Затем проверка WebSocket одновременно обновляет два новых объекта, закрывает оба соединения и снова подключается к ним. Каждый кадр ready содержит только собственное событие, что доказывает: утечку устранила маршрутизация, а не сброс базы данных.

Проверьте и повторно разверните исправленный сервис

На этом шаге вы сопоставите данные среды выполнения с удобными для начинающих представлениями в браузере и Dashboard. Откройте URL, сохранённый в .labex/app-url, добавив /inspect. Зелёное сообщение и таблица должны показывать planning → planning, support → support и два сохранённых идентификатора событий.

Исправленный инспектор сопоставляет каждую комнату с соответствующим объектом и сохранённой историей

Откройте Workers & Pages, выберите точное имя Worker из .labex/run-name и откройте Bindings. Привязка ROOMS должна указывать на RoomJournal.

Привязка ROOMS указывает на Durable Object RoomJournal

Откройте Durable Objects, выберите <your-worker>_RoomJournal и подтвердите значение Storage: SQL. Одно пространство имён может содержать множество именованных объектов; имя выбирает изолированный объект внутри него.

Пространство имён RoomJournal использует хранилище SQL

Вернитесь к Worker в разделе Workers & Pages, откройте Observability и найдите в сохранённых событиях routing_decision. Разверните одно событие, относящееся к операции комнаты. Это решение записывается статeless Worker до вызова Durable Object, поэтому оно появляется в логах Worker, а не в логах пространства имён. В безопасных полях должны отображаться одинаковые запрошенное и выбранное условные имена комнаты; текст журнала выводиться не должен.

Структурированное решение маршрутизации показывает исправленное сопоставление идентификаторов

Наконец, повторно разверните код без изменений и снова прочитайте исходные комнаты:

npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/rooms/planning/events" | jq
curl --silent --fail "$APP_URL/rooms/support/events" | jq

Обе исходные истории сохраняются. Развертывание кода без изменений не создаёт новые идентификаторы объектов, поскольку те же проверенные имена по-прежнему выбирают те же записи пространства имён.

Удалите пространство имён журнала комнат

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

RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o07-*) ;; *) echo "Unexpected Worker name" >&2; exit 1;; esac
cat > src/cleanup.js <<'JS'
export default { fetch() { return Response.json({ status: "cleanup" }, { status: 410 }); } };
JS
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": { "RoomJournal": { "type": "durable-object", "state": "deleted" } }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc

Убедитесь, что в запросе отображается точное значение $RUN, введите y и дождитесь сообщения Successfully deleted. Не выходите из авторизации виртуальной машины до независимой проверки отсутствия:

npx wrangler whoami --json | jq '{loggedIn, authType}'

JSON должен содержать "loggedIn": true; ошибка сети или авторизации не подтверждает удаление.

Отмените авторизацию Wrangler для этой виртуальной машины

На этом шаге вы удалите OAuth-авторизацию только для этой виртуальной машины после независимой проверки удаления:

npx wrangler logout
npx wrangler whoami --json

Итоговый JSON должен содержать "loggedIn": false. Ваша учебная учётная запись останется авторизованной в браузере.

Итоги

Вы диагностировали дефект Durable Object на границе маршрутизации идентификаторов, не сбрасывая исправное хранилище. Контролируемый неисправный релиз доказал, что support выбирала объект planning; диагностика маршрута показала запрошенное и выбранное имена. Восстановление прямого сопоставления имён сразу вернуло обе исходные истории SQLite. Параллельные обновления WebSocket, отключение, переподключение и повторное развертывание кода без изменений подтвердили, что новые и существующие комнаты остаются изолированными. Наконец, вы проверили исправленное развертывание, удалили созданные одноразовые ресурсы и вышли из системы.