Coordinar reservas simultáneas

CloudflareBeginner
Practicar Ahora

Introducción

A un taller le quedan cuatro plazas, pero diez personas pueden hacer clic en Reserve casi al mismo tiempo. Si cada solicitud lee primero reserved = 0, espera y después escribe reserved = 1, la aplicación pierde reservas confirmadas. Otro diseño defectuoso podría aprobar más plazas de las disponibles en el taller. Esta superposición entre tareas asíncronas que aún no han terminado se denomina intercalado (interleaving).

En este laboratorio, cada nombre de taller validado selecciona un Durable Object. Ese objeto posee una fila de capacidad y un registro persistente para cada intento. Su método de reserva ejecuta la comprobación de capacidad, la actualización del contador y el registro del intento dentro de una única transacción síncrona de SQLite. Las llamadas simultáneas pueden llegar juntas, pero ninguna puede observar una transición a medio completar.

Aprenderá tres límites relacionados:

  • Concurrencia significa que varias operaciones están en curso durante el mismo periodo; no requiere varios hilos de JavaScript.
  • Atomicidad significa que las demás operaciones observan el cambio de estado completo o no observan ningún cambio.
  • Inicialización segura crea una fila inexistente sin sobrescribir una fila que ya contiene reservas.

Enviará escenarios locales y en la nube de forma simultánea, comparará los totales aceptados y rechazados con el estado persistente, reiniciará el entorno de ejecución local, volverá a desplegar el Worker en la nube, inspeccionará el Dashboard y eliminará todos los recursos desechables.

Antes de entrar directamente en este curso, complete Connect LabEx to Your Cloudflare Account. Cada VM nueva necesita su propia autorización de Wrangler. Ya debe comprender los nombres estables de Durable Objects, RPC y el estado respaldado por SQLite de O01–O02.

Actualmente, Cloudflare admite Durable Objects respaldados por SQLite en Workers Free. Este laboratorio crea un espacio de nombres de clase desechable, varios objetos pequeños con nombre y lotes limitados de solicitudes. La configuración instala Node.js 22.22.0 y Wrangler 4.132.0 local del proyecto en /home/labex/project/concurrent-reservations; no autoriza Cloudflare, no crea un espacio de nombres, no despliega un Worker ni realiza una reserva.

Autorizar la VM y configurar el espacio de nombres del taller

En este paso, autorizará la VM nueva y declarará una clase de Durable Object respaldada por SQLite. Cada nombre de taller seleccionará un objeto diferente dentro de este espacio de nombres.

Entre en el proyecto, confirme la versión fijada de Wrangler e inicie la autorización mediante el dispositivo:

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

Debe aparecer 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 autorice el acceso. No continúe hasta que Wrangler informe de que la autorización se realizó correctamente. Nunca pegue una contraseña ni un token en el laboratorio.

