Saltar a contenido

Backpressure en Zeebe

Resumen: Zeebe implementa un sistema de backpressure de dos capas (request limiter de concurrencia + write rate limiter basado en Guava RateLimiter) que prefiere dropping sobre buffering — los comandos son rechazados y el cliente reintenta. El límite de concurrencia usa StabilizingAIMDLimit, una variante custom de AIMD que solo reduce el límite cuando el inflight es ≤ limit, evitando oscilaciones. Ciertos comandos críticos (COMPLETE, FAIL, CANCEL, DEPLOY) bypass backpressure completamente. El tracking de requests inflight usa un ring buffer con AtomicReferenceArray.


Filosofía: dropping over buffering

El sistema de backpressure de Zeebe toma una decisión de diseño fundamental: rechazar comandos (dropping) en lugar de encolarlos (buffering).

Razones para dropping

  1. Bounded memory: buffering requiere memoria proporcional a la carga. Bajo presión sostenida, un buffer eventualmente se llena o causa OOM.
  2. Latencia predecible: un comando buffered puede esperar un tiempo arbitrario. Al rechazarlo inmediatamente, el cliente sabe que debe reintentar y puede tomar decisiones informadas (backoff, circuit breaker).
  3. Señal clara al cliente: un error de backpressure (gRPC status RESOURCE_EXHAUSTED) es una señal inequívoca de que el sistema está saturado.
  4. Simplicidad: no se necesita gestión de colas, prioridades, timeouts de buffer, ni dead letter queues para comandos encolados.

Comportamiento del cliente

Cuando un comando es rechazado por backpressure:

flowchart LR
    A[Cliente] --> B[gRPC RESOURCE_EXHAUSTED]
    B --> C[SDK intercepta]
    C --> D[Retry con backoff exponential]
    D --> E[Reintenta el comando]

Los SDKs oficiales de Zeebe implementan retry automático con backoff configurable para errores de backpressure.


Arquitectura de dos capas

El sistema de backpressure tiene dos capas independientes que operan en serie:

flowchart TD
    Req[Request entrante] --> L1
    L1[Capa 1: Request Limiter<br/>StabilizingAIMDLimit<br/>demasiados requests inflight?]
    L1 -->|pasa| L2[Capa 2: Write Rate Limiter<br/>Guava RateLimiter<br/>demasiadas writes/segundo?]
    L2 -->|pasa| SP[Stream Processor]
    L1 -.->|rechaza| X1[RESOURCE_EXHAUSTED]
    L2 -.->|rechaza| X2[RESOURCE_EXHAUSTED]

Capa 1: Request Limiter (concurrency)

Controla el número de requests concurrentes (inflight) que el broker acepta.

  • Métrica: número de requests que han sido aceptados pero cuyo procesamiento no ha completado.
  • Límite: dinámico, ajustado por el algoritmo AIMD.
  • Decisión: si inflight >= limit → rechazar.

Capa 2: Write Rate Limiter

Controla la tasa de escrituras al stream processor.

  • Implementación: Guava RateLimiter (token bucket).
  • Métrica: writes por segundo al log de comandos.
  • Límite: configurable, con un rate máximo de writes por segundo.
  • Decisión: si no hay tokens disponibles → rechazar.

Por qué dos capas

Cada capa protege contra un tipo diferente de sobrecarga:

Escenario Capa que protege
Muchos clientes enviando requests simultáneamente Request Limiter
Pocos clientes pero con requests muy pesados (batch) Request Limiter
Tasa sostenida de writes que supera capacidad del disco/Raft Write Rate Limiter
Burst corto seguido de periodo tranquilo Request Limiter (permite el burst hasta el limit)

StabilizingAIMDLimit

AIMD clásico

AIMD (Additive Increase, Multiplicative Decrease) es un algoritmo de control de congestión:

  • Additive Increase: cuando las cosas van bien, incrementar el límite linealmente (+1).
  • Multiplicative Decrease: cuando hay congestión, reducir el límite multiplicativamente (×0.5 o similar).

Problema con AIMD clásico

En el contexto de Zeebe, el AIMD clásico tiene un problema: cuando se detecta congestión y se reduce el límite, los requests inflight que ya fueron aceptados pueden exceder el nuevo límite reducido. Esto causa que el algoritmo siga reduciendo el límite en cada check, creando una espiral descendente hasta llegar al mínimo.

Ejemplo de espiral descendente:
  t=0: limit=100, inflight=90 → OK
  t=1: congestión detectada, limit=50 (×0.5)
  t=2: inflight=80 (los 90 anteriores aún procesándose, menos 10 completados)
       80 > 50 → "aún congestionado" → limit=25 (×0.5)
  t=3: inflight=60 → 60 > 25 → limit=12
  ... espiral hasta el mínimo

Solución: StabilizingAIMDLimit

El StabilizingAIMDLimit de Zeebe es una variante custom que agrega una condición: solo reduce el límite cuando inflight ≤ limit.

