Saltar a contenido

Sistema de Timers en Zeebe

Resumen: Zeebe implementa un sistema de timers demand-driven (no basado en polling) con resolución de 100ms, usando un DueDateCheckScheduler compartido con el subsistema de job backoff. El diseño prioriza eficiencia O(1) en scheduling y prevención de starvation mediante un YieldingDecorator que cede el control después de 50ms de procesamiento continuo.


Arquitectura general

El sistema de timers en Zeebe está diseñado para manejar potencialmente millones de timers activos sin degradar el rendimiento del engine. A diferencia de sistemas basados en polling periódico (que desperdician ciclos revisando timers no vencidos), Zeebe usa un modelo demand-driven: el scheduler sabe exactamente cuándo despertar porque mantiene una referencia atómica al próximo due date.

Componentes principales

flowchart TD
    DTS[DueDateTimerCheckScheduler]
    DDS[DueDateCheckScheduler<br/>compartido]
    DTS --> DDS
    subgraph DDSInternals[Componentes internos]
        AR["AtomicReference&lt;Long&gt; nextDueDate"]
        SES[ScheduledExecutorService]
        CAS[CAS - Compare-And-Swap<br/>para updates]
    end
    DDS --> DDSInternals
    DDSInternals --> TTP[TimerTriggerProcessor]
    DDSInternals --> YD[YieldingDecorator]

DueDateTimerCheckScheduler

Este es el entry point del sistema de timers. Su responsabilidad principal es decidir cuándo ejecutar el chequeo de timers vencidos, sin recurrir a polling.

Modelo demand-driven

En lugar de un loop que cada N milisegundos revisa todos los timers, el scheduler opera así:

  1. Cuando se crea o modifica un timer, se registra su dueDate en el scheduler.
  2. El scheduler compara el nuevo dueDate con el nextDueDate actual usando CAS (Compare-And-Swap) sobre un AtomicReference<Long>.
  3. Si el nuevo dueDate es anterior al actual, el scheduler reprograma su despertar al nuevo tiempo.
  4. Cuando el tiempo llega, el scheduler dispara el procesamiento de timers vencidos.

Resolución de 100ms

La resolución mínima del sistema es de 100 milisegundos. Esto significa que:

  • Timers con due dates separados por menos de 100ms pueden agruparse en el mismo ciclo de procesamiento.
  • La granularidad es suficiente para la mayoría de casos de uso de workflow automation (donde los timers típicos son de segundos, minutos u horas).
  • Esta resolución es un trade-off deliberado entre precisión y overhead de scheduling.

DueDateCheckScheduler compartido

El DueDateCheckScheduler es una abstracción reutilizada por dos subsistemas:

  1. Timers (via DueDateTimerCheckScheduler): para timer events en BPMN.
  2. Job backoff (via JobBackoffCheckScheduler): para reintentos de jobs con delay — ver concepts/retry-backoff.

Diseño O(1)

El scheduling opera en tiempo constante O(1) gracias a:

  • AtomicReference<Long> nextDueDate: almacena únicamente el próximo due date global, no una cola de todos los timers.
  • CAS (Compare-And-Swap): actualización lock-free del próximo due date. Múltiples threads pueden intentar actualizar concurrentemente sin locks.
  • ScheduledExecutorService: un solo thread programado para despertar en el nextDueDate.

El algoritmo de actualización:

función scheduleIfDueDateIsSooner(newDueDate):
    loop:
        currentDueDate = nextDueDate.get()
        si newDueDate >= currentDueDate:
            return  // el actual ya es más pronto
        si nextDueDate.compareAndSet(currentDueDate, newDueDate):
            cancelar schedule actual
            programar nuevo schedule para newDueDate
            return
        // CAS falló, otro thread actualizó primero → reintentar

Este diseño evita mantener una priority queue en memoria con todos los timers activos, lo cual sería O(log n) en inserción y extracción.


YieldingDecorator

Problema: starvation del engine

