wandres.dev
MÁQUINAS EN EL BACKEND · procesos y workflows

Persistir el estado de la máquina

Una máquina de frontend vive lo que vive una pestaña; una máquina de negocio vive lo que vive el proceso, y un pedido puede tardar semanas en cerrarse. Esta lección convierte el actor efímero en un proceso de larga vida mediante la serialización del snapshot y su rehidratación posterior: qué se guarda exactamente, cómo se escribe junto a los efectos sin abrir grietas, qué partes del actor no sobreviven al viaje por la base de datos y cómo se versiona un modelo cuando hay miles de instancias vivas ejecutando la versión anterior.

⏱ 20 min

Toda la teoría de los niveles anteriores asume un actor que se crea, recibe eventos y se detiene dentro del mismo proceso del sistema operativo. En el backend esa asunción se rompe de inmediato: el evento que hace avanzar un pedido llega horas después del anterior, probablemente a otra instancia del servicio, quizá tras un despliegue que reinició todo. El actor no puede estar esperando en memoria porque entre un evento y el siguiente no hay memoria que valga. La respuesta no es abandonar el modelo, sino aceptar que el actor deja de ser un objeto vivo y pasa a ser un valor que se guarda, se lee y se reconstruye a demanda: la máquina se ejecuta en ráfagas de milisegundos separadas por días, y la base de datos hace de memoria entre ráfaga y ráfaga.

🎯 Al terminar esta lección sabrás
  • Serializar el estado de un actor con getPersistedSnapshot y entender qué contiene exactamente.
  • Rehidratar un actor a partir de un snapshot almacenado y continuar el proceso sin pérdida.
  • Identificar qué partes del actor no sobreviven a la serialización y cómo reponerlas.
  • Versionar el modelo con instancias vivas en producción sin romper procesos en curso.

El snapshot como valor, no como objeto

El puente entre el actor y la base de datos es un método que devuelve una estructura serializable con la posición del grafo, el contexto y el estado de los actores hijos. Ese valor es todo lo que hace falta para reconstruir el proceso, y es deliberadamente pobre: no contiene funciones, ni referencias, ni temporizadores.

import { createActor } from 'xstate'
import { pedido } from './pedido.machine'

export async function aplicar(pedidoId: string, evento: EventoPedido) {
  const fila = await db.pedidoProceso.findUnique({ where: { id: pedidoId } })
  const actor = createActor(pedido, {
    snapshot: fila ? pedido.resolveState(fila.snapshot) : undefined,
    input: { id: pedidoId },
  })
  actor.start()
  actor.send(evento)
  const siguiente = actor.getPersistedSnapshot()
  actor.stop()
  await db.pedidoProceso.update({
    where: { id: pedidoId, version: fila.version },
    data: { snapshot: siguiente, version: fila.version + 1 },
  })
}

El ciclo completo cabe en seis líneas: leer, reconstruir, enviar el evento, extraer el nuevo snapshot, detener y escribir. La condición version en la cláusula de actualización es la pieza que impide la pérdida silenciosa: si dos eventos llegan a la vez y ambos leyeron la misma versión, solo uno consigue escribir y el otro debe reintentar sobre el estado ya avanzado. Sin ese bloqueo optimista, la última escritura gana y una transición desaparece sin dejar rastro, que es la clase de fallo que solo se descubre semanas después contando pedidos que no cuadran.

ℹ️
El snapshot describe la posición, no la identidad

Conviene resistir la tentación de meter todo el pedido dentro del contexto solo porque el snapshot lo transporta gratis. El proceso necesita saber en qué punto está y qué decisiones ha tomado; los datos del pedido siguen viviendo en sus tablas, con sus índices y sus consultas. Un snapshot que engorda hasta contener el catálogo entero convierte cada transición en una reescritura de kilobytes y bloquea la única ventaja de tener el proceso separado: poder consultarlo sin arrastrar el dominio completo.

Escribir el estado y los efectos sin abrir grietas

Hay una consecuencia del ciclo anterior que conviene mirar de frente antes de seguir, porque cambia la forma de escribir las máquinas del backend: entre dos eventos, el actor no existe. No hay nadie esperando, nadie escuchando, nadie con un temporizador armado. Todo lo que en el navegador se resolvía con un actor de larga vida —una suscripción a otro actor, un intervalo, una espera activa— debe reformularse como algo que el mundo exterior provoca. La máquina pasa de ser un participante continuo a ser una función de la posición y el evento, invocada bajo demanda, y esa reformulación es más una disciplina de diseño que un detalle de implementación.

El orden entre persistir el snapshot y ejecutar los efectos externos decide la clase de fallo que sufrirá el sistema. Ejecutar primero el efecto y guardar después arriesga a repetirlo si el proceso muere en medio; guardar primero y ejecutar después arriesga a perderlo. Como ninguna base de datos transacciona con una pasarela de pago, la elección real no es entre corrección e incorrección sino entre dos modos de error, y el único diseño defendible es aplazar los efectos a una bandeja de salida escrita en la misma transacción que el snapshot.

