Durable Delays: gestionar el tiempo como estado distribuido
SchemaBridge Team · 2026-01-10 · Time, Scheduling, Orchestration
Esperas de 30 días sin fugas de memoria. Por qué `sleep()` es un antipatrón en sistemas distribuidos.
La mentira de sleep(): por qué el tiempo es la primitiva distribuida más difícil
Todo lenguaje de programación tiene una instrucción para esperar. En Python es time.sleep(). En Node.js es setTimeout(). En Java es Thread.sleep(). Estas instrucciones son simples e intuitivas y, para cualquier cosa que supere unos pocos segundos, totalmente inútiles en un sistema distribuido en producción.
El humilde sleep() es una mentira porque asume que el entorno es estable. Asume que la máquina que ejecuta el código seguirá viva, que un balanceador de carga no matará el proceso y que el sistema operativo no reclamará la memoria. En un entorno cloud moderno, estas suposiciones son falsas. Si haces sleep(24 60 60) (un día) en un pod de Kubernetes estándar, es muy probable que ese pod sea rotado, reprogramado o redesplegado antes de que termine el día.
Cuando el proceso muere, tu "sleep" muere con él. Tu lógica de negocio se pierde en el vacío. Así nace el "software olvidadizo": sistemas que pierden la pista de las pruebas de los clientes, se saltan renovaciones de suscripciones y no envían correos de seguimiento críticos.
La jerarquía del tiempo: de segundos a meses
Para gestionar el tiempo correctamente, primero debemos clasificarlo. No todos los retrasos son iguales:
1. Retrasos transitorios (milisegundos a segundos): Normalmente son backoffs de red o esperas por un bloqueo rápido de base de datos. Aquí sleep() es a veces aceptable, porque el riesgo de una caída en una ventana de 50 ms es bajo.
2. Retrasos cortos (minutos): Aquí es donde sleep() empieza a fallar. Estás ocupando un hilo de worker o los recursos de un contenedor durante minutos sin hacer nada. Es un desperdicio de dinero y un riesgo para el pool de conexiones.
3. Durable delays (horas a meses): Este es el terreno de los ciclos de vida de negocio. Una prueba gratuita de 14 días, un plazo de pago a 30 días o un calendario de mantenimiento semestral. Esto no puede vivir en el código; tiene que vivir en la infraestructura.
La arquitectura de los workflows durmientes
En SchemaBridge tratamos el tiempo como estado duradero. Cuando tu workflow llega a un vértice DELAY, no bloquea ningún hilo. El paso queda aparcado como SUSPENDED, la intención de despertar se escribe en la base de datos y el worker queda libre de inmediato para hacer otro trabajo.
El modelo de polling sobre la BD (baja precisión, alta durabilidad)
El enfoque artesanal clásico guarda la "hora de despertar" en una tabla de base de datos, y un worker en segundo plano (el "poller") consulta la tabla cada pocos segundos: SELECT * FROM timers WHERE wake_up < NOW(). Es duradero porque un registro en la BD sobrevive a los reinicios, pero lo pagas con un flujo constante de consultas vacías y un límite de precisión igual a tu intervalo de polling.
El enfoque de SchemaBridge
SchemaBridge conserva la durabilidad del registro en la base de datos y prescinde del polling fijo. Cada despertar se persiste como una fila con un execute_at absoluto en una tabla scheduled_tasks de PostgreSQL (detrás de nuestro ScheduledTaskRepository). El clock worker duerme hasta el execute_at pendiente más próximo, y un push de LISTEN/NOTIFY de PostgreSQL lo despierta antes de tiempo cada vez que una tarea pasa a ser ejecutable. Cuando hay tareas vencidas, las reclama en una única sentencia con FOR UPDATE SKIP LOCKED, de modo que varias réplicas del clock worker pueden repartirse el backlog sin colisionar. Existe un límite de inactividad holgado solo como red de seguridad por si se pierde una notificación.
-- Forma simplificada del claim: filas vencidas, las más antiguas primero, repartidas entre réplicas
UPDATE scheduled_tasks SET status = 'PROCESSING'
WHERE task_id IN (
SELECT task_id FROM scheduled_tasks
WHERE status = 'PENDING' AND execute_at <= :now
ORDER BY execute_at
FOR UPDATE SKIP LOCKED
LIMIT 100
)
RETURNING task_id;
Cancelación y salidas anticipadas: cambiar el futuro
Un durable delay es solo la mitad de la historia. Si un usuario está en un paso "Esperar 3 días al pago" y paga a las 2 horas, ¿qué debería ocurrir?
Primero, la cancelación. Detener una ejecución en SchemaBridge cancela la ejecución y después desarma los temporizadores de delay y de aprobación pendientes de esa ejecución, de modo que un recorrido detenido no se despierta días más tarde para seguir adelante. El orden es deliberado —primero cancelar, luego desarmar—, así que el peor caso es un temporizador que se dispara sobre una ejecución ya cancelada, donde las protecciones de estado terminal lo convierten en un no-op inofensivo.
Segundo, el despertar anticipado. SchemaBridge no tiene una primitiva integrada del tipo "despierta este delay cuando llegue un evento externo". Un vértice DELAY espera la duración configurada. Hay dos formas honestas de modelar una salida anticipada:
- Volver a comprobar al despertar: Cuando termina el delay, un paso Gateway consulta el estado actual (p. ej., "¿se ha pagado esta factura?") y una arista condicional enruta hacia la rama "ya hecho" o hacia la de seguimiento. Nunca hace falta interrumpir el delay; la decisión se toma con datos frescos.
- Dividir en el evento: Modela el propio evento (el webhook de pago) como su propio punto de entrada o workflow, y detén la ejecución en espera cuando llegue.
Ninguno de los dos patrones exige que el motor enfrente un temporizador a una señal, que es justo el tipo de condición de carrera que conviene eliminar por diseño en lugar de confiar en que no ocurra.
Deriva de reloj y precisión
En cualquier sistema distribuido, los relojes nunca están perfectamente sincronizados. Es el fenómeno de la deriva de reloj (clock drift). Si el reloj de una máquina va 50 ms por delante del de otra, un "Esperar 1 segundo" puede comportarse de forma distinta según dónde se recoja la tarea.
SchemaBridge mantiene el problema acotado haciendo que el despertar sea una marca de tiempo absoluta en un único lugar: la columna execute_at de PostgreSQL. Al armar un delay se calcula now + delaySeconds una sola vez y se almacena; a partir de ahí, ningún worker mantiene una cuenta atrás en memoria, así que no hay temporizador por worker que pueda derivar o perderse en un reinicio. El desfase ordinario de reloj entre máquinas sigue existiendo, pero para retrasos de negocio medidos en horas o días es irrelevante para el resultado.
Escenario ilustrativo: automatizar una campaña drip de 30 días
Imagina una plataforma de automatización de marketing que lucha con la lógica de su "Welcome Journey".
El reto
Un recorrido incluye:
- Enviar el correo 1 (día 1).
- Esperar 3 días.
- Si el usuario no ha hecho clic, enviar el correo 2.
- Esperar 7 días.
- Si el usuario no ha mejorado su plan, enviar un código promocional.
Construidos sobre una solución propia con cron jobs y una tabla de Postgres, recorridos como este son frágiles: durante una ventana de mantenimiento de la base de datos o un despliegue, una parte de los usuarios puede quedarse "atascada" en un estado de espera y no recibir nunca su siguiente correo.
La forma SchemaBridge
Imagina esos mismos recorridos reconstruidos como workflows de SchemaBridge.
1. Delays visuales: Arrastras un vértice DELAY al grafo y fijas delaySeconds en 259200 (3 días) y 604800 (7 días). El editor los muestra como duraciones compactas, como "3d".
2. Reanudación duradera: Durante los despliegues, las ejecuciones en espera simplemente siguen aparcadas en la base de datos. Cuando el clock worker vuelve, reclama los temporizadores que vencieron durante la caída, los más antiguos primero, y los reanuda.
3. Volver a comprobar al despertar: Tras cada delay, un paso Gateway pregunta al proveedor de correo o de facturación si el usuario hizo clic o mejoró su plan, y una arista condicional elige el siguiente paso.
El beneficio esperado
- Fiabilidad: Un recorrido en pausa sobrevive a reinicios y despliegues, así que la clase de errores "atascado en un estado de espera" debería desaparecer en gran medida.
- Satisfacción del desarrollador: La lógica cron a medida y el servicio "Job Poller" dejan de ser necesarios.
- Una salvedad que hay que tener en cuenta en el diseño: un paso que se reanuda tras un delay largo y lee
steps.<alias>.outputde un paso anterior depende de que esa salida siga dentro de la ventana de retención de tu plan. Para recorridos que abarcan meses, arrastra en el payload los valores que necesites en lugar de ir a buscarlos atrás.
Comparativa: estrategias de planificación existentes
| Característica | setTimeout() | Cron jobs / Quartz | Durable Delays de SchemaBridge |
| :--- | :--- | :--- | :--- |
| Persistencia | Ninguna (volátil) | Manual (respaldada en BD) | Nativa (PostgreSQL scheduled_tasks) |
| Escalabilidad | Baja (ligada a la RAM) | Media (cuello de botella en la BD) | Alta (SKIP LOCKED entre réplicas) |
| Cancelación | Compleja (handle manual) | Limpieza manual de la BD | Detener una ejecución desarma sus temporizadores |
| Visibilidad | Ninguna | Consultas SQL | Pasos aparcados (suspendidos) visibles en la vista de la ejecución |
| Resolución | Milisegundos | Segundos/minutos | Despertar por push (LISTEN/NOTIFY), sin polling fijo |
Checklist experta para lógica de negocio de larga duración
Si diseñas un sistema que espera más de 5 minutos, sigue estas heurísticas:
1. Libera el hilo: Nunca bloquees un worker mientras esperas. Tu worker debe ser stateless y estar listo para que lo maten en cualquier momento.
2. Externaliza el reloj: Usa un almacén central y duradero para guardar la hora de despertar, no el temporizador en memoria de la máquina local.
3. Diseña para resultados anticipados: Asume siempre que el usuario puede realizar la acción que esperas antes de que venza el delay. Vuelve a comprobar el estado al terminar la espera en lugar de suponer que nada ha cambiado.
4. Audita la sala de espera: Vigila cuántas ejecuciones tienen pasos aparcados en un temporizador. Es un indicador importante de la salud del negocio.
5. Prefiere los delays relativos: Expresa una espera como una duración desde el paso anterior (delaySeconds) en lugar de una fecha de calendario fija, de modo que, si un paso anterior se retrasa, la separación relativa entre pasos se mantenga.
Conclusión: el tiempo es un problema de infraestructura
El software que olvida es software que falla. Al tratar el tiempo como un estado duradero de primera clase, tendemos un puente entre los eventos en tiempo real y el comportamiento humano de alta latencia. Con SchemaBridge, un recorrido que abarca días o semanas sobrevive a los reinicios y despliegues que matarían a un hilo dormido, siempre que diseñes las salidas anticipadas y arrastres los datos que necesitan los pasos posteriores.
La capacidad de gestionar de forma fiable el momento de tus interacciones es una ventaja competitiva real. Nosotros ponemos el reloj duradero; tú pones el recorrido.
Parte de nuestra serie Building the Bridge. Consulta también: La brecha de observabilidad para depurar ejecuciones largas y de varios pasos sin perder la cabeza en los logs.