Coordonner des réservations concurrentes

CloudflareBeginner
Pratiquer maintenant

Introduction

Il reste quatre places dans un atelier, mais dix personnes peuvent cliquer presque simultanément sur Reserve. Si chaque requête lit d'abord reserved = 0, attend, puis écrit reserved = 1, l'application perd des réservations pourtant réussies. Une autre conception défectueuse peut même attribuer plus de places que l'atelier n'en possède. Ce chevauchement entre tâches asynchrones non terminées s'appelle l'entrelacement (interleaving).

Dans ce lab, chaque nom d'atelier validé sélectionne un Durable Object. Cet objet possède une ligne de capacité et un enregistrement persistant pour chaque tentative. Sa méthode de réservation effectue la vérification de capacité, la mise à jour du compteur et l'enregistrement de la tentative dans une seule transaction SQLite synchrone. Plusieurs appelants peuvent arriver en même temps, mais aucun ne peut observer une transition partiellement terminée.

Vous découvrirez trois limites liées entre elles :

  • La concurrence signifie que plusieurs opérations sont en cours pendant la même période ; elle ne nécessite pas plusieurs threads JavaScript.
  • L'atomicité signifie que les autres opérations observent soit la modification complète de l'état, soit aucune modification.
  • L'initialisation sûre crée une ligne manquante sans écraser une ligne qui contient déjà des réservations.

Vous enverrez des scénarios locaux et cloud concurrents, comparerez les totaux acceptés et refusés avec l'état persistant, redémarrerez l'environnement local, redéploierez le Worker cloud, inspecterez le Dashboard et supprimerez toutes les ressources temporaires.

Avant d'accéder directement à ce cours, terminez Connect LabEx to Your Cloudflare Account. Chaque nouvelle VM doit disposer de sa propre autorisation Wrangler. Vous devez déjà comprendre les noms stables des Durable Objects, le RPC et l'état géré par SQLite abordés dans O01–O02.

Cloudflare prend actuellement en charge les Durable Objects reposant sur SQLite avec Workers Free. Ce lab crée un namespace de classe temporaire, plusieurs petits objets nommés et des lots de requêtes limités. La configuration installe Node.js 22.22.0 et Wrangler 4.132.0, installé localement dans le projet, dans /home/labex/project/concurrent-reservations ; elle n'autorise pas Cloudflare, ne crée pas de namespace, ne déploie pas de Worker et n'effectue aucune réservation.

Autoriser la VM et configurer le namespace de l'atelier

Dans cette étape, vous allez autoriser la nouvelle VM et déclarer une classe de Durable Object reposant sur SQLite. Chaque nom d'atelier sélectionnera un objet différent dans ce namespace.

Accédez au projet, vérifiez la version figée de Wrangler et démarrez l'autorisation par appareil :

cd /home/labex/project/concurrent-reservations
npx wrangler --version
npx wrangler login --device --browser=false

Wrangler doit afficher 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 prévu, puis autorisez l'accès. Revenez uniquement après le message de réussite de Wrangler. Ne collez jamais de mot de passe ou de jeton dans le lab.

Lisez les champs d'identité non sensibles, sélectionnez l'ID du compte confirmé sans l'afficher et générez un nom de Worker unique :

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"

