Salta al contenuto principale

04 - Durable Objects: Stato Fortemente Consistente e WebSocket sull'Edge

Edge Computing 16 min Durable Objects: Stato Fortemente Consistente e WebSocket sull'Edge I Durable Objects sono la primitiva più potente dell'ecosistema...

Cosa ne pensi di questo articolo?

04 - Durable Objects: Stato Fortemente Consistente e WebSocket sull'Edge
Edge ComputingFoto:SiddePexels

In Breve

Edge Computing 16 min Durable Objects: Stato Fortemente Consistente e WebSocket sull'Edge I Durable Objects sono la primitiva più potente dell'ecosistema...

Edge Computing 16 min

Durable Objects: Stato Fortemente Consistente e WebSocket sull'Edge

I Durable Objects sono la primitiva più potente dell'ecosistema Cloudflare: permettono stato fortemente consistente, WebSocket con session management e coordinazione distribuita senza server centralizzati, tutto all'edge globale.

Durable Objects Edge State WebSocket Strong Consistency Cloudflare

Il Problema dello Stato nell'Edge Computing

Gli articoli precedenti di questa serie hanno mostrato come Cloudflare Workers eccella per workload stateless: ogni richiesta viene gestita in isolamento, senza memoria condivisa tra invocazioni. Questa caratteristica è un punto di forza per la scalabilità orizzontale, ma diventa un ostacolo non appena l'applicazione richiede coordinazione.

Considera questi scenari comuni: una stanza di chat dove i messaggi devono essere ordinati e consegnati a tutti i partecipanti; un documento collaborativo dove più utenti editano contemporaneamente; un rate limiter per API che deve contare richieste in modo globalmente coerente. In tutti questi casi il modello stateless dei Workers non basta: serve uno stato fortemente consistente e la capacità di coordinare richieste concorrenti in un unico punto.

È esattamente il problema che i Durable Objects risolvono.

Cosa Imparerai

  • Cos'è un Durable Object e come differisce da Workers KV
  • Il modello di consistenza forte: single-writer, global coordination
  • Come implementare WebSocket con session management usando Durable Objects
  • Storage transazionale: read, write e transazioni atomiche
  • Pattern per chat room, rate limiting e document collaboration
  • Alarms: operazioni schedulate all'interno del Durable Object
  • Limiti, pricing e quando scegliere DO vs KV vs D1

Cos'è un Durable Object

Un Durable Object (DO) è una classe JavaScript/TypeScript che Cloudflare istanzia in un'unica location geografica per un dato identificatore. A differenza di Workers KV (eventual consistency su 300+ PoP), un DO ha queste garanzie:

  • Single-writer consistency: una sola istanza del DO esiste in tutto il mondo per un dato ID
  • Serializzazione delle richieste: le chiamate al DO vengono eseguite sequenzialmente, mai in concorrenza
  • Storage durabile: un key-value store transazionale privato per ogni istanza
  • WebSocket hibernation: le connessioni WebSocket sopravvivono agli idle periods senza costo

La location di un DO viene determinata automaticamente al momento della prima attivazione e rimane fissa. Cloudflare sceglie il datacenter più vicino alla prima richiesta. Tutte le richieste successive, ovunque nel mondo, vengono instradate a quella singola istanza tramite il routing anycast di Cloudflare.

Primitiva Consistenza Concorrenza Latenza lettura Caso d'uso
Workers KV Eventual (minuti) Multi-writer ~1ms (cached) Config, asset, sessioni read-heavy
Durable Objects Forte (linearizable) Single-writer ~50-150ms (da edge remoto) Chat, rate limiter, game state, CRDT
D1 SQLite Forte (primary) Multi-reader, single-writer ~5-20ms (da PoP vicino) Query relazionali, report, OLTP
R2 Object Storage Forte (per oggetto) Multi-writer (conflict detection) ~50-200ms File, immagini, backup

Struttura di un Durable Object

Un Durable Object è una classe con metodi specifici che il runtime Cloudflare riconosce. Il più importante è fetch(), chiamato per ogni richiesta HTTP inoltrata al DO. Vediamo la struttura di base:

// src/counter-do.ts
// Un Durable Object semplice: un contatore con persistenza