sequenceDiagram
participant API
participant DB
participant Bandeja
participant Externo
API->>DB: leer snapshot y version
API->>API: rehidratar y enviar evento
API->>DB: escribir snapshot nuevo y efectos en una transaccion
DB-->>API: confirmado
Bandeja->>DB: leer efectos pendientes
Bandeja->>Externo: ejecutar efecto
Externo-->>Bandeja: resultado
Bandeja->>API: enviar evento de resultado

Conviene notar que este diseño cambia la naturaleza del actor invocado. En memoria, invoke arrancaba una promesa que resolvía en el mismo proceso; aquí el actor invocado se convierte en una fila de la bandeja que alguien recogerá después, y su resultado vuelve como un evento externo igual que cualquier otro. El grafo no cambia ni una línea —sigue teniendo su estado en curso, su transición de éxito y su transición de error—, pero la ejecución del efecto ha salido del proceso que decidió ordenarlo. Esa separación es la que permite que la misma definición de máquina funcione idéntica en una prueba unitaria, donde el efecto se resuelve al instante, y en producción, donde puede tardar horas.

Con esta disposición, la máquina nunca invoca al mundo: solo declara qué debe ocurrir, y esa declaración viaja en la misma transacción que el cambio de estado. Si el servicio cae justo después de confirmar, el estado y la intención sobreviven juntos y la bandeja reintentará el efecto; si cae antes, ninguno de los dos existe y el evento se reprocesa desde el principio. Lo que jamás puede pasar es que el pedido figure como pagado sin que exista la orden de cobrar, o al revés, porque ambos hechos comparten destino.

⚠️
Al menos una vez es la única garantía que vas a tener

La bandeja de salida garantiza que el efecto se ejecute al menos una vez, nunca exactamente una. Un reintento tras una caída puede llamar dos veces a la pasarela. Esto no es un defecto de la implementación sino una consecuencia de que no existe transacción distribuida entre tu base de datos y un servicio ajeno. La conclusión práctica se desarrolla en la lección de idempotencia, pero conviene interiorizarla ya: el diseño debe tolerar la repetición, no intentar evitarla.

Lo que no viaja en el snapshot

La serialización es una frontera y todo lo que no sea dato se queda al otro lado. Saber qué se pierde evita descubrirlo mediante un proceso que se queda colgado para siempre porque esperaba un temporizador que nadie volvió a armar.

Elemento Sobrevive Qué hay que hacer al rehidratar
Posición en el grafo nada, se restaura íntegra
Contexto serializable nada, viaja como JSON
Actores hijos persistentes sí, su snapshot anidado se reconstruyen con la máquina padre
Implementaciones de acciones y actores no vienen del código actual, no del snapshot
Temporizadores de transiciones diferidas no reprogramar como trabajos externos con fecha
Promesas en vuelo de un actor invocado no reintentar el efecto desde la bandeja
Funciones, clases y referencias del contexto no rediseñar el contexto para que sean datos
📝
La implementación viene del código, y eso es una ventaja

Que las acciones y los actores no se serialicen suele leerse como una limitación y es justo lo contrario. Significa que un proceso guardado hace seis meses ejecuta hoy la implementación corregida de un actor, sin migrar nada: el snapshot aporta el dónde y el código aporta el cómo. La contrapartida es que renombrar una implementación referenciada por nombre rompe procesos vivos de forma silenciosa, porque el snapshot sigue apuntando a una clave que ya no existe. Los nombres de acciones y actores, en un sistema persistido, son interfaz pública con las mismas obligaciones de compatibilidad que un endpoint.

Las dos filas críticas son las dos últimas del bloque de efectos. Un after que en el navegador se traduce en un temporizador de milisegundos no puede significar lo mismo cuando el proceso duerme dos semanas en una tabla: allí el plazo debe convertirse en una fila con fecha de vencimiento que un planificador externo consulta y transforma en evento. Y una promesa que estaba a mitad de camino cuando el proceso se apagó no se reanuda: para el snapshot ese actor sigue en curso, y solo la bandeja de salida, al reintentar, producirá el evento de resultado que lo cierre.

💡
Trata el contexto como si fuera una columna, porque lo es

La disciplina más rentable al persistir máquinas es diseñar el contexto pensando que va a pasar por JSON: nada de instancias de clase, nada de fechas que dependan del formato, nada de mapas ni conjuntos que se aplanen a objetos vacíos sin avisar. Guardar identificadores, cadenas, números y estructuras planas cuesta un poco más al escribir el modelo y ahorra la clase de incidente en el que un proceso rehidratado se comporta distinto porque una fecha volvió como texto.