Si votre compte d'apprentissage dédié porte un autre nom d'affichage, remplacez celui-ci par le nom que vous avez confirmé. 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": "WORKSHOPS", "class_name": "WorkshopReservations" }
    ]
  },
  "exports": {
    "WorkshopReservations": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

WORKSHOPS est la liaison vers le namespace du Worker frontal. L'export de classe fournit à chaque atelier nommé une base de données SQLite privée. Aucune ressource cloud n'existe avant le déploiement.

Implémenter une transition de réservation atomique

Dans cette étape, vous allez créer les tables persistantes de capacité et de tentatives, puis implémenter une transition de réservation atomique.

Le constructeur s'exécute chaque fois que Cloudflare crée ou redémarre une instance de classe en mémoire. CREATE TABLE IF NOT EXISTS recrée le schéma manquant en toute sécurité. L'instruction INSERT ... ON CONFLICT DO NOTHING insère la ligne de capacité de quatre places uniquement si elle n'existe pas ; elle ne remet jamais une valeur reserved existante à zéro.

Créez le point d'entrée du 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() n'accepte que des opérations de stockage synchrones. La clause UPDATE protégée ne modifie le compteur que si le nombre de places demandé tient encore dans la capacité, et RETURNING lit la valeur produite par cette même instruction. La ligne de tentative est validée dans la même transaction. Un requestId répété renvoie la première décision au lieu de consommer deux fois la capacité.

Exécutez les tests de routage déterministes et vérifiez le bundle réel :

NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run

Vous devez obtenir deux tests réussis et une simulation de déploiement réussie. Aucune ressource distante n'est créée.

Envoyer dix requêtes locales simultanément

Dans cette étape, dix commandes de réservation seront exécutées ensemble pour un atelier de quatre places. xargs -P 10 démarre jusqu'à dix processus shell simultanément ; leur ordre de fin n'est volontairement pas défini.

Démarrez l'environnement local avec un répertoire de persistance explicite :

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

Envoyez dix tentatives d'une place au même objet stable. Chaque processus écrit sa réponse dans un fichier distinct afin que les sorties concurrentes du terminal ne se mélangent pas :

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"
' _ {}

Le code HTTP 409 est la réponse applicative attendue pour une réservation refusée. curl --silent enregistre tout de même son corps JSON, ce qui vous permet d'examiner chaque décision sans considérer un atelier complet comme une erreur de transport. Inspectez toutes les décisions sous la forme d'un seul tableau :

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

Les identifiants de requête acceptés peuvent varier, car l'ordre d'arrivée n'est pas garanti. L'invariant, lui, ne varie pas : exactement quatre réservations sont acceptées, six sont refusées et aucune réponse n'indique plus de quatre places réservées.

Lisez les totaux persistants de l'objet :

curl --silent http://127.0.0.1:8787/workshops/launch-day/status | jq

Vous devez obtenir une capacité de 4, reserved à 4, quatre requêtes acceptées, six requêtes refusées et quatre places acceptées. Les fichiers de réponse expliquent les résultats individuels ; la ligne d'état prouve que leurs totaux correspondent à l'état persistant.

Redémarrer sans réinitialiser la capacité

Dans cette étape, vous allez supprimer l'instance de classe en mémoire en arrêtant Wrangler, démarrer un nouvel environnement sur la même base de données et démontrer que l'initialisation ne restaure pas la capacité.

Arrêtez et redémarrez le processus :

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

Lisez l'état de l'atelier avant de prendre une nouvelle décision :

curl --silent http://127.0.0.1:8787/workshops/launch-day/status | jq

Il doit toujours indiquer reserved: 4. Le constructeur s'est exécuté à nouveau, mais ON CONFLICT DO NOTHING a préservé la ligne existante.

Rejouez le premier identifiant de requête, puis envoyez une nouvelle requête alors que l'atelier est complet :

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

La réponse rejouée contient replayed: true et n'ajoute pas de nouvelle tentative. Le nouvel identifiant est refusé une fois. Les totaux finaux restent de quatre places acceptées et passent à sept requêtes refusées.

Tester la concurrence dans le cloud

Dans cette étape, vous allez arrêter l'environnement local, déployer le namespace et répéter le test de concurrence limitée sur Cloudflare.

Arrêtez le processus local et déployez :

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"

La route du Worker et son namespace de Durable Object nouvellement réconcilié peuvent devenir disponibles à des moments différents. Interrogez un objet réel pour vérifier le contrat JSON attendu, puis attendez la courte période de stabilisation testée avant de créer un autre objet :

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

Envoyez dix tentatives cloud concurrentes à 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" {}

Comparez les totaux des réponses avec l'état persistant :

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

L'invariant cloud doit correspondre au résultat local : quatre acceptées, six refusées et reserved: 4. Exécutez la vérification indépendante. Elle inspecte la liaison et le namespace utilisés, vérifie cloud-launch, puis envoie douze requêtes concurrentes à un atelier distinct dont le nom est unique pour cette exécution :

python3 .labex/verify.py deployed

Redéployer et inspecter la coordination des réservations

Dans cette étape, vous allez redéployer le Worker inchangé. Cela peut remplacer l'instance de classe en mémoire, et le constructeur peut donc s'exécuter à nouveau. La ligne de capacité persistante doit rester complète.

Redéployez, puis lisez cloud-launch avec une nouvelle requête :

npx wrangler deploy
curl --silent "$APP_URL/workshops/cloud-launch/status" | jq

Vous devez obtenir la même capacité 4, reserved à 4, quatre requêtes acceptées et six requêtes refusées. Cette vérification après redémarrage cloud mène à la même conclusion que celle du redémarrage local : l'initialisation sûre crée l'état manquant, mais n'écrase jamais l'état déjà établi.

Ouvrez le Cloudflare Dashboard et sélectionnez le même compte. Accédez à Workers & Pages, ouvrez le Worker labex-c10-o03-... exact et sélectionnez Bindings. Vérifiez que WORKSHOPS cible le namespace WorkshopReservations testé.

Le Worker accepté relie WORKSHOPS au namespace de Durable Object WorkshopReservations

Le suffixe visible dans la capture d'écran appartient à l'exécution d'auteur acceptée. Le suffixe généré pour votre exécution sera différent ; le nom de liaison, le type et la classe cible sont les champs qui doivent correspondre.

Ouvrez le namespace et sélectionnez Overview. Storage: SQL identifie le backend qui possède les tables de capacité et de tentatives.

La vue d'ensemble du namespace WorkshopReservations confirme le stockage SQL

Ouvrez maintenant Logs. Les lignes WorkshopReservations.jsrpc réussies correspondent aux appels de méthodes de l'objet effectués par les lots concurrents et le vérificateur. Plusieurs ID d'objet apparaissent, car les ateliers readiness, cloud-launch et ceux du vérificateur, uniques pour chaque exécution, sont volontairement isolés. Les journaux affichent les appels et les erreurs ; les totaux HTTP restent la preuve faisant autorité que la capacité a été respectée.

Des appels RPC de réservation réussis apparaissent pour plusieurs ID de Durable Objects isolés

Exécutez une nouvelle fois la vérification cloud indépendante après le redéploiement :

python3 .labex/verify.py deployed

Supprimer le namespace de réservations et se déconnecter

Dans cette étape, vous allez supprimer définitivement le namespace temporaire et ses bases de données d'atelier, supprimer le Worker restant et révoquer l'autorisation de cette VM.

Vérifiez que $RUN commence par labex-c10-o03-. 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 exactement le même Worker et le même compte. Le marqueur state: "deleted" supprime définitivement uniquement le namespace de 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

La sortie de réconciliation doit indiquer Deleted: WorkshopReservations. Supprimez le Worker sans état restant et vérifiez qu'il s'agit uniquement du nom généré exact :

npx wrangler delete --config wrangler.cleanup.jsonc

Prouvez l'absence authentifiée avant de vous déconnecter :

python3 .labex/verify.py deleted

Uniquement après PASS: deleted, révoquez l'autorisation de la VM et inspectez l'état structuré :

npx wrangler logout
npx wrangler whoami --json

Le JSON final doit contenir "loggedIn": false. Une erreur réseau ou d'authentification ne constitue pas une preuve de nettoyage.

Résumé

Vous avez créé un service de réservation à capacité limitée dans lequel chaque nom d'atelier sélectionne un Durable Object. Une transaction SQLite synchrone a combiné la vérification de capacité, la modification du compteur et l'enregistrement de la tentative en une seule transition indivisible. Dix appelants concurrents pouvaient finir dans n'importe quel ordre, mais exactement quatre places ont été acceptées et aucune réponse n'a dépassé la capacité.

Vous avez également sécurisé l'initialisation avec ON CONFLICT DO NOTHING, rejoué un identifiant de requête stable sans double réservation et démontré que les mêmes totaux persistants subsistaient après le redémarrage local et le redéploiement cloud. Enfin, vous avez relié les preuves d'exécution à la liaison, au namespace SQL et aux journaux RPC dans le Dashboard, puis supprimé le namespace et le Worker exacts avant de vous déconnecter.