Planifier l’expiration d’une réservation

CloudflareBeginner
Pratiquer maintenant

Introduction

Une réservation temporaire doit se libérer automatiquement, même si personne ne revient visiter l’application. Un minuteur JavaScript en mémoire n’est pas fiable, car un Worker peut devenir inactif ou redémarrer avant son déclenchement. Une alarme de Durable Object enregistre au contraire une heure de réveil future avec l’état durable de l’objet. Lorsque cette heure arrive, Cloudflare réveille l’objet et appelle sa méthode alarm().

Les alarmes offrent une exécution au moins une fois : Cloudflare réessaie un gestionnaire qui a échoué, de sorte que le même effet attendu peut être tenté plusieurs fois. L’opération d’expiration doit donc être idempotente : son exécution plusieurs fois doit produire le même état final qu’une seule exécution. Vous utiliserez une mise à jour SQLite conditionnelle afin que seule une réservation held puisse devenir expired. Son compteur augmente dans la même mise à jour et ne peut pas augmenter lors d’une nouvelle exécution.

Ce laboratoire utilise un Durable Object nommé par réservation. Chaque réservation possède donc son propre emplacement d’alarme, disponible dans son objet. Vous planifierez des réservations courtes et longues, redémarrerez l’environnement local avant le déclenchement d’une alarme, rejouerez volontairement deux fois le chemin d’expiration, répéterez un test d’alarme réel sur Cloudflare, inspecterez le Dashboard, redéploierez, puis effectuerez le nettoyage.

Avant d’accéder directement à ce cours, terminez Connect LabEx to Your Cloudflare Account. Chaque nouvelle VM doit être autorisée avec Wrangler. Vous devez déjà comprendre les noms stables des objets, le RPC, l’état stocké dans SQLite et la concurrence limitée abordés dans O01–O03.

Un nouvel appel à setAlarm() remplace l’alarme du même objet ; les autres objets nommés conservent leurs propres alarmes. La configuration installe Node.js 22.22.0 et Wrangler 4.132.0, installé localement dans le projet, sous /home/labex/project/reservation-expiry. Elle n’autorise pas Cloudflare, ne crée aucune alarme et ne déploie aucun Worker.

Autoriser la VM et déclarer l’espace de noms des alarmes

Dans cette étape, vous allez autoriser la nouvelle VM et déclarer une classe de Durable Object utilisant SQLite pour les réservations planifiées.

Accédez au projet, vérifiez la version épinglée de Wrangler, puis autorisez cette nouvelle VM :

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

Wrangler doit afficher la version 4.132.0. Ouvrez dans le navigateur l’URL Cloudflare affichée, saisissez le code court, vérifiez qu’il s’agit bien du compte d’apprentissage voulu, puis autorisez-le. Ne collez jamais de mot de passe ni de jeton dans le terminal ou le laboratoire.

Lisez uniquement les champs d’identité non sensibles, sélectionnez le compte que vous avez vérifié et créez un nom de Worker unique et temporaire :

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 votre compte d’apprentissage dédié possède un autre nom d’affichage, remplacez ce nom par celui que vous avez vérifié. Créez la configuration :

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 permet au Worker d’entrée de sélectionner un objet à partir de l’identifiant de réservation. L’export de la classe fournit à chaque objet sélectionné un stockage SQLite privé et un emplacement d’alarme. Rien n’existe dans le cloud avant le déploiement.

Implémenter une expiration persistante et idempotente

Dans cette étape, vous allez implémenter l’état durable d’une réservation, la planification de l’alarme et une transition d’expiration sûre en cas de rejeu.

Créez l’application. L’élément important est la mise à jour conditionnelle UPDATE, et non la gestion 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) enregistre un horodatage Unix absolu. getAlarm() permet d’observer la planification. Lorsque l’échéance est atteinte, une instruction SQL fait passer held à expired et augmente le compteur. Lors d’une nouvelle tentative, l’état est déjà expired, donc la condition WHERE status = 'held' ne trouve aucune ligne.

