Programar la caducidad de reservas

CloudflareBeginner
Practicar Ahora

Introducción

Una reserva temporal debe liberarse aunque nadie vuelva a visitar la aplicación. Un temporizador de JavaScript en memoria no es seguro, porque un Worker puede quedar inactivo o reiniciarse antes de que se active el temporizador. En cambio, una alarma de Durable Object almacena una hora futura de activación junto con el estado persistente del objeto. Cuando llega esa hora, Cloudflare activa el objeto y llama a su método alarm().

Las alarmas tienen una ejecución al menos una vez: Cloudflare vuelve a intentar un controlador que ha fallado, por lo que el mismo efecto previsto puede intentarse de nuevo. Por eso, la operación de caducidad debe ser idempotente: ejecutarla más de una vez debe producir el mismo estado final que ejecutarla una sola vez. Utilizará una actualización condicional de SQLite para que solo una reserva con estado held pueda pasar a expired; el contador aumentará en la misma actualización y no volverá a aumentar si se repite la operación.

Este laboratorio utiliza un Durable Object con nombre por cada reserva. Por lo tanto, cada reserva tiene su propia ranura de alarma, que es la única disponible para su objeto. Programará reservas cortas y largas, reiniciará el entorno de ejecución local antes de que se active una alarma, repetirá deliberadamente la ruta de caducidad dos veces, repetirá una prueba de alarma real en Cloudflare, inspeccionará el Dashboard, volverá a implementar y limpiará los recursos.

Antes de entrar directamente en este curso, complete Conectar LabEx con su cuenta de Cloudflare. Cada VM nueva necesita su propia autorización de Wrangler. Ya debe comprender los nombres estables de objetos, RPC, el estado respaldado por SQLite y la concurrencia limitada de O01–O03.

Volver a llamar a setAlarm() reemplaza la alarma del mismo objeto; los demás objetos con nombre conservan sus propias alarmas. La configuración instala Node.js 22.22.0 y Wrangler 4.132.0 local para el proyecto en /home/labex/project/reservation-expiry. No autoriza a Cloudflare, no crea una alarma ni implementa un Worker.

Autorizar la VM y declarar el espacio de nombres de alarmas

En este paso, autorizará la VM nueva y declarará una clase de Durable Object respaldada por SQLite para las reservas programadas.

Entre en el proyecto, confirme la versión fijada de Wrangler y autorice esta VM nueva:

cd /home/labex/project/reservation-expiry
npx wrangler --version
npx wrangler login --device --browser=false

Espere obtener Wrangler 4.132.0. Abra en el navegador la URL de Cloudflare que se muestra, introduzca el código corto, confirme la cuenta de aprendizaje correcta y autorícela. Nunca pegue una contraseña ni un token en el terminal o en el laboratorio.

Lea únicamente los campos de identidad seguros, seleccione la cuenta que confirmó y genere un nombre único para el Worker temporal:

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"

Si su cuenta de aprendizaje dedicada tiene otro nombre visible, sustituya el nombre por el que confirmó. Cree la configuración:

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 el Worker de entrada seleccione un objeto mediante el ID de la reserva. La exportación de la clase proporciona a cada objeto seleccionado almacenamiento SQLite privado y una ranura de alarma. Hasta que se implemente, no existirá nada en la nube.

Implementar una caducidad persistente e idempotente

En este paso, implementará el estado persistente de las reservas, la programación de alarmas y una transición de caducidad segura frente a repeticiones.

Cree la aplicación. La parte importante es UPDATE condicional, no la infraestructura 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) almacena una marca de tiempo Unix absoluta. getAlarm() permite consultar la programación. Cuando llega la hora, una instrucción SQL cambia held a expired y aumenta el contador. Un reintento encuentra expired, por lo que la condición WHERE status = 'held' no coincide con ninguna fila.

La ruta /replay-alarm es un punto de prueba deliberado: llama al mismo método que utiliza alarm() con un reloj explícito. Así puede comprobar inmediatamente la seguridad frente a repeticiones sin provocar un error de Cloudflare.

Ejecute las pruebas deterministas de enrutamiento y una compilación de 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

Comprobar que una alarma sobrevive a un reinicio local

En este paso, programará dos reservas locales, reiniciará Wrangler y observará que solo caduca la reserva cuya hora ha llegado.

Inicie el entorno local de Durable Objects de Wrangler y espere a que esté disponible:

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

Cree una reserva de 30 segundos y otra independiente de una hora. La reserva corta más larga le dará tiempo suficiente para detener Wrangler antes de que llegue su alarma:

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

Ambas respuestas muestran held, expirationCount: 0 y un valor numérico en alarmAt. Detenga el entorno antes de que llegue la primera alarma y vuelva a abrir el mismo almacenamiento 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

La reserva corta debe aparecer como caducada exactamente una vez y su alarma debe ser null; el objeto independiente debe seguir en estado held con su propia alarma. Esta es la diferencia entre una alarma persistente y setTimeout().

Repetir la caducidad sin aplicarla dos veces

En este paso, invocará dos veces la ruta de caducidad con la misma fecha límite lógica y comparará ambas respuestas.

Cree una reserva larga para que su alarma real no interfiera con esta comprobación determinista. Guarde la fecha límite almacenada:

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')"

Llame dos veces al mismo método de caducidad con un reloj situado justo después de la fecha límite:

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

La primera respuesta tiene changed: true; la segunda, changed: false. Ambas terminan con expired y expirationCount: 1. Esta es la idempotencia práctica: un reintento es seguro aunque la plataforma no pueda saber si el intento anterior terminó.

