Saltar a contenido

Raft Consensus en Zeebe

Resumen: Zeebe implementa Raft consensus para replicar el log de cada particion entre brokers. Usa un State Pattern con cuatro roles (Inactive, Follower, Candidate, Leader), leader election con randomized timeouts y priority-based election, log replication via quorum commit, y joint consensus para reconfiguracion de membership. Todo corre en un solo ThreadContext por particion para garantizar thread safety.


RaftContext

El RaftContext mantiene el estado persistente del nodo Raft para una particion:

Campo Tipo Descripcion
term long Termino actual. Incrementa con cada eleccion. Identifica "epocas" de liderazgo.
leader MemberId El lider conocido del termino actual. null si no se conoce aun.
lastVotedFor MemberId A quien voto este nodo en el termino actual. Previene doble voto.
commitIndex long Indice del ultimo entry confirmado por quorum. Solo avanza, nunca retrocede.

Estos campos se persisten a disco antes de responder a cualquier RPC. Si el nodo se reinicia, recupera su estado Raft desde disco y retoma desde donde estaba.

Invariantes criticas

  • term es monotonicamente creciente.
  • Un nodo solo vota una vez por termino (campo lastVotedFor).
  • commitIndex solo avanza cuando el leader confirma quorum.
  • Un entry con commitIndex <= entry.index es inmutable y nunca se sobreescribe.

State Pattern: roles del nodo

Cada nodo Raft implementa un State Pattern con cuatro estados posibles:

InactiveRole

Estado inicial y estado de shutdown. El nodo no participa en el cluster. No vota, no replica, no procesa. Se entra a este estado cuando: - El nodo esta arrancando y aun no se unio al cluster. - El nodo esta siendo removido del cluster (reconfiguracion). - El nodo esta en shutdown graceful.

FollowerRole

Estado normal de la mayoria de nodos. Comportamiento: - Recibe AppendEntries del leader y replica entries en su log local. - Recibe RequestVote de candidates y vota si el candidate tiene log al menos tan actualizado. - Election timeout: si no recibe heartbeat del leader en un periodo aleatorio, transiciona a CandidateRole. - No procesa commands: redirige clientes al leader conocido.

El election timeout es randomizado para evitar split votes: cada follower elige un timeout aleatorio dentro de un rango configurable (tipicamente 150ms-300ms). Esto hace extremadamente improbable que dos followers inicien elecciones simultaneas.

CandidateRole

Estado transitorio durante una eleccion. Flujo: 1. Incrementar term en 1. 2. Votar por si mismo (lastVotedFor = self). 3. Enviar RequestVote a todos los demas nodos. 4. Esperar respuestas: - Si recibe mayoria de votos → transicionar a LeaderRole. - Si recibe AppendEntries de un leader con term >= propio → transicionar a FollowerRole. - Si timeout sin resultado → incrementar term y reiniciar eleccion.

LeaderRole

Solo un nodo por particion es leader en un termino dado. Responsabilidades: - Procesar todos los commands: los clientes solo escriben al leader. - Replicar entries a todos los followers via AppendEntries. - Enviar heartbeats periodicos para mantener liderazgo y prevenir elecciones. - Avanzar commitIndex cuando un entry es replicado en mayoria de nodos. - Manejar reconfiguraciones de cluster (joint consensus).


Leader Election detallado

VoteQuorum

VoteQuorum es la estructura que trackea votos recibidos durante una eleccion:

VoteQuorum {
    votes: Map<MemberId, boolean>
    quorumSize: int  // (cluster_size / 2) + 1

    addVote(member, granted) → QuorumResult
    // QuorumResult: PENDING | ELECTED | REJECTED
}

El quorum requiere (N/2) + 1 votos para ganar, donde N es el tamaño del cluster.

Randomized timeout

El timeout de eleccion se calcula como:

electionTimeout = minElectionTimeout + random(0, maxElectionTimeout - minElectionTimeout)

Valores tipicos: minElectionTimeout = 2s, maxElectionTimeout = 4s. El jitter aleatorio previene split votes en la mayoria de casos.

Priority-based election

Zeebe extiende Raft vanilla con prioridad de eleccion. Cada nodo tiene una prioridad configurable. Los nodos con mayor prioridad: - Usan timeouts mas cortos, lo que les da ventaja para iniciar elecciones. - Los followers con menor prioridad ceden su voto a candidates con mayor prioridad.

Esto permite preferir ciertos nodos como leaders (e.g., nodos en el mismo datacenter que la mayoria de clientes) sin romper la correccion del protocolo. Si el nodo preferido no esta disponible, cualquier otro nodo puede ganar la eleccion normalmente.


Log Replication

LeaderAppender

El LeaderAppender es responsable de enviar entries a los followers:

  1. El leader recibe un command y lo escribe a su log local.
  2. Crea un InternalAppendRequest con:
  3. term: termino actual del leader.
  4. leaderId: identificador del leader.
  5. prevLogIndex y prevLogTerm: para verificar consistencia del log del follower.
  6. entries[]: entries nuevos a replicar.
  7. leaderCommit: commitIndex del leader (para que el follower avance su propio commit).
  8. Envia el request a cada follower.
  9. Cada follower valida prevLogIndex/prevLogTerm, escribe los entries, y responde con exito o fallo.
  10. El leader trackea matchIndex por follower (ultimo entry replicado exitosamente).