export class CounterDO implements DurableObject {
  private state: DurableObjectState;
  private env: Env;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    this.env = env;
    // state.storage e il key-value store privato di questa istanza
    // Persiste attraverso i riavvii del DO
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    switch (url.pathname) {
      case '/increment': {
        // Legge il valore corrente (undefined se non esiste)
        const current = (await this.state.storage.get<number>('count')) ?? 0;
        const next = current + 1;
        // Scrittura atomica: garantita durabile prima del return
        await this.state.storage.put('count', next);
        return Response.json({ count: next });
      }

      case '/decrement': {
        const current = (await this.state.storage.get<number>('count')) ?? 0;
        const next = Math.max(0, current - 1);
        await this.state.storage.put('count', next);
        return Response.json({ count: next });
      }

      case '/value': {
        const count = (await this.state.storage.get<number>('count')) ?? 0;
        return Response.json({ count });
      }

      case '/reset': {
        await this.state.storage.put('count', 0);
        return Response.json({ count: 0, reset: true });
      }

      default:
        return new Response('Not Found', { status: 404 });
    }
  }
}

interface Env {
  COUNTER: DurableObjectNamespace;
}

Per usare il DO dal Worker entry point, devi istanziarlo tramite il namespace binding e un ID:

// src/worker.ts - entry point del Worker
export { CounterDO } from './counter-do';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Estrae l'ID dalla query string: /counter?id=room-1
    const counterId = url.searchParams.get('id') ?? 'global';

    // idFromName() crea un ID deterministico da una stringa
    // Lo stesso nome produce sempre lo stesso ID (e la stessa istanza)
    const id = env.COUNTER.idFromName(counterId);

    // Ottiene lo stub per comunicare con l'istanza
    const stub = env.COUNTER.get(id);

    // Invia la richiesta al Durable Object
    // La richiesta viene instradata al datacenter corretto automaticamente
    return stub.fetch(request);
  },
};

interface Env {
  COUNTER: DurableObjectNamespace;
}

La configurazione wrangler.toml deve dichiarare il binding:

# wrangler.toml
name = "counter-worker"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

[[durable_objects.bindings]]
name = "COUNTER"
class_name = "CounterDO"

[[migrations]]
tag = "v1"
new_classes = ["CounterDO"]

WebSocket con Durable Objects: Chat Room

Il caso d'uso più potente dei Durable Objects è la gestione di sessioni WebSocket con stato condiviso. Ogni stanza di chat è un'istanza separata del DO, che mantiene la lista di connessioni attive e la cronologia dei messaggi.

Cloudflare supporta la WebSocket Hibernation API: il DO viene "ibernato" quando non ci sono messaggi da processare, riducendo drasticamente i costi (si paga solo per il tempo di elaborazione, non per le connessioni aperte).

// src/chat-room-do.ts
// Durable Object per una stanza di chat con WebSocket Hibernation

export class ChatRoomDO implements DurableObject {
  private state: DurableObjectState;
  private env: Env;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    this.env = env;
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === '/ws') {
      // Verifica che sia una richiesta di upgrade WebSocket
      const upgradeHeader = request.headers.get('Upgrade');
      if (!upgradeHeader || upgradeHeader !== 'websocket') {
        return new Response('Expected WebSocket upgrade', { status: 426 });
      }

      // Crea la coppia WebSocket server/client
      const { 0: clientWs, 1: serverWs } = new WebSocketPair();

      // Accetta la connessione tramite la Hibernation API
      // Il DO sara ibernato tra i messaggi (no costo di CPU idle)
      this.state.acceptWebSocket(serverWs);

      // Opzionale: associa metadata alla connessione
      // Utile per identificare l'utente nei messaggi successivi
      const userId = url.searchParams.get('userId') ?? `anon-${Date.now()}`;
      serverWs.serializeAttachment({ userId });

      // Invia la storia recente al nuovo utente
      const history = (await this.state.storage.get<Message[]>('history')) ?? [];
      if (history.length > 0) {
        serverWs.send(JSON.stringify({ type: 'history', messages: history }));
      }

      return new Response(null, {
        status: 101,
        webSocket: clientWs,
      });
    }

    if (url.pathname === '/messages' && request.method === 'GET') {
      const history = (await this.state.storage.get<Message[]>('history')) ?? [];
      return Response.json({ messages: history });
    }

    return new Response('Not Found', { status: 404 });
  }

  // Chiamato dalla Hibernation API quando arriva un messaggio WebSocket
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    const { userId } = ws.deserializeAttachment() as { userId: string };

    let parsed: ClientMessage;
    try {
      parsed = JSON.parse(message as string);
    } catch {
      ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' }));
      return;
    }

    if (parsed.type === 'chat') {
      const msg: Message = {
        id: crypto.randomUUID(),
        userId,
        text: parsed.text,
        timestamp: Date.now(),
      };

      // Salva nella history (mantieni solo gli ultimi 100 messaggi)
      const history = (await this.state.storage.get<Message[]>('history')) ?? [];
      const newHistory = [...history, msg].slice(-100);
      await this.state.storage.put('history', newHistory);

      // Broadcast a tutte le connessioni WebSocket attive nel DO
      const allWebSockets = this.state.getWebSockets();
      const payload = JSON.stringify({ type: 'message', message: msg });
      for (const socket of allWebSockets) {
        try {
          socket.send(payload);
        } catch {
          // Connessione chiusa, ignorala
        }
      }
    }
  }

  // Chiamato quando una connessione WebSocket viene chiusa
  async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
    const { userId } = ws.deserializeAttachment() as { userId: string };
    ws.close(code, reason);

    // Notifica gli altri utenti dell'uscita
    const notification = JSON.stringify({
      type: 'user_left',
      userId,
      timestamp: Date.now(),
    });
    for (const socket of this.state.getWebSockets()) {
      try {
        socket.send(notification);
      } catch { /* ignore */ }
    }
  }

  // Chiamato in caso di errore sulla connessione WebSocket
  async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
    console.error('WebSocket error:', error);
    ws.close(1011, 'Internal error');
  }
}

