Coordenar reservas simultâneas

CloudflareBeginner
Pratique Agora

Introdução

Uma oficina tem quatro lugares disponíveis, mas dez pessoas podem clicar em Reserve quase ao mesmo tempo. Se cada solicitação primeiro ler reserved = 0, aguardar e depois gravar reserved = 1, a aplicação perderá reservas que deveriam ter sido aceitas. Outro projeto incorreto pode aprovar mais lugares do que a oficina possui. Essa sobreposição entre tarefas assíncronas ainda não concluídas é chamada de intercalação.

Neste laboratório, cada nome de oficina validado seleciona um Durable Object. Esse objeto é responsável por uma linha de capacidade e por um registro persistente para cada tentativa. O método de reserva executa a verificação de capacidade, a atualização do contador e o registro da tentativa dentro de uma única transação síncrona do SQLite. Vários chamadores podem chegar juntos, mas nenhum deles consegue observar uma transição parcialmente concluída.

Você aprenderá três limites relacionados:

  • Concorrência significa que várias operações estão em andamento durante o mesmo período; isso não exige várias threads JavaScript.
  • Atomicidade significa que outras operações observam a alteração completa do estado ou não observam alteração alguma.
  • Inicialização segura cria uma linha ausente sem sobrescrever uma linha que já contém reservas.

Você enviará conjuntos de dados locais e na nuvem simultaneamente, comparará os totais aceitos e rejeitados com o estado persistente, reiniciará o ambiente local, reimplantará o Worker na nuvem, inspecionará o Dashboard e removerá todos os recursos descartáveis.

Antes de entrar diretamente neste curso, conclua Conectar o LabEx à sua conta da Cloudflare. Cada VM nova precisa de sua própria autorização do Wrangler. Você já deve entender nomes estáveis de Durable Objects, RPC e estado baseado em SQLite, conforme apresentado em O01–O02.

Atualmente, a Cloudflare oferece suporte a Durable Objects baseados em SQLite no Workers Free. Este laboratório cria um namespace de classe descartável, vários objetos nomeados pequenos e conjuntos limitados de solicitações. A configuração instala o Node.js 22.22.0 e o Wrangler 4.132.0 local do projeto em /home/labex/project/concurrent-reservations; ela não autoriza a Cloudflare, não cria um namespace, não implanta um Worker nem faz uma reserva.

Autorizar a VM e configurar o namespace da oficina

Nesta etapa, você autorizará a VM nova e declarará uma classe de Durable Object baseada em SQLite. Cada nome de oficina selecionará um objeto diferente nesse namespace.

Entre no projeto, confirme a versão fixada do Wrangler e inicie a autorização por dispositivo:

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

Você deve ver o Wrangler 4.132.0. Abra no navegador a URL da Cloudflare exibida, informe o código curto, confirme a conta de aprendizado correta e autorize. Só volte depois que o Wrangler informar que a operação foi concluída. Nunca cole uma senha ou um token no laboratório.

Leia os campos de identidade seguros, selecione o ID da conta confirmada sem exibi-lo e gere um nome exclusivo para o 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"

Se a sua conta de aprendizado dedicada tiver outro nome de exibição, substitua o nome pelo que você confirmou. Crie a configuração:

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 é o binding do namespace do Worker de entrada. A exportação da classe fornece a cada oficina nomeada um banco de dados SQLite privado. Nenhum recurso na nuvem existe até a implantação.

Implementar uma transição atômica de reserva

Nesta etapa, você criará as tabelas persistentes de capacidade e tentativas e implementará uma transição atômica de reserva.

O construtor é executado sempre que a Cloudflare cria ou reinicia uma instância da classe em memória. CREATE TABLE IF NOT EXISTS recria com segurança um esquema ausente. A instrução INSERT ... ON CONFLICT DO NOTHING insere a linha de capacidade de quatro lugares somente quando ela não existe; ela nunca redefine para zero um valor reserved já existente.

Crie o ponto de entrada do 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() aceita apenas operações síncronas de armazenamento. O UPDATE com condição altera o contador somente quando os lugares solicitados ainda cabem, e RETURNING lê o valor produzido por essa mesma instrução. A linha da tentativa é confirmada na mesma transação. Um requestId repetido retorna a primeira decisão sem consumir a capacidade novamente.

Execute os testes determinísticos de roteamento e uma verificação real do bundle:

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

Você deve obter dois testes aprovados e uma execução de teste sem erros. Nenhum recurso remoto é criado.

Enviar dez solicitações locais simultaneamente

Nesta etapa, dez comandos de reserva estarão em andamento ao mesmo tempo para uma oficina com quatro lugares. xargs -P 10 inicia até dez processos do shell simultaneamente; a ordem em que eles terminam não é definida de propósito.

Inicie o ambiente local com um diretório explícito de persistência:

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

Envie dez tentativas de um lugar para o mesmo objeto estável. Cada processo grava um arquivo de resposta separado, para que a saída simultânea do terminal não fique misturada:

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 é a resposta esperada da aplicação para uma reserva rejeitada. curl --silent ainda salva o corpo JSON, permitindo inspecionar cada decisão sem tratar uma oficina lotada como falha de transporte. Inspecione todas as decisões como uma única matriz:

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

Os IDs exatos das solicitações aceitas podem variar, porque a ordem de chegada não é garantida. O invariante não varia: exatamente quatro são aceitas, seis são rejeitadas e nenhuma resposta informa mais de quatro lugares reservados.

Leia os totais persistentes do objeto:

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

Você deve obter capacidade 4, reserved igual a 4, quatro solicitações aceitas, seis solicitações rejeitadas e quatro lugares aceitos. Os arquivos de resposta explicam os resultados individuais; a linha de status prova que os totais correspondem ao estado persistente.