La route /replay-alarm est un point d’injection destiné aux tests : elle appelle exactement la méthode utilisée par alarm(), avec une horloge explicite. Elle démontre immédiatement la sûreté en cas de rejeu, sans devoir provoquer artificiellement une défaillance Cloudflare.

Exécutez les tests de routage déterministes et une compilation 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

Vérifier qu’une alarme survit à un redémarrage local

Dans cette étape, vous allez planifier deux réservations locales, redémarrer Wrangler et vérifier que seule la réservation arrivée à échéance expire.

Démarrez l’environnement local des Durable Objects de Wrangler et attendez qu’il soit 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

Créez une réservation de 30 secondes et une réservation indépendante d’une heure. La réservation plus longue vous laisse suffisamment de temps pour arrêter Wrangler avant le déclenchement de sa propre alarme :

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

Les deux réponses doivent afficher held, expirationCount: 0 et une valeur numérique pour alarmAt. Arrêtez l’environnement avant l’échéance de la première alarme, puis rouvrez le même stockage local durable :

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 réservation courte doit être expired exactement une fois et son alarme doit être null. L’objet indépendant doit rester held avec sa propre alarme. C’est ce qui distingue une alarme persistante de setTimeout().

Rejouer l’expiration sans l’appliquer deux fois

Dans cette étape, vous allez appeler deux fois le chemin d’expiration avec la même échéance logique et comparer les deux résultats.

Créez une réservation longue afin que sa véritable alarme ne puisse pas interférer avec cette vérification déterministe. Capturez l’échéance enregistrée :

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

Appelez deux fois la même méthode d’expiration avec une horloge située juste après l’échéance :

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 première réponse doit contenir changed: true ; la seconde, changed: false. Les deux réponses finales doivent indiquer expired et expirationCount: 1. C’est l’idempotence en pratique : une nouvelle tentative reste sûre, même si la plateforme ne peut pas savoir si la tentative précédente s’est terminée.

Exécuter la véritable alarme sur Cloudflare

Dans cette étape, vous allez déployer l’espace de noms temporaire et vérifier indépendamment une véritable alarme Cloudflare.

Arrêtez l’environnement local et déployez le Worker temporaire :

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 route du Worker et l’espace de noms peuvent devenir disponibles à des moments légèrement différents. Interrogez une route de lecture sans effet, puis créez une réservation courte et une réservation longue :

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 réservation cloud courte doit expirer exactement une fois ; la réservation longue indépendante doit rester valide. La vérification ci-dessous crée également de nouveaux objets propres à cette exécution et répète séparément les tests de l’alarme réelle et du rejeu.

Inspecter l’espace de noms, la liaison et les journaux des alarmes

Dans cette étape, vous allez mettre en relation les éléments observés à l’exécution avec les vues du Dashboard et vérifier l’état après un redéploiement.

Ouvrez Workers & Pages dans le Dashboard Cloudflare, sélectionnez le Worker dont le nom exact est enregistré dans $RUN, puis ouvrez Settings > Bindings. La ligne RESERVATIONS doit pointer vers ReservationExpiry. Une liaison est le point d’entrée du Worker vers l’espace de noms ; elle ne représente pas une réservation individuelle.

La liaison Durable Object RESERVATIONS pointe vers ReservationExpiry

Ouvrez Durable Objects, sélectionnez l’espace de noms associé au même Worker et vérifiez qu’il utilise le stockage SQLite. Cet espace de noms regroupe tous les objets de réservation nommés créés par ce laboratoire.

L’espace de noms ReservationExpiry utilise le stockage SQLite