función onSample(inflight, latency):
    si latency > threshold:  // hay congestión
        si inflight <= limit:
            // La congestión NO es por exceso de inflight
            // Reducir es seguro porque no estamos en espiral
            limit = max(limit * backoffRatio, minLimit)
        // sino: no reducir, ya que los inflight actuales
        // que exceden el límite causan la congestión
    sino:
        limit = min(limit + 1, maxLimit)  // additive increase

Parámetros configurables

| Parámetro | Default | Descripción | |---|---| | minLimit | 1 | Límite mínimo de concurrencia | | maxLimit | 1000 | Límite máximo de concurrencia | | backoffRatio | 0.9 | Factor de reducción (menos agresivo que 0.5 clásico) | | initialLimit | 200 | Límite inicial |


Whitelisted commands

Ciertos comandos nunca son rechazados por backpressure, independientemente del estado de las dos capas:

Comando Razón para bypass
COMPLETE_JOB Rechazar completions causaría timeout del job y retry innecesario, empeorando la congestión
FAIL_JOB Similar a complete; rechazar fallos dejaría jobs en limbo
CANCEL_PROCESS_INSTANCE Cancelaciones liberan recursos; rechazarlas empeoraría la presión
DEPLOY_RESOURCE Deployments son infrecuentes y críticos para operaciones

Lógica del bypass

La decisión de qué comandos bypass backpressure se basa en un principio: rechazar estos comandos empeoraría la congestión en lugar de aliviarla.

Si un COMPLETE_JOB es rechazado: 1. El job timeout expira. 2. El job se reactiva automáticamente. 3. Un worker lo toma y lo procesa de nuevo. 4. El worker intenta completarlo de nuevo. 5. Net result: más trabajo, no menos.


Ring buffer tracking con AtomicReferenceArray

Problema

Para implementar el request limiter, Zeebe necesita trackear el número de requests inflight con mínimo overhead y alta concurrencia (múltiples threads gRPC aceptando requests simultáneamente).

Solución: ring buffer con AtomicReferenceArray

En lugar de un simple AtomicInteger para contar inflight (que se convertiría en un bottleneck de contención), Zeebe usa un ring buffer implementado con AtomicReferenceArray:

AtomicReferenceArray<Request> inflight = new AtomicReferenceArray<>(capacity);

// Registrar request
función acquire(request):
    slot = hash(request) % capacity
    si inflight.compareAndSet(slot, null, request):
        return true  // request aceptado
    return false  // slot ocupado, rechazar

// Liberar request
función release(request):
    slot = hash(request) % capacity
    inflight.compareAndSet(slot, request, null)

Ventajas del ring buffer

  1. Distributed contention: en lugar de que todos los threads compitan por un solo AtomicInteger, cada thread accede a un slot diferente del array, reduciendo la contención.
  2. Lock-free: usa CAS (Compare-And-Swap) para operaciones atómicas sin locks.
  3. O(1): tanto acquire como release son O(1).
  4. Cache-friendly: el array es contiguo en memoria, aprovechando cache lines del CPU.

Trade-offs

  • False contention: dos requests que hashean al mismo slot compiten innecesariamente. El tamaño del array se dimensiona para minimizar esto.
  • Approximate count: el "count" de inflight es una aproximación basada en slots ocupados, no un conteo exacto. Esto es aceptable para backpressure, donde precisión exacta no es necesaria.

Configuración

El sistema de backpressure es configurable vía properties de Zeebe:

zeebe:
  broker:
    backpressure:
      enabled: true
      algorithm: aimd  # aimd | fixed | vegas | gradient | gradient2
      aimd:
        initialLimit: 200
        minLimit: 1
        maxLimit: 1000
        backoffRatio: 0.9
        requestTimeout: 10s

Algoritmos disponibles

Zeebe soporta múltiples algoritmos de control de concurrencia (del proyecto Netflix Concurrency Limits):

Algoritmo Descripción
aimd StabilizingAIMDLimit (default, recomendado)
fixed Límite fijo, sin ajuste dinámico
vegas TCP Vegas-inspired, basado en RTT
gradient Gradient-based, más sensible a cambios
gradient2 Gradient2, variante mejorada

El default aimd fue elegido por su estabilidad y comportamiento predecible.


Monitoreo

Métricas expuestas para observabilidad del backpressure:

Métrica Descripción
zeebe_backpressure_inflight_requests_count Requests actualmente inflight
zeebe_backpressure_requests_limit Límite actual del request limiter
zeebe_backpressure_requests_dropped_total Total de requests rechazados por backpressure
zeebe_write_rate_limit Rate limit actual de escrituras

Consideraciones para un MVP

  • Esencial: alguna forma de backpressure es necesaria para un engine de producción. Sin ella, un burst de requests puede saturar el engine y causar cascading failures.
  • Esencial: el bypass de COMPLETE/FAIL es crítico — sin él, el backpressure puede empeorar la congestión.
  • Simplificable: para un MVP, un fixed limit en lugar de AIMD dinámico es suficiente. AIMD agrega complejidad pero es más adaptable.
  • Simplificable: la capa de write rate limiting puede omitirse inicialmente si el request limiter es suficiente.
  • Simplificable: el ring buffer con AtomicReferenceArray es una optimización de rendimiento. Un AtomicInteger simple funciona para un MVP con menos concurrencia.