Ejecutar la alarma real en Cloudflare

En este paso, implementará el espacio de nombres temporal y verificará de forma independiente una alarma real de Cloudflare.

Detenga el entorno local e implemente el Worker temporal:

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

La ruta del Worker y el espacio de nombres pueden estar disponibles en momentos ligeramente distintos. Consulte repetidamente una lectura segura y, después, cree una reserva corta y otra larga:

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

La reserva corta en la nube debe caducar exactamente una vez; la reserva larga independiente debe seguir siendo válida. La comprobación siguiente también crea objetos nuevos y exclusivos de esta ejecución, y repite de forma independiente las pruebas de alarma real y de repetición.

Inspeccionar el espacio de nombres, el binding y los registros de alarmas

En este paso, relacionará las pruebas del entorno de ejecución con las vistas del Dashboard y verificará el estado después de volver a implementar.

Abra Workers & Pages en el Dashboard de Cloudflare, seleccione el Worker cuyo nombre exacto está almacenado en $RUN y abra Settings > Bindings. La fila RESERVATIONS debe apuntar a ReservationExpiry. Un binding es la ruta del Worker de entrada hacia el espacio de nombres, no una reserva individual.

The RESERVATIONS Durable Object binding points to ReservationExpiry

Abra Durable Objects, seleccione el espacio de nombres asociado al mismo Worker y confirme que utiliza almacenamiento SQLite. El espacio de nombres es la colección de todos los objetos de reserva con nombre que crea este laboratorio.

The owned ReservationExpiry namespace uses SQLite storage

Abra la pestaña Logs del espacio de nombres y elija una fila cuyos detalles indiquen eventType: "alarm". El evento probado también muestra entrypoint: "ReservationExpiry" y outcome: "ok", lo que relaciona la activación programada con la clase que escribió. Los campos de registro retryCount e isRetry del propio controlador pueden ayudar a diagnosticar errores; la corrección sigue dependiendo del estado condicional persistente, no de suponer que el primer intento siempre termina correctamente.

A successful alarm event names the ReservationExpiry entrypoint

Los datos del Dashboard pueden aparecer después de la solicitud. Las comprobaciones del entorno de ejecución y de la API siguen siendo la fuente autoritativa. Estas imágenes sirven como orientación y proceden de la ejecución temporal probada; el sufijo, las marcas de tiempo y los totales de tráfico serán diferentes.

Vuelva a implementar el código sin cambios y compruebe que ambos estados persisten:

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

Eliminar el espacio de nombres de alarmas

En este paso, eliminará el espacio de nombres temporal exacto y el Worker mientras la VM siga autorizada. Mantener la autorización hasta ejecutar la comprobación del backend permite a LabEx distinguir una eliminación comprobada de un error de red o de autenticación.

Confirme que $RUN comienza por labex-c10-o04-. Cree un punto de entrada de limpieza sin estado:

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

Cree una configuración de limpieza para el mismo Worker y la misma cuenta. La marca state: "deleted" elimina únicamente el espacio de nombres de clase de este laboratorio, incluidos sus objetos temporales y las alarmas largas pendientes:

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

La salida de conciliación debe indicar Deleted: ReservationExpiry. Elimine el Worker sin estado restante. Wrangler solicita confirmación porque la eliminación no se puede deshacer; confirme únicamente después de comprobar que el nombre mostrado coincide exactamente con el valor de $RUN:

npx wrangler delete --config wrangler.cleanup.jsonc

Cuando aparezca la solicitud de confirmación, escriba y y pulse Enter. El comando debe terminar con Successfully deleted seguido del nombre generado para el Worker.

Mantenga esta VM autorizada para la comprobación del final de este paso. Confirme que Wrangler todavía informa de una sesión autenticada:

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

El JSON debe contener "loggedIn": true. LabEx ya puede consultar la cuenta seleccionada y comprobar que tanto el Worker como su espacio de nombres de Durable Object han desaparecido. Un error de red o de autenticación no demuestra que la limpieza se haya completado.

Revocar la autorización de Wrangler de esta VM

En este paso, revocará la autorización de OAuth almacenada únicamente en esta VM nueva, después de verificar la eliminación de los recursos de la nube.

wrangler logout elimina la autorización local. La comprobación estructurada con whoami --json es importante porque la salida legible para humanos puede ser ambigua; el campo loggedIn es el resultado autoritativo:

npx wrangler logout
npx wrangler whoami --json

El JSON final debe contener "loggedIn": false. Esto no elimina ni cierra la sesión de su cuenta de aprendizaje de Cloudflare en el navegador; únicamente impide que esta VM realice más solicitudes autenticadas de Wrangler.

Resumen

Creó una reserva temporal por cada Durable Object con nombre y asignó a cada objeto una alarma persistente. Observó que una caducidad programada sobrevivía al reinicio del entorno local, comprobó que una reserva independiente seguía siendo válida y utilizó una transición condicional de SQLite para que la repetición de la caducidad fuera segura. Repitió el comportamiento de la alarma real en Cloudflare, inspeccionó el binding, el espacio de nombres y los registros, verificó que el estado sobrevivía a una nueva implementación y eliminó el espacio de nombres temporal exacto antes de cerrar la sesión.

La regla de diseño reutilizable es la siguiente: programe el trabajo futuro de forma persistente, suponga que puede intentarse de nuevo y almacene suficiente estado para que el propio efecto determine si ya se produjo.