Quorum commit

Un entry se considera committed cuando esta replicado en una mayoria de nodos:

commitIndex = median(matchIndex de todos los nodos, incluyendo el leader)

Ejemplo con 3 nodos (quorum = 2): - Leader: matchIndex = 10 - Follower A: matchIndex = 10 - Follower B: matchIndex = 8 - commitIndex = median(10, 10, 8) = 10 (2 de 3 tienen >= 10)

Una vez committed, el entry es durable y nunca se pierde, incluso si el leader falla.


Entry types

El log de Raft contiene tres tipos de entries:

ApplicationEntry

Contiene los records del engine (commands, events). Es el tipo principal. Soporta batching: multiples records del StreamProcessor se empaquetan en un solo ApplicationEntry para reducir overhead de replicacion.

Estructura:

ApplicationEntry {
    term: long
    lowestPosition: long   // posicion del primer record
    highestPosition: long  // posicion del ultimo record
    data: byte[]           // records serializados (SBE)
}

ConfigurationEntry

Contiene cambios de membership del cluster (agregar/remover nodos). Se replica como cualquier otro entry pero trigger lógica especial de reconfiguracion.

Estructura:

ConfigurationEntry {
    term: long
    members: List<MemberId>        // configuracion nueva
    oldMembers: List<MemberId>     // configuracion anterior (para joint consensus)
}

InitialEntry

Un no-op entry que el leader escribe al inicio de su termino. Sirve para: - Confirmar que el leader puede commitir en el termino actual. - Forzar la replicacion de entries de terminos anteriores que aun no estaban committed. - Es una optimizacion del paper original de Raft para acelerar la convergencia.


EntryValidator hook

Antes de commitir un entry, Zeebe permite ejecutar un EntryValidator como hook de validacion:

EntryValidator {
    validateEntry(ApplicationEntry) → ValidationResult
    // ValidationResult: OK | SKIP | ERROR
}

Usos: - Validar que los records dentro del entry son bien-formados antes de commitirlos. - Detectar entries corruptos (e.g., por bugs en serializacion). - Saltar entries invalidos sin abortar todo el procesamiento.

Esto es una extension que no esta en Raft vanilla. Proporciona una capa de seguridad entre la replicacion y el procesamiento.


Joint Consensus para reconfiguracion

Zeebe implementa joint consensus (del paper extendido de Raft) para cambiar la membership del cluster de forma segura:

Problema

Cambiar la configuracion directamente (e.g., de 3 nodos a 5) puede crear dos quorums independientes durante la transicion, causando split brain.

Solucion: dos fases

  1. Fase 1 — Joint configuration (C_old,new):
  2. El leader escribe un ConfigurationEntry con la union de la configuracion vieja y nueva.
  3. Durante esta fase, un entry requiere quorum en ambas configuraciones para commitir.
  4. Esto garantiza que ni la configuracion vieja ni la nueva pueden decidir unilateralmente.

  5. Fase 2 — New configuration (C_new):

  6. Una vez committed la joint configuration, el leader escribe otro ConfigurationEntry con solo la configuracion nueva.
  7. A partir de aqui, solo la nueva configuracion determina quorum.

Implicaciones para implementacion

Joint consensus es complejo de implementar correctamente. Para un MVP simplificado: - Considerar usar single-step membership change (agregar/remover un nodo a la vez), que es mas simple y evita la necesidad de joint consensus. - Raft garantiza safety con single-step changes siempre que no se hagan cambios concurrentes.


ThreadContext: single-threaded safety

Todo el procesamiento de Raft para una particion ocurre en un unico ThreadContext:

  • Transiciones de estado (Follower → Candidate → Leader) son secuenciales.
  • Escrituras al log son secuenciales.
  • Respuestas a RPCs son secuenciales.

Esto elimina race conditions y simplifica enormemente la logica. El ThreadContext es el mismo que usa el StreamProcessor (ver concepts/stream-processing), lo que garantiza que el procesamiento de records y las decisiones de Raft no compiten por locks.

Para implementacion: no paralelizar la logica de Raft. Un solo thread es suficiente y correcto. La escalabilidad viene de tener multiples particiones, cada una con su propio thread y su propia instancia de Raft.


Implicaciones para implementacion simplificada

  1. Raft es el componente mas critico: errores aqui causan perdida de datos o inconsistencia. Considerar usar una libreria existente (e.g., etcd/raft, hashicorp/raft) en lugar de implementar desde cero.
  2. Priority-based election es una optimizacion: no esencial para un MVP. Vanilla Raft funciona correctamente sin prioridades.
  3. Joint consensus es opcional: single-step membership change es suficiente para clusters pequeños.
  4. EntryValidator es una buena practica: implementar al menos validacion basica de entries antes de procesarlos.
  5. Batching de ApplicationEntry es importante para rendimiento: sin batching, cada record requiere un round de replicacion completo.

Ver tambien: concepts/stream-processing para como se procesan los entries una vez committed, concepts/partitioning para como se distribuyen particiones entre nodos.