Ouvrez l’onglet Logs de l’espace de noms et choisissez une ligne dont les détails indiquent eventType: "alarm". L’événement testé doit également indiquer entrypoint: "ReservationExpiry" et outcome: "ok", ce qui relie le réveil planifié à la classe que vous avez écrite. Les champs de journalisation retryCount et isRetry du gestionnaire peuvent aider à diagnostiquer les échecs ; la correction repose néanmoins sur l’état conditionnel durable, et non sur l’hypothèse qu’une première tentative réussira.

Un événement d’alarme réussi indique le point d’entrée ReservationExpiry

Les données du Dashboard peuvent apparaître après la requête. L’environnement d’exécution et les vérifications via l’API restent les sources de référence. Ces images servent de repères et proviennent de l’exécution temporaire testée ; votre suffixe, vos horodatages et vos totaux de trafic seront différents.

Redéployez le code inchangé et vérifiez que les deux états sont conservés :

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

Supprimer l’espace de noms des alarmes

Dans cette étape, vous allez supprimer l’espace de noms temporaire exact et le Worker, alors que la VM est encore autorisée. Conserver l’autorisation jusqu’à l’exécution de la vérification côté backend permet à LabEx de distinguer une suppression confirmée d’un échec réseau ou d’authentification.

Vérifiez que $RUN commence par labex-c10-o04-. Créez un point d’entrée de nettoyage sans état :

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

Créez une configuration de nettoyage pour le même Worker et le même compte. Le marqueur state: "deleted" supprime uniquement l’espace de noms de classes de ce laboratoire, y compris ses objets temporaires et ses alarmes longues encore actives :

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 sortie de rapprochement doit indiquer Deleted: ReservationExpiry. Supprimez le Worker sans état restant. Wrangler demande une confirmation, car cette suppression est irréversible ; confirmez uniquement lorsque le nom affiché correspond exactement à la valeur de $RUN :

npx wrangler delete --config wrangler.cleanup.jsonc

À l’invite, saisissez y, puis appuyez sur Entrée. La commande doit se terminer par Successfully deleted, suivi du nom de Worker généré.

Laissez cette VM autorisée pour la vérification à la fin de l’étape. Vérifiez que Wrangler indique toujours une session authentifiée :

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

Le JSON doit contenir "loggedIn": true. LabEx peut maintenant interroger le compte sélectionné et confirmer que le Worker et son espace de noms Durable Object sont absents. Une erreur réseau ou d’authentification ne constitue pas une preuve de nettoyage.

Révoquer l’autorisation Wrangler de cette VM

Dans cette étape, vous allez révoquer l’autorisation OAuth stockée uniquement sur cette nouvelle VM, après avoir vérifié la suppression des ressources cloud.

wrangler logout supprime l’autorisation locale. La vérification structurée avec whoami --json est importante, car la sortie destinée aux utilisateurs peut être ambiguë ; le champ loggedIn constitue le résultat de référence :

npx wrangler logout
npx wrangler whoami --json

Le JSON final doit contenir "loggedIn": false. Cette opération ne supprime pas votre compte d’apprentissage Cloudflare et ne vous déconnecte pas de celui-ci dans le navigateur ; elle empêche uniquement cette VM d’effectuer d’autres requêtes Wrangler authentifiées.

Résumé

Vous avez créé une réservation temporaire par Durable Object nommé et attribué à chaque objet une alarme persistante. Vous avez observé qu’une expiration planifiée survivait au redémarrage de l’environnement local, vérifié qu’une autre réservation restait valide et utilisé une transition SQLite conditionnelle pour rendre les expirations répétées sûres. Vous avez reproduit le comportement d’une véritable alarme sur Cloudflare, inspecté la liaison, l’espace de noms et les journaux, vérifié la conservation des états après un redéploiement, puis supprimé l’espace de noms temporaire exact avant de vous déconnecter.

La règle de conception réutilisable est la suivante : planifiez durablement le travail futur, partez du principe qu’il peut être tenté à nouveau et stockez suffisamment d’état pour que l’effet lui-même puisse déterminer s’il a déjà eu lieu.