Versionar un modelo con procesos vivos

El problema definitivo de la larga vida no es guardar, es cambiar. En el momento en que se despliega una versión nueva de la máquina hay miles de snapshots escritos por la versión anterior, y algunos permanecerán en estados que la versión nueva ha renombrado o eliminado. Las estrategias son tres y no se excluyen: mantener el estado viejo como alias que transita de inmediato al nuevo, migrar los snapshots en lote antes de retirar el código antiguo, o versionar el modelo entero y despachar cada instancia a la definición con la que nació.

🔀

Alias transitorio

El estado retirado se conserva en el modelo nuevo con una transición inmediata al que lo sustituye. Barato y suficiente para renombrados, inútil para cambios de semántica.

🚚

Migración en lote

Un proceso recorre los snapshots antiguos y los reescribe con la forma nueva. Requiere ventana de parada o doble lectura, y una copia de seguridad que nadie quiere usar y todos agradecen.

🏷️

Modelo versionado

Cada instancia guarda con qué versión nació y se despacha a esa definición. Es lo correcto cuando las reglas tienen consecuencias contractuales.

🧊

Congelar y drenar

La versión antigua deja de admitir instancias nuevas y se mantiene hasta que la última termina. Exige medir cuántas quedan y poner fecha al final.

const versiones = { 1: pedidoV1, 2: pedidoV2 } as const

function definicionDe(fila: { snapshot: unknown; modelo: 1 | 2 }) {
  return versiones[fila.modelo]
}

Elegir entre ellas depende de una única pregunta previa: si el cambio es de forma o de fondo. Renombrar un estado, dividir uno en dos que representan lo mismo con más detalle o reordenar campos del contexto son cambios de forma, y el alias o la migración en lote bastan. Cambiar qué transiciones son legales, alterar el resultado económico de una fase o añadir una obligación nueva son cambios de fondo, y aplicarlos a instancias en curso equivale a modificar el contrato después de firmarlo.

La tercera opción es la más honesta para procesos largos con implicaciones contractuales, porque respeta un principio que el negocio da por supuesto: un pedido debe terminar bajo las reglas vigentes cuando se abrió, no bajo las que se desplegaron el martes. Su coste es mantener definiciones antiguas vivas hasta que se agote la última instancia, y ese coste se controla con una única disciplina: registrar cuántas instancias quedan por versión y tener un plan explícito para retirar cada una.

Persistir el estado es aceptar que el programa vive más que el proceso que lo ejecuta

Hay un supuesto tan interiorizado en la práctica de la programación que casi nunca se enuncia: que la vida de un programa coincide con la vida de su proceso. Todo nuestro vocabulario lo asume —variables, pila, memoria, cierre— y funciona mientras el trabajo dure lo que dura una petición. Los procesos de negocio quiebran ese supuesto sin pedir permiso, porque una suscripción dura años y ninguna instancia de servidor dura años. Cuando persistes el snapshot no estás añadiendo una funcionalidad al actor: estás cambiando de qué está hecha su continuidad. La identidad del proceso deja de residir en un puntero y pasa a residir en una fila; la memoria deja de ser un espacio de direcciones y pasa a ser un registro consultable; y la ejecución deja de ser un hilo continuo para convertirse en una sucesión de despertares breves separados por silencios de días. Ese cambio tiene una consecuencia que va más allá de lo técnico y toca al diseño del dominio: cuando el estado del proceso es un dato duradero, se vuelve auditable, migrable, reparable y consultable por gente que no ejecuta código. Un operador puede preguntar cuántos procesos llevan más de tres días en un estado concreto y obtener una respuesta con una consulta, algo impensable cuando el proceso vivía en la pila de un servidor. Al pagar el precio de serializar has comprado, casi sin notarlo, la propiedad más valiosa de un sistema de negocio: que su trabajo en curso sea visible. La máquina en memoria era una herramienta de correción; la máquina persistida es una herramienta de gobierno.

⚔️ Convierte tu máquina en un proceso duradero
  1. Añade a tu proceso una tabla con identificador, snapshot en formato JSON, versión del modelo y número de revisión para bloqueo optimista.
  2. Escribe la función de aplicación completa: leer, rehidratar, enviar, extraer snapshot y escribir con la condición de revisión.
  3. Provoca dos eventos concurrentes sobre el mismo proceso y verifica que uno falla y reintenta en lugar de sobrescribir.
  4. Recorre tu contexto campo a campo y elimina o transforma todo lo que no sobreviva a una ida y vuelta por JSON.
  5. Sustituye cada transición diferida por una fila con fecha de vencimiento y un planificador que la convierta en evento.
  6. Renombra un estado en el modelo y diseña la estrategia de compatibilidad para los snapshots que ya lo contienen.