Lea los campos de identidad seguros, seleccione el ID de la cuenta confirmada sin mostrarlo y genere un nombre único para el 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-o03-$(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": "WORKSHOPS", "class_name": "WorkshopReservations" }
    ]
  },
  "exports": {
    "WorkshopReservations": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

WORKSHOPS es el binding del espacio de nombres del Worker de entrada. La exportación de la clase proporciona a cada taller con nombre una base de datos SQLite privada. No existirá ningún recurso en la nube hasta el despliegue.

Implementar una transición de reserva atómica

En este paso, creará las tablas persistentes de capacidad e intentos y después implementará una transición de reserva atómica.

El constructor se ejecuta cada vez que Cloudflare crea o reinicia una instancia de clase en memoria. CREATE TABLE IF NOT EXISTS vuelve a crear de forma segura un esquema inexistente. La instrucción INSERT ... ON CONFLICT DO NOTHING inserta la fila de capacidad de cuatro plazas solo cuando no existe; nunca restablece a cero el valor existente de reserved.

Cree el punto de entrada del 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() solo acepta operaciones de almacenamiento síncronas. La instrucción UPDATE protegida modifica el contador únicamente cuando las plazas solicitadas todavía caben, y RETURNING lee el valor producido por esa misma instrucción. La fila del intento se confirma dentro de la misma transacción. Si se repite un requestId, se devuelve su primera decisión en lugar de consumir capacidad dos veces.

Ejecute las pruebas deterministas de enrutamiento y una comprobación real del paquete:

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

Debe obtener dos pruebas superadas y una ejecución de prueba correcta. No se crea ningún recurso remoto.

Enviar simultáneamente diez solicitudes locales

En este paso, diez comandos de reserva estarán en curso a la vez para un taller de cuatro plazas. xargs -P 10 inicia hasta diez procesos de shell simultáneamente; el orden en que terminan no está definido intencionadamente.

Inicie el entorno de ejecución local con un directorio de persistencia explícito:

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

Envíe diez intentos de una plaza al mismo objeto estable. Cada proceso escribe un archivo de respuesta independiente, de modo que la salida simultánea del terminal no se mezcle:

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

HTTP 409 es la respuesta esperada de la aplicación para una reserva rechazada. curl --silent sigue guardando el cuerpo JSON, lo que le permite inspeccionar cada decisión sin tratar un taller lleno como un error de transporte. Inspeccione todas las decisiones como una única matriz:

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

Los requestId exactos aceptados pueden variar porque el orden de llegada no está garantizado. La invariante no puede variar: se aceptan exactamente cuatro, se rechazan seis y ninguna respuesta informa de más de cuatro plazas reservadas.

Lea los totales persistentes del objeto:

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

Debe obtener una capacidad de 4, reserved igual a 4, cuatro solicitudes aceptadas, seis solicitudes rechazadas y cuatro plazas aceptadas. Los archivos de respuesta explican los resultados individuales; la fila de estado demuestra que sus totales coinciden con el estado persistente.

Reiniciar sin restablecer la capacidad

En este paso, eliminará la instancia de clase en memoria deteniendo Wrangler, iniciará un entorno de ejecución nuevo con la misma base de datos y demostrará que la inicialización no restaura la capacidad.

Detenga y reinicie el proceso:

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

Lea el taller antes de tomar otra decisión:

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

Debe seguir mostrando reserved: 4. El constructor se ejecutó de nuevo, pero ON CONFLICT DO NOTHING conservó la fila existente.

Repita el primer requestId y después envíe una solicitud nueva mientras el taller está lleno:

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 repetición debe incluir replayed: true y no debe añadir otro intento. El nuevo ID se rechaza una vez. Los totales finales siguen siendo cuatro plazas aceptadas y pasan a ser siete solicitudes rechazadas.

Probar la concurrencia en la nube

En este paso, detendrá el entorno de ejecución local, desplegará el espacio de nombres y repetirá la prueba de concurrencia limitada contra Cloudflare.

Detenga el proceso local y despliegue:

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 ruta del Worker y su espacio de nombres de Durable Objects recién reconciliado pueden estar disponibles en momentos distintos. Consulte una lectura real de un objeto para comprobar el contrato JSON esperado y, después, espere el breve intervalo de estabilización probado antes de crear otro objeto:

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

Envíe diez intentos simultáneos en la nube a 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" {}

Compare los totales de las respuestas con el estado persistente:

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

La invariante en la nube coincide con el resultado local: cuatro aceptadas, seis rechazadas y reserved: 4. Ejecute la comprobación independiente. Esta inspecciona el binding y el espacio de nombres que posee, verifica cloud-launch y después envía doce solicitudes simultáneas a un taller independiente y exclusivo de esta ejecución:

python3 .labex/verify.py deployed

Volver a desplegar e inspeccionar la coordinación de reservas

En este paso, volverá a desplegar el Worker sin modificaciones. Esto puede reemplazar la instancia de clase en memoria, por lo que el constructor podría ejecutarse de nuevo. La fila de capacidad persistente debe seguir llena.

Vuelva a desplegar y lea cloud-launch mediante una solicitud nueva:

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

Debe obtener la misma capacidad 4, reserved igual a 4, cuatro solicitudes aceptadas y seis solicitudes rechazadas. Esta comprobación del reinicio en la nube llega a la misma conclusión que el reinicio local: la inicialización segura crea el estado inexistente, pero nunca sobrescribe el estado ya establecido.

Abra Cloudflare Dashboard y seleccione la misma cuenta. Vaya a Workers & Pages, abra el Worker exacto labex-c10-o03-... y seleccione Bindings. Confirme que WORKSHOPS apunta al espacio de nombres probado de WorkshopReservations.

El Worker aceptado conecta WORKSHOPS con el espacio de nombres de Durable Objects WorkshopReservations

El sufijo que aparece en la captura corresponde a la ejecución de autoría aceptada. El sufijo que usted genere será diferente; el nombre, el tipo y la clase de destino del binding son los campos que deben coincidir.

Abra el espacio de nombres y seleccione Overview. Storage: SQL identifica el backend que posee las tablas de capacidad e intentos.

La vista general del espacio de nombres WorkshopReservations confirma el almacenamiento SQL

Ahora abra Logs. Las filas correctas WorkshopReservations.jsrpc corresponden a las llamadas a métodos del objeto realizadas por los lotes simultáneos y el verificador. Aparecen varios ID de objeto porque los talleres readiness, cloud-launch y los talleres exclusivos de la ejecución del verificador están deliberadamente aislados. Los registros muestran invocaciones y errores; los totales HTTP siguen siendo la evidencia autorizada de que se respetó la capacidad.

Las llamadas RPC de reserva correctas aparecen en varios ID de Durable Objects aislados

Vuelva a ejecutar la comprobación independiente de la nube después del redespliegue:

python3 .labex/verify.py deployed

Eliminar el espacio de nombres de reservas y cerrar sesión

En este paso, eliminará permanentemente el espacio de nombres desechable y sus bases de datos de talleres, eliminará el Worker restante y revocará la autorización de esta VM.

Confirme que $RUN comienza por labex-c10-o03-. 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 exactos. El marcador state: "deleted" elimina permanentemente solo el espacio de nombres de la clase 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 salida de reconciliación debe informar Deleted: WorkshopReservations. Elimine el Worker sin estado restante y confirme únicamente el nombre generado exacto:

npx wrangler delete --config wrangler.cleanup.jsonc

Demuestre la ausencia autenticada antes de cerrar sesión:

python3 .labex/verify.py deleted

Solo después de obtener PASS: deleted, revoque la autorización de la VM e inspeccione el estado estructurado:

npx wrangler logout
npx wrangler whoami --json

El JSON final debe contener "loggedIn": false. Un error de red o de autenticación no demuestra que la limpieza se haya completado.

Resumen

Creó un servicio de reservas con capacidad limitada en el que cada nombre de taller selecciona un Durable Object. Una transacción síncrona de SQLite combinó la protección de capacidad, el cambio del contador y el registro del intento en una única transición indivisible. Diez llamadas simultáneas podían terminar en cualquier orden, pero se aceptaron exactamente cuatro plazas y ninguna respuesta superó la capacidad.

También hizo segura la inicialización con ON CONFLICT DO NOTHING, repitió un requestId estable sin reservar dos veces la misma plaza y demostró los mismos totales persistentes después de reiniciar el entorno local y volver a desplegarlo en la nube. Por último, relacionó las evidencias del entorno de ejecución con el binding, el espacio de nombres SQL y los registros RPC en el Dashboard, y después eliminó el espacio de nombres y el Worker exactos antes de cerrar sesión.