Cloudflare Durable Objects — Coordinación de Reservas en Tiempo Real

Implementación TypeScript · Arquitectura edge sin servidor · Consistencia fuerte por sala

Cloudflare TypeScript Durable Objects

Arquitectura del Sistema

Edge · Sin servidor
Capa de Entrada
🌐
Cloudflare Worker
Enruta peticiones HTTP/WebSocket a la sala correcta usando getByName(roomId). Stateless, escala infinito.
Coordinación (1 DO por sala)
🏠
BookingRoom DO
Singleton por sala. Serializa todas las operaciones. Mantiene conexiones WebSocket abiertas. SQLite para persistencia.
Almacenamiento
🗄️
SQLite + Alarms
Tabla reservations co-ubicada con el DO. Alarm de 5 min libera reservas pendientes sin confirmar.

Instancias Durable Objects Activas

3 salas en producción
cowork-mad-sala-A1
mad:a1
Estado Disponible
Reservas hoy 4 / 8 slots
Capacidad 8 personas
Próxima alarma
3 clientes WebSocket conectados
cowork-mad-escritorio-12
mad:e12
Estado Pendiente confirmación
Reservas hoy 1 / 1 slot
Capacidad 1 persona
Alarma TTL 3:42 min restantes
1 cliente WebSocket conectado
cowork-bcn-sala-B3
bcn:b3
Estado Disponible
Reservas hoy 2 / 6 slots
Capacidad 4 personas
Próxima alarma
2 clientes WebSocket conectados

Implementación

BookingRoom.ts — DO principal
ts BookingRoom.ts
ts worker.ts
jsonc wrangler.jsonc
ts BookingRoom.test.ts
// src/BookingRoom.ts — Durable Object para Cultiva Cowork // Coordinación de reservas: 1 instancia DO por sala/escritorio
import { DurableObject } from "cloudflare:workers";

export interface Env { BOOKING_ROOM: DurableObjectNamespace<BookingRoom>; }

interface Reservation { id: number; userId: string; startTs: number; // unix ms endTs: number; status: "pending" | "confirmed" | "cancelled"; createdAt: number; }

export class BookingRoom extends DurableObject<Env> { private sessions: Set<WebSocket> = new Set();

constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); // ✅ Regla 4: Inicializar schema en constructor con blockConcurrencyWhile ctx.blockConcurrencyWhile(async () => { this.ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS reservations ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, start_ts INTEGER NOT NULL, end_ts INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_time ON reservations(start_ts, end_ts, status); `); }); }

// ✅ Regla 5: Métodos RPC, no fetch() handler async book( userId: string, startTs: number, endTs: number ): Promise<{ ok: boolean; id?: number; error?: string }> { // ✅ Consistencia fuerte: comprueba conflictos y escribe en la misma transacción const conflicts = this.ctx.storage.sql.exec<{ count: number }>( `SELECT COUNT(*) as count FROM reservations WHERE status IN ('pending','confirmed') AND start_ts < ? AND end_ts > ?`, endTs, startTs ).one();

if (conflicts.count > 0) { return { ok: false, error: "Slot ocupado" }; }

// ✅ Regla 6: Persistir ANTES de actualizar estado en memoria const { id } = this.ctx.storage.sql.exec<{ id: number }>( `INSERT INTO reservations (user_id, start_ts, end_ts, status, created_at) VALUES (?, ?, ?, 'pending', ?) RETURNING id`, userId, startTs, endTs, Date.now() ).one();

// ✅ Regla 7: Alarma de 5 min para liberar si no se confirma await this.ctx.storage.setAlarm(Date.now() + 5 * 60_000);

this.broadcast({ type: "booked", id, userId, startTs, endTs }); return { ok: true, id }; }

async confirm(reservationId: number, userId: string): Promise<boolean> { const result = this.ctx.storage.sql.exec( `UPDATE reservations SET status='confirmed' WHERE id=? AND user_id=? AND status='pending'`, reservationId, userId ); const ok = result.rowsWritten > 0; if (ok) { this.broadcast({ type: "confirmed", id: reservationId }); } return ok; }