interface Message {
  id: string;
  userId: string;
  text: string;
  timestamp: number;
}

interface ClientMessage {
  type: 'chat' | 'ping';
  text?: string;
}

interface Env {
  CHAT_ROOM: DurableObjectNamespace;
}

Il Worker entry point instrada le richieste alla stanza corretta in base al path:

// src/worker.ts
export { ChatRoomDO } from './chat-room-do';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // /room/:roomId/ws -> WebSocket per la stanza
    // /room/:roomId/messages -> GET cronologia
    const match = url.pathname.match(/^\/room\/([^/]+)(\/.*)?$/);
    if (!match) {
      return new Response('Not Found', { status: 404 });
    }

    const roomId = match[1];
    const subpath = match[2] ?? '/ws';

    // Ogni stanza e una istanza distinta del DO
    const id = env.CHAT_ROOM.idFromName(roomId);
    const stub = env.CHAT_ROOM.get(id);

    // Rewrite del path per il DO
    const doUrl = new URL(request.url);
    doUrl.pathname = subpath;

    return stub.fetch(new Request(doUrl.toString(), request));
  },
};

interface Env {
  CHAT_ROOM: DurableObjectNamespace;
}

Storage Transazionale: Operazioni Atomiche

Lo storage dei Durable Objects supporta transazioni atomiche tramite state.storage.transaction(). Questo è fondamentale quando un'operazione deve leggere e scrivere più chiavi in modo coerente:

// Esempio: trasferimento di crediti tra utenti (atomico)
export class AccountDO implements DurableObject {
  state: DurableObjectState;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

  async fetch(request: Request): Promise<Response> {
    if (request.method !== 'POST') {
      return new Response('Method Not Allowed', { status: 405 });
    }

    const { from, to, amount } = await request.json<TransferRequest>();

    try {
      // La transazione e atomica: o tutto va a buon fine, o niente
      await this.state.storage.transaction(async (txn) => {
        const fromBalance = (await txn.get<number>(`balance:${from}`)) ?? 0;
        const toBalance = (await txn.get<number>(`balance:${to}`)) ?? 0;

        if (fromBalance < amount) {
          // Il throw annulla la transazione
          throw new Error(`Insufficient balance: ${fromBalance} < ${amount}`);
        }

        await txn.put(`balance:${from}`, fromBalance - amount);
        await txn.put(`balance:${to}`, toBalance + amount);

        // Log dell'operazione
        const txLog = (await txn.get<TxRecord[]>('tx_log')) ?? [];
        txLog.push({ from, to, amount, timestamp: Date.now() });
        await txn.put('tx_log', txLog.slice(-1000));
      });

      return Response.json({ success: true, from, to, amount });
    } catch (err) {
      return Response.json(
        { success: false, error: (err as Error).message },
        { status: 400 }
      );
    }
  }
}

interface TransferRequest {
  from: string;
  to: string;
  amount: number;
}

interface TxRecord {
  from: string;
  to: string;
  amount: number;
  timestamp: number;
}