Reiniciar sem redefinir a capacidade

Nesta etapa, você removerá a instância da classe em memória parando o Wrangler, iniciará um novo ambiente usando o mesmo banco de dados e provará que a inicialização não restaura a capacidade.

Pare e reinicie o processo:

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

Leia a oficina antes de tomar outra decisão:

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

O resultado ainda deve informar reserved: 4. O construtor foi executado novamente, mas ON CONFLICT DO NOTHING preservou a linha existente.

Reenvie o primeiro ID de solicitação e depois envie uma nova solicitação enquanto a oficina estiver lotada:

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

A repetição terá replayed: true e não adicionará outra tentativa. O novo ID será rejeitado uma vez. Os totais finais continuarão com quatro lugares aceitos e passarão a ter sete solicitações rejeitadas.

Exercitar a concorrência na nuvem

Nesta etapa, você parará o ambiente local, implantará o namespace e repetirá o teste de concorrência limitada na Cloudflare.

Pare o processo local e faça a implantação:

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"

A rota do Worker e o namespace de Durable Objects reconciliado recentemente podem ficar disponíveis em momentos diferentes. Consulte um objeto real até obter o contrato JSON esperado e, em seguida, aguarde o curto intervalo de estabilização testado antes de criar outro objeto:

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

Envie dez tentativas simultâneas na nuvem para 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" {}

Compare os totais das respostas com o estado persistente:

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

O invariante da nuvem deve corresponder ao resultado local: quatro aceitas, seis rejeitadas e reserved: 4. Execute a verificação independente. Ela inspeciona o binding e o namespace pertencentes ao projeto, verifica cloud-launch e depois envia doze solicitações simultâneas para uma oficina separada e exclusiva desta execução:

python3 .labex/verify.py deployed

Reimplantar e inspecionar a coordenação das reservas

Nesta etapa, você reimplantará o Worker sem alterações. Isso pode substituir a instância da classe em memória, portanto o construtor poderá ser executado novamente. A linha persistente de capacidade deve continuar cheia.

Reimplante e leia cloud-launch por meio de uma nova solicitação:

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

Você deve obter a mesma capacidade 4, reserved igual a 4, quatro solicitações aceitas e seis solicitações rejeitadas. Essa verificação de reinicialização na nuvem chega à mesma conclusão da reinicialização local: a inicialização segura cria o estado ausente, mas nunca sobrescreve o estado já estabelecido.

Abra o Cloudflare Dashboard e selecione a mesma conta. Acesse Workers & Pages, abra o Worker exato labex-c10-o03-... e selecione Bindings. Confirme que WORKSHOPS aponta para o namespace de Durable Objects WorkshopReservations testado.

O Worker aceito conecta WORKSHOPS ao namespace de Durable Objects WorkshopReservations

O sufixo mostrado na captura de tela pertence à execução de autoria aceita. O sufixo gerado para você será diferente; o nome, o tipo e a classe de destino do binding são os campos que devem corresponder.

Abra o namespace e selecione Overview. Storage: SQL identifica o backend responsável pelas tabelas de capacidade e de tentativas.

A visão geral do namespace WorkshopReservations confirma o armazenamento SQL

Agora abra Logs. As linhas bem-sucedidas WorkshopReservations.jsrpc são as chamadas de métodos do objeto feitas pelos conjuntos de solicitações simultâneas e pelo verificador. Vários IDs de objetos aparecem porque as oficinas readiness, cloud-launch e as oficinas exclusivas do verificador são isoladas de propósito. Os logs mostram invocações e erros; os totais HTTP continuam sendo a evidência oficial de que a capacidade foi respeitada.

Chamadas RPC de reserva bem-sucedidas aparecem em IDs de Durable Objects isolados

Execute novamente a verificação independente da nuvem após a reimplantação:

python3 .labex/verify.py deployed

Excluir o namespace de reservas e sair da conta

Nesta etapa, você removerá permanentemente o namespace descartável e seus bancos de dados das oficinas, excluirá o Worker restante e revogará a autorização desta VM.

Confirme que $RUN começa com labex-c10-o03-. Crie um ponto de entrada de limpeza sem estado:

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

Crie uma configuração de limpeza para o mesmo Worker e a mesma conta. O marcador state: "deleted" remove permanentemente apenas o namespace da classe 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

A saída da reconciliação deve informar Deleted: WorkshopReservations. Exclua o Worker sem estado restante e confirme apenas o nome exato gerado:

npx wrangler delete --config wrangler.cleanup.jsonc

Comprove a ausência autenticada antes de sair da conta:

python3 .labex/verify.py deleted

Somente depois de ver PASS: deleted, revogue a autorização da VM e inspecione o estado estruturado:

npx wrangler logout
npx wrangler whoami --json

O JSON final deve conter "loggedIn": false. Um erro de rede ou de autenticação não comprova a limpeza.

Resumo

Você criou um serviço de reservas com capacidade limitada no qual cada nome de oficina seleciona um Durable Object. Uma transação síncrona do SQLite combinou a proteção de capacidade, a alteração do contador e o registro da tentativa em uma única transição indivisível. Dez chamadores simultâneos puderam terminar em qualquer ordem, mas exatamente quatro lugares foram aceitos e nenhuma resposta ultrapassou a capacidade.

Você também tornou a inicialização segura com ON CONFLICT DO NOTHING, repetiu um ID de solicitação estável sem reservar o mesmo lugar duas vezes e comprovou os mesmos totais persistentes após a reinicialização local e a reimplantação na nuvem. Por fim, relacionou as evidências do ambiente de execução ao binding, ao namespace SQL e aos logs RPC no Dashboard e depois excluiu o namespace e o Worker exatos antes de sair da conta.