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 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.

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.

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.