interface Env {
  ACCOUNT: DurableObjectNamespace;
}

Alarms: Operazioni Schedulate nel Durable Object

I Durable Objects supportano gli Alarms: una callback alarm() che viene invocata a un'ora schedulata, anche se non ci sono richieste attive al DO. Questo è utile per TTL, scadenze e job periodici legati allo stato del DO:

// src/session-do.ts
// DO con alarm per scadenza automatica della sessione

export class SessionDO implements DurableObject {
  state: DurableObjectState;
  static SESSION_TTL_MS = 30 * 60 * 1000; // 30 minuti

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === '/create' && request.method === 'POST') {
      const data = await request.json<SessionData>();

      // Salva i dati della sessione
      await this.state.storage.put('session', {
        ...data,
        createdAt: Date.now(),
      });

      // Schedula l'alarm per la scadenza della sessione
      // Se l'alarm e gia schedulato, viene sostituito
      await this.state.storage.setAlarm(Date.now() + SessionDO.SESSION_TTL_MS);

      return Response.json({ ok: true, expiresIn: SessionDO.SESSION_TTL_MS });
    }

    if (url.pathname === '/get') {
      const session = await this.state.storage.get<SessionData>('session');
      if (!session) {
        return Response.json({ error: 'Session not found' }, { status: 404 });
      }

      // Refresh del TTL ad ogni accesso (sliding expiry)
      await this.state.storage.setAlarm(Date.now() + SessionDO.SESSION_TTL_MS);

      return Response.json({ session });
    }

    if (url.pathname === '/invalidate' && request.method === 'DELETE') {
      await this.state.storage.deleteAll();
      await this.state.storage.deleteAlarm();
      return Response.json({ ok: true });
    }

    return new Response('Not Found', { status: 404 });
  }

  // Chiamato automaticamente quando scatta l'alarm
  async alarm(): Promise<void> {
    // Pulisce i dati della sessione scaduta
    const session = await this.state.storage.get<SessionData>('session');
    if (session) {
      console.log(`Session expired for user: ${session.userId}`);
      await this.state.storage.deleteAll();
    }
  }
}

interface SessionData {
  userId: string;
  role: string;
  metadata?: Record<string, unknown>;
}

interface Env {
  SESSION: DurableObjectNamespace;
}

Rate Limiter Globale con Durable Objects

Un rate limiter distribuito è uno dei pattern più richiesti per API pubbliche. Con Workers KV l'implementazione sarebbe soggetta a race condition. Con un DO, ogni "bucket" di rate limiting è un'istanza con consistenza forte:

// src/rate-limiter-do.ts
// Token bucket rate limiter con Durable Objects

export class RateLimiterDO implements DurableObject {
  state: DurableObjectState;

  // Configurazione: 100 req/minuto per IP
  static MAX_TOKENS = 100;
  static REFILL_RATE_MS = 60_000; // 1 minuto per refill completo

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

  async fetch(request: Request): Promise<Response> {
    const now = Date.now();

    // Legge lo stato attuale del bucket
    const bucket = (await this.state.storage.get<TokenBucket>('bucket')) ?? {
      tokens: RateLimiterDO.MAX_TOKENS,
      lastRefill: now,
    };

    // Calcola quanti token sono stati aggiunti dall'ultimo accesso
    const elapsed = now - bucket.lastRefill;
    const tokensToAdd = (elapsed / RateLimiterDO.REFILL_RATE_MS) * RateLimiterDO.MAX_TOKENS;
    const currentTokens = Math.min(
      RateLimiterDO.MAX_TOKENS,
      bucket.tokens + tokensToAdd
    );

    if (currentTokens < 1) {
      // Rate limit superato
      const retryAfterMs = Math.ceil(
        (1 - currentTokens) / (RateLimiterDO.MAX_TOKENS / RateLimiterDO.REFILL_RATE_MS)
      );

      await this.state.storage.put('bucket', {
        tokens: currentTokens,
        lastRefill: now,
      });

      return Response.json(
        {
          allowed: false,
          retryAfter: Math.ceil(retryAfterMs / 1000),
          remaining: 0,
        },
        {
          status: 429,
          headers: {
            'Retry-After': String(Math.ceil(retryAfterMs / 1000)),
            'X-RateLimit-Limit': String(RateLimiterDO.MAX_TOKENS),
            'X-RateLimit-Remaining': '0',
          },
        }
      );
    }

    // Consuma un token e aggiorna il bucket
    await this.state.storage.put('bucket', {
      tokens: currentTokens - 1,
      lastRefill: now,
    });

    return Response.json({
      allowed: true,
      remaining: Math.floor(currentTokens - 1),
    });
  }
}

