Cloudflare
Durable Objects
Programação
Estado
Concorrência

El modelo de programación de Durable Objects: en qué se diferencia de cualquier cosa que hayas usado

En qué se diferencia el modelo de programación de Durable Objects de cualquier modelo sin servidor convencional, con las API de almacenamiento, transacciones y alarmas que cambian lo que es posible.

El modelo de programación de Durable Objects: en qué se diferencia de cualquier cosa que hayas usado

La mayoría de los ingenieros que encuentran objetos duraderos por primera vez los leen como "Trabajadores con [base de datos integrada]" y comienzan a escribir código en esa dirección. El resultado son aplicaciones que funcionan en pruebas y fallan de manera sutil en producción. El modelo mental es erróneo porque la diferencia entre un DO y un trabajador con acceso a un banco no es una cuestión de conveniencia: es dónde vive el Estado y quién controla el acceso a él.

La estructura de una clase de objeto duradero

Un DO es una clase de JavaScript con tres elementos principales: el constructor, el método fetch() y el acceso this.ctx.storage. El constructor se ejecuta una vez cuando se crea el DO o se despierta de la hibernación. fetch() procesa cada solicitud recibida. Y this.ctx.storage es la API de almacenamiento duradero que persiste en el estado entre solicitudes y entre hibernaciones.

export class Counter implements DurableObject { private count: number = 0; constructor(private ctx: DurableObjectState, private env: Env) { this.ctx.blockConcurrencyWhile(async () => { this.count = (await this.ctx.storage.get<number>('count')) ?? 0; }); } async fetch(request: Request): Promise<Response> { this.count++; await this.ctx.storage.put('count', this.count); return new Response(String(this.count)); } }

El blockConcurrencyWhile() en el constructor es necesario cuando necesita hidratar el estado de almacenamiento antes de procesar cualquier solicitud. Sin él, una solicitud que llegara antes de que se resolviera el almacenamiento await tendría un estado incompleto. blockConcurrencyWhile pone en cola las solicitudes hasta que se resuelve la devolución de llamada; esta es la forma de inicializar el DO de forma segura.

¿Qué cambia cuando el estado vive en la memoria?

La operación "leer del banco, calcular, volver a escribir" es el patrón más común en las aplicaciones del lado del servidor y también la fuente de las condiciones de carrera más comunes. Entre lectura y escritura, es posible que otro proceso haya modificado el mismo registro. Para resolver esto con un banco convencional, se utilizan transacciones con bloqueos, SELECCIONAR PARA ACTUALIZAR o alguna forma de versionado optimista.

En una DO este problema no se da de la misma forma. El estado vive en la memoria: this.count ya tiene el valor actual. No hay ida y vuelta para leer. El incremento y la persistencia ocurren dentro de la misma ejecución, sin que pueda intercalarse otra solicitud. La garantía no es del banco: es de la ejecución serial de la propia DO.

Esto tiene un costo que debe ser explícito: la coherencia depende de que usted persista en la memoria del estado que modificó. Si el DO hiberna antes de que se llame a un put(), la modificación de la memoria se pierde. Cada cambio importante debe persistir en el almacenamiento antes de que finalice la solicitud. El patrón correcto es modificar y persistir en la misma operación, sin depender de una descarga posterior.

La API de almacenamiento y cuándo usar la transacción()

La API de almacenamiento tiene operaciones directas: get(), put(), delete(), list(). Todos son individualmente atómicos y duraderos: se garantiza que lo escrito estará en el disco incluso si el DO hiberna inmediatamente después. La linealización se aplica a todo el almacenamiento de una instancia: cualquier lectura posterior a put() verá el valor escrito, sin excepción.

Cuando una operación lógica modifica varias claves y todas deben ser coherentes entre sí, transaction() es la herramienta correcta:

await this.ctx.storage.transaction(async (txn) => { const balance = await txn.get<number>('balance') ?? 0; await txn.put('balance', balance - amount); await txn.put('last_debit', Date.now()); await txn.put('debit_count', (await txn.get<number>('debit_count') ?? 0) + 1); });

Si la devolución de llamada transaction() genera una excepción, ninguna de las escrituras persiste. El estado de almacenamiento vuelve al que era antes de que comenzara la transacción. No hay compromiso parcial. Esto reemplaza la necesidad de compensar transacciones o lógica de reversión en la aplicación para operaciones que afectan múltiples claves.

Un detalle operativo de list(): por defecto devuelve un máximo de 128 entradas. Para conjuntos más grandes, utilice el cursor devuelto en la respuesta a la página. Ignorar esto es descubrir el límite en la producción con datos reales.

La API de alarma: setTimeout que sobrevive a la hibernación

Los trabajadores tienen setTimeout(), pero el temporizador no sobrevive a la hibernación aislada. Cuando el trabajador termina de procesar la solicitud, los temporizadores pendientes desaparecen. Para un DO que necesita hacer algo en el futuro (purgar sesiones caducadas, reenviar un mensaje fallido, invalidar un bloqueo después de un tiempo de espera), la API de alarma es el mecanismo correcto:

async fetch(request: Request): Promise<Response> { const body = await request.json() as { lockKey: string; ttlMs: number }; await this.ctx.storage.put(`lock:${body.lockKey}`, true); await this.ctx.storage.setAlarm(Date.now() + body.ttlMs); return new Response('lock acquired'); } async alarm(): Promise<void> { const keys = await this.ctx.storage.list({ prefix: 'lock:' }); for (const key of keys.keys()) { await this.ctx.storage.delete(key); } }

setAlarm() recibe una marca de tiempo absoluta en milisegundos. Cuando DO duerme, Cloudflare mantiene la alarma programada. En el momento adecuado, el DO se despierta y se invoca el método alarm(). El costo es de $0,15/millón de invocaciones, la misma lista de precios que las solicitudes normales.

Sólo es posible tener una alarma activa por DO a la vez. setAlarm() reemplaza al anterior. Si necesita varios temporizadores, la estrategia es almacenar la lista de próximos eventos en el almacenamiento, programar una alarma para el más cercano y, cuando se ejecute alarm(), procesar los eventos caducados y reprogramarlos para el siguiente.

Cómo funciona el enrutamiento a la instancia correcta

Los objetos duraderos tienen tres estrategias de generación de ID y la elección determina el comportamiento de enrutamiento.

idFromName("room-123") es determinista: el mismo nombre siempre produce la misma identificación, globalmente. Es la estrategia para cuando quieres que todos los clientes que soliciten "habitación-123" lleguen al mismo DO. La ID se obtiene aplicando hash al nombre, y dos DO con el mismo nombre en el mismo espacio de nombres son el mismo objeto.

newUniqueId() genera una ID aleatoria, siempre una nueva DO. Úselo cuando cree una entidad y almacenará la identificación para referencia futura sin necesidad de derivarla de una clave.

idFromString(hexStr) reconstruye una ID a partir de una cadena hexadecimal que almacenó previamente. Útil cuando la ID se generó con newUniqueId(), persistió en una base de datos externa o en el KV y necesita hacer referencia al mismo DO más adelante.

Las sugerencias de ubicación le permiten sugerir una jurisdicción geográfica (UE, EE. UU.), pero no garantizan un PoP específico. El DO se crea en el PoP más cercano a la primera solicitud que lo instancia. Para aplicaciones con requisitos de residencia de datos de la UE, locationHint: 'eu' dirige la creación a un PoP europeo, pero Cloudflare elige cuál.

¿Cómo se conecta esto con el trabajador que enruta?

El trabajador que recibe la solicitud del cliente debe obtener el talón DO y reenviar la solicitud:

export default { async fetch(request: Request, env: Env): Promise<Response> { const roomId = new URL(request.url).searchParams.get('room') ?? 'default'; const id = env.ROOMS.idFromName(roomId); const stub = env.ROOMS.get(id); return stub.fetch(request); } };

El talón tiene el mismo contrato fetch() que un Trabajador normal. El trabajador que enruta no tiene acceso al almacenamiento DO, no puede leer su estado interno y no puede llamar a métodos distintos de fetch() y RPC (cuando está configurado con WorkerEntrypoint). La comunicación se realiza a través de solicitudes HTTP normales.

Los objetos duraderos no son una abstracción de base de datos. Son un modelo informático con estado. Tratarlos como una base de datos que también procesa lógica, en lugar de un objeto con identidad y estado, es lo que genera confusión y código que funciona en producción por accidente, no por diseño.

Lea también