Agendar a Expiração de uma Reserva

CloudflareBeginner
Pratique Agora

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.

O binding do Durable Object RESERVATIONS aponta para ReservationExpiry

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.

O namespace ReservationExpiry pertencente ao Worker usa armazenamento SQLite

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.

Um evento de alarme bem-sucedido identifica o entrypoint ReservationExpiry

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.