async getSlots(fromTs: number, toTs: number): Promise<Reservation[]> { return this.ctx.storage.sql.exec<Reservation>( `SELECT * FROM reservations WHERE start_ts >= ? AND end_ts <= ? AND status != 'cancelled' ORDER BY start_ts`, fromTs, toTs ).toArray(); }

// ✅ WebSocket: broadcast a todos los clientes conectados async fetch(request: Request): Promise<Response> { if (request.headers.get("Upgrade") === "websocket") { const [client, server] = Object.values(new WebSocketPair()); server.accept(); this.sessions.add(server); server.addEventListener("close", () => this.sessions.delete(server)); return new Response(null, { status: 101, webSocket: client }); } return new Response("Not Found", { status: 404 }); }

// ✅ Alarma: libera reservas 'pending' expiradas async alarm(): Promise<void> { const now = Date.now(); const expired = this.ctx.storage.sql.exec<{ id: number }>( `UPDATE reservations SET status='cancelled' WHERE status='pending' AND created_at < ? RETURNING id`, now - 5 * 60_000 ).toArray(); if (expired.length) { this.broadcast({ type: "expired", ids: expired.map(r => r.id) }); } }

private broadcast(msg: unknown): void { const payload = JSON.stringify(msg); for (const ws of this.sessions) { ws.send(payload); } } }

Reglas Críticas Aplicadas

7 reglas DO · 5 anti-patrones evitados
Patrones Correctos
  • 1 DO por sala — cada sala es un átomo de coordinación independiente
  • getByName(roomId) — routing determinista, misma sala siempre mismo DO
  • SQLite via new_sqlite_classes — storage transaccional co-ubicado
  • blockConcurrencyWhile solo en constructor — setup del schema, no en requests
  • RPC methods (book, confirm, getSlots) — no fetch() handler para lógica de negocio
  • Persist-first — INSERT antes de actualizar estado en memoria
  • Alarma TTL de 5 min — liberación automática de pending sin confirmar
Anti-Patrones Evitados
  • NO un DO global para todas las reservas (cuello de botella)
  • NO blockConcurrencyWhile en cada request (mata el throughput)
  • NO estado crítico solo en memoria (perdido en eviction)
  • NO await entre writes relacionados (rompe atomicidad SQLite)
  • NO I/O externo dentro de blockConcurrencyWhile

⏰ Flujo de Reserva con Alarma TTL — escritorio-12

T+0:00
book() llamado
status: pending
INSERT + setAlarm
T+0:01
broadcast WS
{type: "booked"}
→ 1 cliente notificado
T+1:18
Esperando confirmación
TTL: 3:42 restantes
status: pending
T+5:00
alarm() dispara
Si sigue pending:
status → cancelled
T+5:01
Sala liberada
{type: "expired"}
→ broadcast WS

Configuración Wrangler + Tests

jsonc wrangler.jsonc
// Cultiva Cowork — wrangler.jsonc { "name": "cultiva-cowork-reservas", "main": "src/worker.ts", "compatibility_date": "2024-04-03", "durable_objects": { "bindings": [{ "name": "BOOKING_ROOM", "class_name": "BookingRoom" }] }, "migrations": [{ "tag": "v1", // ✅ SQLite storage habilitado "new_sqlite_classes": ["BookingRoom"] }] }
ts BookingRoom.test.ts
import { env } from "cloudflare:test"; import { describe, it, expect } from "vitest";
describe("BookingRoom — reservas", () => { it("debe reservar un slot libre", async () => { const stub = env.BOOKING_ROOM .getByName("cowork-mad-sala-A1"); const res = await stub.book( "user-42", 1718870400000, 1718874000000 ); expect(res.ok).toBe(true); expect(res.id).toBe(1); }); it("debe rechazar doble reserva", async () => { const stub = env.BOOKING_ROOM .getByName("cowork-mad-sala-A1"); const r2 = await stub.book( "user-99", 1718870400000, 1718874000000 ); expect(r2.ok).toBe(false); expect(r2.error).toBe("Slot ocupado"); }); });