interface TokenBucket {
  tokens: number;
  lastRefill: number;
}

interface Env {
  RATE_LIMITER: DurableObjectNamespace;
}

Il Worker integra il rate limiter nel flow di ogni richiesta:

// src/worker.ts con rate limiting
export { RateLimiterDO } from './rate-limiter-do';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Identifica il client (IP o API key)
    const clientIp = request.headers.get('CF-Connecting-IP') ?? 'unknown';
    const apiKey = request.headers.get('X-API-Key');
    const bucketId = apiKey ?? `ip:${clientIp}`;

    // Controlla il rate limit per questo client
    const rateLimiterId = env.RATE_LIMITER.idFromName(bucketId);
    const rateLimiter = env.RATE_LIMITER.get(rateLimiterId);
    const limitCheck = await rateLimiter.fetch(new Request('https://dummy/check'));

    if (limitCheck.status === 429) {
      return limitCheck; // Propaga la risposta 429 con gli headers
    }

    // Prosegue con la logica dell'API
    return handleApiRequest(request, env);
  },
};

async function handleApiRequest(request: Request, env: Env): Promise<Response> {
  return Response.json({ data: 'your api response here' });
}

interface Env {
  RATE_LIMITER: DurableObjectNamespace;
}

Considerazioni di Performance e Costi

I Durable Objects hanno un profilo di costi e performance molto diverso rispetto ai semplici Workers. Alcuni punti chiave da tenere a mente:

Latenza: Non Sempre Locale

Ogni istanza DO risiede in un unico datacenter. Se un utente in Europa accede a un DO instanziato in Nord America, la latenza di ogni operazione include il round-trip transatlantico (~100-150ms). Progetta gli ID dei DO per limitare la condivisione geografica: usa ID basati su region quando possibile (idFromName(`${region}:${resourceId}`)), oppure accetta la latenza elevata solo per operazioni che richiedono consistenza forte cross-regionale.

Voce Free tier Paid (Workers Paid)
Richieste al DO 1M/mese incluse $0.15 per milione oltre
Durata DO (CPU) 400.000 GB-s/mese $12.50 per milione GB-s
Storage 1GB incluso $0.20/GB-mese oltre
WebSocket Hibernation Disponibile Disponibile (no costo idle)
Alarms Disponibili Disponibili

Best Practices

  • Granularità degli ID: usa ID specifici (es. chat:room-42) piuttosto che ID globali per evitare hotspot su singole istanze.
  • WebSocket Hibernation sempre: usa state.acceptWebSocket() invece di event listener manuali per ridurre i costi delle connessioni idle.
  • Storage batch: usa storage.put(map) per scrivere più chiavi in un'unica operazione invece di multiple put() singole.
  • Limita la dimensione storage: ogni istanza ha un limite di 128MB di storage. Per dati grandi usa R2 e salva solo riferimenti nel DO.
  • Serialization budget: le richieste vengono accodate e processate sequenzialmente; operazioni lente bloccano quelle successive. Mantieni i handler veloci (<1s ideale).

Conclusioni e Prossimi Passi

I Durable Objects colmano il divario tra il modello stateless dei Workers e le applicazioni moderne che richiedono coordinazione e stato condiviso. La loro combinazione con la WebSocket Hibernation API e gli Alarms li rende una primitiva completa per chat, gaming, document collaboration e sistemi di coordinazione globale.

Il compromesso è la latenza: un DO è fisicamente in un solo datacenter, quindi le operazioni di scrittura cross-regionale aggiungono latenza. Per letture scalabili considera di combinare DO (per le scritture) con KV (per le letture cached).

Prossimi Articoli della Serie

  • Articolo 5: Workers AI - Inferenza di LLM e Modelli Vision sull'Edge: come eseguire Llama, Whisper e modelli vision direttamente nei Workers senza GPU dedicata, con la crescita del 4000% YoY di Workers AI.
  • Articolo 6: Vercel Edge Runtime - Middleware Avanzato, Geolocalizzazione e A/B Testing: l'approccio di Vercel all'edge con Next.js.
  • Articolo 7: Routing Geografico all'Edge - Personalizzazione Contenuti e Compliance GDPR: geo-fencing, prezzi localizzati e GDPR.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti