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:
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¶
- Inyectar clock:
InstantSource clocken signatures de processors - KeyGenerator monotónico: no
UUID.randomUUID(), usar serial PostgreSQL - Test continuo de replay: dos engines compartiendo log, verificación automática
- 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)