Deterministic Simulation Testing: Metodología de Inyección de Fallas en TigerBeetle y FoundationDB
Por Qué Testear Sistemas Distribuidos Es Tan Difícil
El problema fundamental es el no-determinismo. Un sistema distribuido tiene múltiples fuentes:
- Scheduling de threads: El OS decide qué thread corre cuándo
- Timing de red: Los paquetes llegan en orden impredecible con latencia variable
- Timing de I/O de disco: Lecturas y escrituras se completan a velocidades variables
- Skew de reloj: Los relojes del sistema en diferentes máquinas derivan independientemente
- Timing de fallas: Crashes, particiones de red, y errores de disco pasan en momentos arbitrarios
Un bug que requiere una combinación específica de estos eventos no-determinísticos podría tomar billones de ejecuciones de test para aparecer con testing random. Y aunque lo encuentres, no es posible reproducirlo porque no controlás el scheduler del OS, la red, ni el disco.
Los integration tests tradicionales intentan manejar esto con sleeps y retries:
# Así testea la mayoría la gente los sistemas distribuidos (no hagas esto)
def test_leader_election():
cluster.start(3)
time.sleep(5) # "esperar a que complete la elección" 🤞
leader = cluster.get_leader()
assert leader is not None
cluster.kill(leader)
time.sleep(10) # "esperar a la re-elección" 🤞🤞
new_leader = cluster.get_leader()
assert new_leader is not None
assert new_leader != leader
Este test pasa el 99% del tiempo y se pierde todos los bugs interesantes. Los bugs viven en los bordes del timing — ¿qué pasa cuando el timeout de elección se dispara en el exacto mismo instante que un heartbeat? ¿Qué pasa cuando una partición de red se cura durante una transferencia de liderazgo? Estos tests no pueden explorar ese espacio.
La Metodología de FoundationDB
FoundationDB fue pionero en simulation testing en bases de datos de producción. Su insight clave: si controlás todas las fuentes de no-determinismo, es posible explorar todo el espacio de estados sistemáticamente.
La arquitectura tiene tres pilares:
1. Core Single-Threaded, Event-Driven
La base de datos entera corre en un solo thread (o múltiples threads determinísticos con scheduling explícito). No hay concurrencia a nivel del OS. Todas las operaciones asíncronas se modelan como eventos en una cola de prioridad:
Cola de Eventos (ordenada por tiempo simulado):
t=100ms: Nodo A recibe AppendEntries del Nodo B
t=102ms: Timeout de elección del Nodo C se dispara
t=103ms: Escritura de disco en Nodo A completa
t=105ms: Timer de heartbeat del Nodo B se dispara
t=108ms: Partición de red comienza (falla inyectada)
El simulador saca eventos en orden, los ejecuta, y cualquier evento resultante vuelve a la cola. Porque la cola es determinística (mismo seed → mismo estado inicial → mismo orden de eventos), la ejecución es perfectamente reproducible.
2. Capa de I/O Abstracta
Cada interacción con el mundo exterior pasa por una interfaz:
// El runtime flow de FoundationDB abstrae todo
class INetwork {
virtual Future<Void> connect(NetworkAddress addr) = 0;
virtual Future<Void> send(Connection conn, Bytes data) = 0;
virtual Future<Bytes> receive(Connection conn) = 0;
};
class IDisk {
virtual Future<Void> write(FileHandle fh, Offset off, Bytes data) = 0;
virtual Future<Bytes> read(FileHandle fh, Offset off, Size len) = 0;
virtual Future<Void> sync(FileHandle fh) = 0;
};
class IClock {
virtual double now() = 0;
virtual Future<Void> delay(double seconds) = 0;
};
En producción, estos llaman syscalls reales. En simulación, se reemplazan con fakes que introducen delays controlados, fallas, y reordenamientos.
3. Inyección de Fallas Basada en Seed
Dado un seed random, el simulador decide:
- Cuándo disparar timers (con jitter)
- Cuándo entregar o dropear paquetes de red
- Cuándo inyectar errores de disco
- Cuándo simular crashes y restarts de procesos
- Cuándo crear y curar particiones de red
# Pseudocódigo para el inyector de fallas de la simulación
class FaultInjector:
def __init__(self, seed):
self.rng = Random(seed)
def should_drop_packet(self) -> bool:
return self.rng.random() < 0.01 # 1% packet loss
def network_delay_ms(self) -> float:
return self.rng.exponential(scale=5.0) # avg 5ms de delay
def should_inject_partition(self, time) -> bool:
return self.rng.random() < (1.0 / 3600000.0) * time_step_ms
def should_crash_node(self, node_id) -> bool:
return self.rng.random() < 0.0001 # raro pero devastador
def disk_write_should_fail(self) -> bool:
return self.rng.random() < 0.001 # errores de disco ocasionales
El Approach de TigerBeetle: VOPR
TigerBeetle (una base de datos de transacciones financieras escrita en Zig) tomó las ideas de FoundationDB y construyó VOPR — el simulador de Viewstamped Operation Replication. Lo que hace distintivo el approach de TigerBeetle es cuán profundamente la simulación está integrada en el codebase.
En TigerBeetle, la abstracción de I/O no es un afterthought — es el cimiento. Cada syscall pasa por IO, que tiene dos implementaciones:
// I/O de producción — syscalls reales de io_uring
const IO = if (is_simulation)
@import("io_simulation.zig").IO
else
@import("io_uring.zig").IO;
El I/O de simulación reemplaza io_uring con un event loop determinístico:
// I/O de simulación simplificado
pub const IO = struct {
prng: std.rand.DefaultPrng,
event_queue: PriorityQueue(Event),
tick: u64,
pub fn submit_write(self: *IO, buffer: []const u8, offset: u64) Completion {
const delay = self.prng.random_int(u64) % 10 + 1; // 1-10 ticks
const completion = Completion{
.result = if (self.should_inject_fault())
error.DiskError
else
.success,
};
self.event_queue.insert(.{
.tick = self.tick + delay,
.completion = completion,
});
return completion;
}
fn should_inject_fault(self: *IO) bool {
return self.prng.random_int(u64) % 1000 == 0;
}
};
El runner de VOPR ejecuta el cluster entero de TigerBeetle — múltiples réplicas, clientes, y la red — en un solo proceso:
// VOPR corre una simulación de cluster completo
pub fn run(seed: u64) !void {
var prng = std.rand.DefaultPrng.init(seed);
var cluster = Cluster.init(&prng, .{
.replica_count = 6,
.standby_count = 2,
.client_count = 3,
});
var tick: u64 = 0;
while (tick < 10_000_000) : (tick += 1) {
if (prng.random_int(u64) % 100 == 0) {
const fault = random_fault(&prng);
cluster.inject_fault(fault);
}
cluster.tick();
cluster.check_invariants();
}
}
La llamada a check_invariants() es la clave. Después de cada tick de la simulación — no solo al final — TigerBeetle verifica que todas las propiedades de seguridad se mantengan: sin doble-gasto, sin transacciones perdidas, sin divergencia del estado committeado.
Construyendo Tu Propio Harness de Simulación
Esta metodología puede aplicarse a proyectos de menor escala también. A continuación se presenta la arquitectura práctica:
Paso 1: Definí Tu Boundary de Determinismo
Listá todas las fuentes de no-determinismo en tu sistema:
Fuentes de no-determinismo:
✅ Reloj del sistema → wrappear con interfaz Clock inyectable
✅ Números random → usar PRNG seeded en todos lados
✅ I/O de red → wrappear con interfaz Network inyectable
✅ I/O de disco → wrappear con interfaz Storage inyectable
✅ Scheduling de threads → usar event loop single-threaded en simulación
❌ Orden de asignación de memoria → generalmente determinístico, pero ojo con hash maps
❌ Manejo de señales → deshabilitar en simulación
Paso 2: Construí la Capa de I/O Abstracta
// Ejemplo en Go: I/O abstracto para una implementación de Raft
type Clock interface {
Now() time.Time
After(d time.Duration) <-chan time.Time
}
type Network interface {
Send(to NodeID, msg Message) error
Receive() <-chan Message
}
type Storage interface {
Append(entries []LogEntry) error
ReadRange(start, end uint64) ([]LogEntry, error)
Sync() error
}
// Implementaciones de simulación son determinísticas
type SimClock struct {
current time.Time
}
func (c *SimClock) Now() time.Time { return c.current }
func (c *SimClock) Advance(d time.Duration) { c.current = c.current.Add(d) }
Paso 3: El Runner de Simulación
type Simulator struct {
seed int64
rng *rand.Rand
clock *SimClock
network *SimNetwork
nodes []*RaftNode
events *PriorityQueue
}
func (s *Simulator) Run(ticks int) error {
for i := 0; i < ticks; i++ {
s.clock.Advance(time.Millisecond)
s.maybeInjectFault()
s.network.DeliverPending(s.rng)
for _, node := range s.nodes {
node.Tick()
}
if err := s.checkInvariants(); err != nil {
return fmt.Errorf("invariante violada en tick %d (seed %d): %w",
i, s.seed, err)
}
}
return nil
}
Paso 4: Integración con CI
simulation-test:
strategy:
matrix:
seed-range: ["0-100", "100-200", "200-300", "300-400", "400-500"]
steps:
- run: |
START=$(echo ${{ matrix.seed-range }} | cut -d- -f1)
END=$(echo ${{ matrix.seed-range }} | cut -d- -f2)
for seed in $(seq $START $END); do
echo "Corriendo simulación con seed $seed"
./bin/simulation-test --seed=$seed --ticks=1000000
done
Bugs que Esta Metodología Ha Encontrado
En la práctica, el simulation testing consistentemente encuentra bugs en estas categorías:
Escrituras de líder stale: Un nodo cree que es el líder pero fue depuesto. Acepta una escritura que se pierde cuando el log del líder real la sobrescribe. Solo pasa cuando el mensaje de deposición se delay-ea exactamente la cantidad justa.
Split-brain durante curación de partición: Cuando una partición de red se cura, dos sub-clusters tienen estado divergente. El protocolo de reconciliación tiene una ventana donde ambos creen que tienen quórum.
Reordenamiento de escrituras de disco: En hardware real, write(A); write(B); no garantiza que A se persista antes que B. La simulación inyecta reordenamiento de escrituras para encontrar suposiciones sobre el orden de persistencia.
Condiciones de carrera en timers: Cuando dos timers se disparan en el mismo instante simulado, el orden importa. La simulación explora ambos ordenes para cada seed.
Un harness de simulación puede encontrar bugs complejos de interleaving en segundos que tomarían semanas descubrir mediante debugging manual. El contraste entre semanas de investigación manual y segundos de ejecución de simulación subraya el valor de la metodología.
Para sistemas que necesitan ser correctos bajo fallas — bases de datos, protocolos de consenso, procesadores de transacciones, locks distribuidos — el deterministic simulation testing no es opcional. Es la única metodología que sistemáticamente encuentra los bugs que más importan: los que corrompen datos.