Saltar a contenido

Replay Determinism

Zeebe garantiza que el state reconstruido por replay del log es idéntico al state producido por procesamiento directo, exceptuando 2 column families (DEFAULT, MIGRATIONS_STATE). Esta garantía se verifica continuamente con ContinuouslyReplayTest, ejecutando dos engines sobre el mismo log en paralelo y comparando state column-by-column.

La garantía

Propiedad: para cualquier secuencia de commands C1, C2, ..., Cn:

processing(C1, C2, ..., Cn) = replay(log_after_processing)

Es decir, dos formas de llegar al mismo state: 1. Procesar los commands directamente 2. Releer el log de events y aplicarlos

Esto es el contrato fundamental del command sourcing (ver concepts/command-sourcing).

Por qué importa

Para failover

Cuando un broker leader falla, otro nodo del cluster Raft toma el liderazgo. Para tomar el liderazgo, el nuevo leader debe reconstruir el state via replay del log. Si replay no es deterministic, dos brokers podrían tener state divergente — corrupción silenciosa.

Para snapshots

Snapshots se toman periódicamente y se transfieren entre nodes. Un nuevo node que recibe un snapshot + log desde X position debe llegar al mismo state que el leader. Sin replay determinism, esto no es posible.

Para debugging

Replay determinism permite reproducir bugs deterministicamente. Dado un log + un caso de prueba, el bug se manifiesta de la misma forma siempre.

Excepciones documentadas

Dos column families se EXCLUYEN del check de equivalencia:

Column Family Razón
DEFAULT Estados transientes que se pierden en leader replay de todas formas
MIGRATIONS_STATE Se popula en broker start, no via events

Implicación arquitectónica: la gran mayoría del state ES replay-deterministic, pero hay excepciones. Estas excepciones deben: - Documentarse explícitamente - Solo contener state que pueda recomputarse o ignorarse en failover - Estar aisladas (column families separadas)

El test que lo verifica: ContinuouslyReplayTest

private final ListLogStorage sharedStorage = new ListLogStorage();

@Rule
public final EngineRule replay =
    EngineRule.withSharedStorage(sharedStorage)
        .withStreamProcessorMode(StreamProcessorMode.REPLAY);

@Rule
public final EngineRule processing = 
    EngineRule.withSharedStorage(sharedStorage);

Setup: - Dos EngineRule instances comparten el mismo ListLogStorage - processing procesa commands y produce events al log - replay lee el log en mode REPLAY y reconstruye state

Verificación:

Awaitility.await("await that the replay state is equal to the processing state")
    .untilAsserted(() -> {
        final var replayState = replay.collectState();
        final var processingState = processing.collectState();

        // assert column-by-column equality (excepto DEFAULT y MIGRATIONS_STATE)
    });

Usa Awaitility porque replay puede estar atrás del processing — espera hasta convergencia.

Property-based version

ReplayStateRandomizedPropertyTest extiende este check a procesos generados aleatoriamente con paths aleatorios. La propiedad es:

Para CUALQUIER BPMN model válido y CUALQUIER execution path válido, replay state debe igualar processing state.

Esto detecta bugs en processors raramente ejercitados (gateways exóticos, event combinations, etc.) que los tests específicos podrían no cubrir.

Cómo se logra replay determinism

Para que el engine sea replay-deterministic:

1. Single-threaded processing por partición

Sin concurrencia, no hay race conditions que puedan producir diferentes outputs para los mismos inputs. Ver concepts/stream-processing.

2. Determinismo en behaviors

Toda fuente de no-determinism debe ser externalizada: - clock: inyectado como InstantSource, mockeable - keyGenerator: monotónico (no random) - Sin acceso directo al system clock - Sin acceso directo a random number generators

3. Events contienen TODO lo necesario para state mutation

Un EventApplier que recibe un event debe poder producir el mismo state mutation sin acceso a información externa. El event debe ser self-contained.

4. Orden de processing preservado

El log preserva el orden exacto de commands → events. Replay procesa events en el mismo orden.

Implicaciones para el MVP

Mantener replay determinism es CRÍTICO

Si el MVP soporta cualquier forma de failover o snapshot, replay determinism es un requisito. Incluso para single-node MVP, sirve para: - Debugging reproducible - Testing exhaustivo - Migration de versions del engine - Disaster recovery via replay del command log

Patterns a replicar

  1. Inyectar clock: InstantSource clock en signatures de processors
  2. KeyGenerator monotónico: no UUID.randomUUID(), usar serial PostgreSQL
  3. Test continuo de replay: dos engines compartiendo log, verificación automática
  4. Excepciones documentadas: si hay state no-deterministic, aislarlo en tablas separadas

Para PostgreSQL como backend

-- Tabla command log (deterministic)
CREATE TABLE command_log (
    position BIGSERIAL PRIMARY KEY,  -- orden monotónico
    timestamp TIMESTAMPTZ NOT NULL,  -- desde clock inyectado, no NOW()
    command_type TEXT NOT NULL,
    intent TEXT NOT NULL,
    payload JSONB NOT NULL
);

-- Tabla event log (deterministic)
CREATE TABLE event_log (
    position BIGSERIAL PRIMARY KEY,
    command_position BIGINT REFERENCES command_log(position),
    timestamp TIMESTAMPTZ NOT NULL,
    event_type TEXT NOT NULL,
    intent TEXT NOT NULL,
    payload JSONB NOT NULL
);

-- State reconstruible del event log
-- Si no es reconstruible (e.g., caches), tabla separada marcada como "transient"

Test mínimo para validar

Implementar el equivalente a ContinuouslyReplayTest en el MVP:

def test_replay_determinism():
    log = SharedLog()

    processing_engine = Engine(log, mode=PROCESSING)
    replay_engine = Engine(log, mode=REPLAY)

    # ejecutar algunos commands en processing
    processing_engine.deploy("simple_process.bpmn")
    pid = processing_engine.create_instance("simple_process")
    processing_engine.complete_job(...)

    # esperar a que replay alcance
    wait_until(lambda: replay_engine.state == processing_engine.state)