Cuando hay muchos timers vencidos simultáneamente (por ejemplo, después de un recovery o cuando un batch de process instances tiene timers con el mismo due date), procesarlos todos de una vez bloquearía el stream processor de Zeebe, impidiendo que otros comandos (como completar jobs o crear nuevas instancias) se procesen.

Solución: ceder después de 50ms

El YieldingDecorator envuelve al procesador de timers con una política de yield cooperativo:

  1. Al iniciar el procesamiento de timers vencidos, se marca un timestamp de inicio.
  2. Después de cada timer procesado, se verifica si han pasado más de 50 milisegundos desde el inicio.
  3. Si se superan los 50ms, el procesador cede el control al stream processor principal, permitiendo que otros comandos se procesen.
  4. El scheduler se reprograma para continuar procesando los timers restantes en el siguiente ciclo.

Implicaciones

  • Fairness: otros comandos no quedan bloqueados incluso si hay miles de timers vencidos.
  • Throughput de timers reducido temporalmente: en escenarios de alta carga de timers, el procesamiento se distribuye en múltiples ciclos.
  • Latencia predecible: el worst-case de bloqueo por timers es de ~50ms por ciclo.

TimerTriggerProcessor

Este processor es el encargado de activar el elemento BPMN target cuando un timer vence, y de manejar la lógica de repeating timers.

Activación del elemento target

Cuando un timer vence:

  1. El TimerTriggerProcessor identifica el elemento BPMN asociado al timer (boundary event, intermediate catch event, start event, etc.).
  2. Genera un comando para activar ese elemento, insertándolo en el stream de comandos de Zeebe.
  3. El elemento se procesa como cualquier otro evento BPMN.

Reschedule de repeating timers

Para timers con repetición (cycle timers, por ejemplo R3/PT1H — repetir 3 veces cada hora):

  1. Después de disparar el timer, el processor calcula el próximo due date.
  2. Crucialmente, el próximo due date se calcula anclado a la fecha anterior, no al momento actual. Esto significa que si un timer debía dispararse a las 10:00 y se procesó a las 10:00.050 (50ms de delay), el siguiente se programa para las 11:00, no para las 11:00.050.
  3. Esta estrategia previene timer drift: la acumulación gradual de pequeños delays que eventualmente desincronizarían el timer de su schedule original.
  4. Si el próximo due date calculado ya está en el pasado (por ejemplo, después de un recovery largo), se dispara inmediatamente y se recalcula el siguiente.

Tipos de timer events soportados

Tipo BPMN Comportamiento del trigger
Timer Start Event Crea nueva process instance
Timer Intermediate Catch Event Continúa el flujo del proceso
Timer Boundary Event (interrupting) Cancela la actividad y toma el path alternativo
Timer Boundary Event (non-interrupting) Dispara el path alternativo sin cancelar la actividad
Timer Event Sub-Process Inicia el sub-proceso

Interacción con el stream processor

El sistema de timers se integra con el stream processor de Zeebe como un source de comandos interno:

  1. Los timer checks generan comandos TIMER:TRIGGER que se insertan en el log de comandos.
  2. Estos comandos pasan por el mismo pipeline de procesamiento que cualquier comando externo (gRPC).
  3. El state del timer se mantiene en RocksDB como parte del state store de la partición.
  4. En un cluster, cada partición maneja sus propios timers de forma independiente — no hay coordinación cross-partition para timers.

Consideraciones para un MVP

Para una implementación simplificada de workflow engine:

  • Esencial: el modelo demand-driven con AtomicReference + CAS es elegante y eficiente; vale la pena replicarlo.
  • Esencial: el yield después de 50ms previene problemas reales de starvation; cualquier implementación de timers en un single-threaded processor lo necesita.
  • Simplificable: la resolución de 100ms es un buen default pero podría ser configurable.
  • Simplificable: el anclaje a fecha anterior para repeating timers es correcto pero un MVP podría empezar con anclaje al momento actual y refinar después.