Introdução
Uma reserva temporária deve ser liberada mesmo quando ninguém acessa novamente a aplicação. Um timer JavaScript em memória não é seguro, porque um Worker pode ficar ocioso ou ser reiniciado antes que o timer seja executado. Um alarme do Durable Object, por outro lado, armazena um único horário futuro de ativação junto ao estado persistente do objeto. Quando esse horário chega, a Cloudflare ativa o objeto e chama o método alarm().
Os alarmes têm execução pelo menos uma vez: a Cloudflare tenta novamente um handler que falhou, portanto o mesmo efeito pretendido pode ser tentado mais de uma vez. Por isso, a operação de expiração precisa ser idempotente: executá-la mais de uma vez deve produzir o mesmo estado final que executá-la uma vez. Você usará uma atualização condicional do SQLite para permitir que somente uma reserva held se torne expired; o contador será incrementado na mesma atualização e não poderá aumentar durante uma repetição.
Este laboratório usa um Durable Object nomeado para cada reserva. Assim, cada reserva possui seu próprio slot de alarme, que é o único slot disponível para o respectivo objeto. Você agendará retenções curtas e longas, reiniciará o runtime local antes que um alarme seja disparado, repetirá deliberadamente o caminho de expiração duas vezes, repetirá um teste de alarme real na Cloudflare, inspecionará o Dashboard, fará o redeploy e executará a limpeza.
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 objetos, RPC, estado baseado em SQLite e concorrência limitada, conforme apresentado em O01–O03.
Chamar setAlarm() novamente substitui o alarme do mesmo objeto; outros objetos nomeados mantêm seus próprios alarmes. A configuração instala o Node.js 22.22.0 e o Wrangler 4.132.0 local do projeto em /home/labex/project/reservation-expiry. Ela não autoriza a Cloudflare, não cria um alarme nem faz o deploy de um Worker.
Autorizar a VM e Declarar o Namespace de Alarmes
Nesta etapa, você autorizará a VM nova e declarará uma classe de Durable Object baseada em SQLite para reservas agendadas.
Entre no projeto, confirme a versão fixada do Wrangler e autorize esta VM nova:
cd /home/labex/project/reservation-expiry
npx wrangler --version
npx wrangler login --device --browser=false
O Wrangler deve exibir 4.132.0. Abra no navegador a URL da Cloudflare exibida, informe o código curto, confirme a conta de aprendizagem correta e autorize o acesso. Nunca cole uma senha ou um token no terminal ou no laboratório.
Leia somente os campos de identidade seguros, selecione a conta que você confirmou e crie um nome exclusivo e descartável 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-o04-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"
Se sua conta de aprendizagem dedicada tiver outro nome de exibição, substitua-o pelo nome 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": "RESERVATIONS", "class_name": "ReservationExpiry" }
] },
"exports": {
"ReservationExpiry": { "type": "durable-object", "storage": "sqlite" }
}
}
JSON
RESERVATIONS permite que o Worker de entrada selecione um objeto pelo ID da reserva. A exportação da classe fornece a cada objeto selecionado um armazenamento SQLite privado e um slot de alarme. Nada existirá na nuvem até o deploy.
Implementar uma Expiração Persistente e Idempotente
Nesta etapa, você implementará o estado persistente da reserva, o agendamento do alarme e uma transição de expiração segura para repetições.
Crie a aplicação. A parte importante é o UPDATE condicional, não a infraestrutura HTTP:
cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
const NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,38}[a-z0-9])$/;
const json = (body, status = 200) => Response.json(body, { status });
async function readBody(request) {
try { return await request.json(); } catch { return null; }
}
function parsePath(pathname) {
const match = pathname.match(/^\/reservations\/([^/]+)(?:\/(replay-alarm))?$/);
if (!match || !NAME_PATTERN.test(match[1])) return null;
return { reservationId: match[1], action: match[2] ?? null };
}
export class ReservationExpiry extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS reservation (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
status TEXT NOT NULL CHECK (status IN ('held', 'expired')),
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
expired_at INTEGER,
expiration_count INTEGER NOT NULL DEFAULT 0
)
`);
});
}
row() {
return this.ctx.storage.sql.exec(`
SELECT status, created_at, expires_at, expired_at, expiration_count
FROM reservation WHERE singleton = 1
`).one();
}
async createReservation(ttlSeconds) {
const createdAt = Date.now();
const expiresAt = createdAt + ttlSeconds * 1000;
this.ctx.storage.sql.exec(`
INSERT INTO reservation
(singleton, status, created_at, expires_at, expired_at, expiration_count)
VALUES (1, 'held', ?, ?, NULL, 0)
ON CONFLICT(singleton) DO UPDATE SET
status = 'held', created_at = excluded.created_at,
expires_at = excluded.expires_at, expired_at = NULL,
expiration_count = 0
`, createdAt, expiresAt);
await this.ctx.storage.setAlarm(expiresAt);
return this.getStatus();
}
async getStatus() {
const record = this.row();
if (!record) return { status: "missing", alarmAt: await this.ctx.storage.getAlarm() };
return {
status: record.status,
createdAt: record.created_at,
expiresAt: record.expires_at,
expiredAt: record.expired_at,
expirationCount: record.expiration_count,
alarmAt: await this.ctx.storage.getAlarm()
};
}
async processExpiry(now = Date.now()) {
const result = this.ctx.storage.sql.exec(`
UPDATE reservation
SET status = 'expired', expired_at = ?,
expiration_count = expiration_count + 1
WHERE status = 'held' AND expires_at <= ?
`, now, now);
const changed = result.rowsWritten === 1;
if (changed) await this.ctx.storage.deleteAlarm();
return { ...(await this.getStatus()), changed };
}
async alarm(alarmInfo) {
console.log(JSON.stringify({
event: "reservation-alarm",
isRetry: alarmInfo?.isRetry ?? false,
retryCount: alarmInfo?.retryCount ?? 0
}));
await this.processExpiry(Date.now());
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/") return json({ service: "reservation-expiry" });
const parsed = parsePath(url.pathname);
if (!parsed) return json({ error: "Use a lowercase reservation ID containing 3-40 letters, digits, or hyphens." }, 400);
let body = null;
if (request.method === "POST") {
body = await readBody(request);
if (!body) return json({ error: "Send a JSON request body." }, 400);
}
if (!parsed.action && request.method === "POST") {
if (!Number.isInteger(body.ttlSeconds) || body.ttlSeconds < 5 || body.ttlSeconds > 3600) {
return json({ error: "ttlSeconds must be an integer from 5 through 3600." }, 400);
}
} else if (parsed.action === "replay-alarm" && request.method === "POST") {
if (!Number.isSafeInteger(body.now) || body.now < 1) return json({ error: "now must be a positive integer timestamp." }, 400);
} else if (parsed.action || request.method !== "GET") {
return json({ error: "Method not allowed." }, 405);
}
const stub = env.RESERVATIONS.getByName(parsed.reservationId);
if (request.method === "GET") return json({ reservationId: parsed.reservationId, ...(await stub.getStatus()) });
if (parsed.action === "replay-alarm") return json({ reservationId: parsed.reservationId, ...(await stub.processExpiry(body.now)) });
return json({ reservationId: parsed.reservationId, ...(await stub.createReservation(body.ttlSeconds)) }, 201);
}
};
JS
setAlarm(expiresAt) armazena um timestamp Unix absoluto. getAlarm() torna o agendamento observável. Quando chega o horário, uma instrução SQL altera held para expired e incrementa o contador. Uma repetição encontra expired, portanto a condição WHERE status = 'held' não corresponde a nenhuma linha.
A rota /replay-alarm é um ponto de teste deliberado: ela chama exatamente o método usado por alarm() com um relógio explícito. Assim, você comprova imediatamente a segurança contra repetições sem precisar provocar uma falha na Cloudflare.
Execute os testes determinísticos de roteamento e uma compilação do Wrangler:
NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run --outdir /tmp/o04-dry-run
Comprovar que um Alarme Sobrevive a uma Reinicialização Local
Nesta etapa, você agendará duas retenções locais, reiniciará o Wrangler e observará que somente a retenção cujo prazo chegou expirará.
Inicie o runtime local do Durable Object do Wrangler e aguarde até que ele esteja disponível:
rm -rf .wrangler/state
npx wrangler dev --local --ip 127.0.0.1 --port 8787 > .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/ >/dev/null && break
sleep 1
done
curl --silent --fail http://127.0.0.1:8787/ | jq
Crie uma retenção de 30 segundos e outra independente de uma hora. A retenção mais longa dá tempo suficiente para interromper o Wrangler antes que o alarme seja devido:
curl --silent --fail --request POST http://127.0.0.1:8787/reservations/local-expiring \
--header 'content-type: application/json' --data '{"ttlSeconds":30}' | jq
curl --silent --fail --request POST http://127.0.0.1:8787/reservations/local-safe \
--header 'content-type: application/json' --data '{"ttlSeconds":3600}' | jq
As duas respostas devem mostrar held, expirationCount: 0 e um alarmAt numérico. Interrompa o runtime antes que o primeiro alarme seja devido e reabra o mesmo armazenamento local persistente:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler dev --local --ip 127.0.0.1 --port 8787 > .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/ >/dev/null && break
sleep 1
done
for attempt in $(seq 1 50); do
STATE="$(curl --silent --fail http://127.0.0.1:8787/reservations/local-expiring)"
test "$(printf '%s' "$STATE" | jq -r .status)" = expired && break
sleep 1
done
printf '%s\n' "$STATE" | jq
curl --silent --fail http://127.0.0.1:8787/reservations/local-safe | jq
A retenção curta deve estar expirada exatamente uma vez, e seu alarme deve ser null; o outro objeto deve continuar com status held e seu próprio alarme. É isso que diferencia um alarme persistente de setTimeout().
Repetir a Expiração sem Aplicá-la Duas Vezes
Nesta etapa, você chamará o caminho de expiração duas vezes com o mesmo prazo lógico e comparará os dois resultados.
Crie uma retenção longa para que seu alarme real não concorra com esta verificação determinística. Capture o prazo armazenado:
REPLAY="$(curl --silent --fail --request POST http://127.0.0.1:8787/reservations/replay-proof \
--header 'content-type: application/json' --data '{"ttlSeconds":180}')"
printf '%s\n' "$REPLAY" | jq
REPLAY_NOW="$(printf '%s\n' "$REPLAY" | jq '.expiresAt + 1')"
Chame duas vezes o mesmo método de expiração, usando um relógio imediatamente posterior ao prazo:
curl --silent --fail --request POST http://127.0.0.1:8787/reservations/replay-proof/replay-alarm \
--header 'content-type: application/json' --data "{\"now\":$REPLAY_NOW}" \
| tee .labex/replay-first.json | jq
curl --silent --fail --request POST http://127.0.0.1:8787/reservations/replay-proof/replay-alarm \
--header 'content-type: application/json' --data "{\"now\":$REPLAY_NOW}" \
| tee .labex/replay-second.json | jq
A primeira resposta deve conter changed: true; a segunda, changed: false. As duas devem terminar com expired e expirationCount: 1. Isso é idempotência na prática: uma repetição é segura mesmo quando a plataforma não consegue saber se uma tentativa anterior foi concluída.
Executar o Alarme Real na Cloudflare
Nesta etapa, você fará o deploy do namespace descartável e verificará de forma independente um alarme real da Cloudflare.
Interrompa o runtime local e faça o deploy do Worker descartável:
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" | tee .labex/app-url
A rota do Worker e o namespace podem ficar disponíveis em momentos ligeiramente diferentes. Consulte repetidamente uma leitura inofensiva e depois crie uma retenção curta e uma longa:
for attempt in $(seq 1 30); do
curl --silent --fail "$APP_URL/" >/dev/null && break
sleep 1
done
curl --silent --fail --request POST "$APP_URL/reservations/cloud-expiring" \
--header 'content-type: application/json' --data '{"ttlSeconds":12}' | jq
curl --silent --fail --request POST "$APP_URL/reservations/cloud-safe" \
--header 'content-type: application/json' --data '{"ttlSeconds":3600}' | jq
for attempt in $(seq 1 50); do
CLOUD_STATE="$(curl --silent --fail "$APP_URL/reservations/cloud-expiring")"
test "$(printf '%s' "$CLOUD_STATE" | jq -r .status)" = expired && break
sleep 1
done
printf '%s\n' "$CLOUD_STATE" | jq
curl --silent --fail "$APP_URL/reservations/cloud-safe" | jq
A retenção curta na nuvem deve expirar exatamente uma vez; a retenção longa independente deve continuar válida. A verificação abaixo também cria objetos exclusivos para a execução atual e repete de forma independente os testes do alarme real e da repetição.
Inspecionar o Namespace, o Binding e os Logs de Alarmes
Nesta etapa, você relacionará as evidências do runtime às visualizações do Dashboard e verificará o estado após um novo deploy.
Abra Workers & Pages no Cloudflare Dashboard, selecione o Worker cujo nome exato está armazenado em $RUN e abra Settings > Bindings. A linha RESERVATIONS deve apontar para ReservationExpiry. Um binding é a rota do Worker de entrada para o namespace, não uma reserva individual.

Abra Durable Objects, selecione o namespace associado ao mesmo Worker e confirme que ele usa armazenamento SQLite. O namespace é a coleção de todos os objetos de reserva nomeados criados por este laboratório.

Abra a aba Logs do namespace e escolha uma linha cujos detalhes informem eventType: "alarm". O evento testado também informa entrypoint: "ReservationExpiry" e outcome: "ok", relacionando a ativação agendada à classe que você escreveu. Os próprios campos de log retryCount e isRetry do handler podem ajudar no diagnóstico de falhas; a correção ainda depende do estado condicional persistente, não da suposição de que a primeira tentativa sempre será concluída.

Os dados do Dashboard podem aparecer depois que a requisição for concluída. O runtime e as verificações da API continuam sendo a fonte de verdade. Estas imagens servem como orientação a partir da execução descartável testada; seu sufixo, seus timestamps e seus totais de tráfego serão diferentes.
Faça o redeploy do código sem alterações e comprove que os dois estados persistem:
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/reservations/cloud-expiring" | jq
curl --silent --fail "$APP_URL/reservations/cloud-safe" | jq
Excluir o Namespace de Alarmes
Nesta etapa, você excluirá o namespace descartável exato e o Worker enquanto a VM ainda estiver autorizada. Manter a autorização até a execução da verificação no backend permite que o LabEx diferencie uma exclusão comprovada de uma falha de rede ou de autenticação.
Confirme que $RUN começa com labex-c10-o04-. Crie um entrypoint 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 somente o namespace da classe deste laboratório, incluindo seus objetos descartáveis e alarmes longos pendentes:
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": {
"ReservationExpiry": { "type": "durable-object", "state": "deleted" }
}
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
A saída de reconciliação deve informar Deleted: ReservationExpiry. Exclua o Worker sem estado restante. O Wrangler pedirá confirmação porque a exclusão não pode ser desfeita; confirme somente depois que o nome exibido corresponder exatamente ao valor de $RUN:
npx wrangler delete --config wrangler.cleanup.jsonc
No prompt, digite y e pressione Enter. O comando deve terminar com Successfully deleted, seguido pelo nome gerado do Worker.
Mantenha esta VM autorizada para a verificação no final desta etapa. Confirme que o Wrangler ainda informa uma sessão autenticada:
npx wrangler whoami --json | jq '{loggedIn, authType}'
O JSON deve conter "loggedIn": true. Agora o LabEx pode consultar a conta selecionada e comprovar que o Worker e seu namespace de Durable Object não existem mais. Um erro de rede ou de autenticação não comprova a limpeza.
Revogar a Autorização do Wrangler para Esta VM
Nesta etapa, você revogará a autorização OAuth armazenada somente nesta VM nova depois que a exclusão dos recursos na nuvem tiver sido verificada.
wrangler logout remove a autorização local. A verificação estruturada com whoami --json é importante porque a saída legível por humanos pode ser ambígua; o campo loggedIn é o resultado oficial:
npx wrangler logout
npx wrangler whoami --json
O JSON final deve conter "loggedIn": false. Isso não exclui nem desconecta sua conta de aprendizagem da Cloudflare no navegador; apenas impede que esta VM faça novas requisições autenticadas do Wrangler.
Resumo
Você criou uma reserva descartável por Durable Object nomeado e atribuiu a cada objeto um alarme persistente. Observou uma expiração agendada sobreviver à reinicialização do runtime local, comprovou que uma reserva separada continuou válida e usou uma transição condicional do SQLite para tornar as expirações repetidas seguras. Você repetiu o comportamento do alarme real na Cloudflare, inspecionou o binding, o namespace e os logs, verificou o estado após um novo deploy e removeu o namespace descartável exato antes de sair da sessão.
A regra de design reutilizável é: agende o trabalho futuro de forma persistente, suponha que ele possa ser tentado novamente e armazene estado suficiente para que o próprio efeito decida se já aconteceu.



