Saltar al contenido principal
Testing

Deterministic Simulation Testing: Metodología de Inyección de Fallas en TigerBeetle y FoundationDB

8 min lectura
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
También disponible en: English, Italiano

Por Qué Testear Sistemas Distribuidos Es Tan Difícil

El problema fundamental es el no-determinismo. Un sistema distribuido tiene múltiples fuentes:

  1. Scheduling de threads: El OS decide qué thread corre cuándo
  2. Timing de red: Los paquetes llegan en orden impredecible con latencia variable
  3. Timing de I/O de disco: Lecturas y escrituras se completan a velocidades variables
  4. Skew de reloj: Los relojes del sistema en diferentes máquinas derivan independientemente
  5. 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.

simulación-determinísticatestingsistemas-distribuidostigerbeetlefoundationdbinyección-de-fallascorrectitudzig

Herramientas mencionadas en este artículo

AWSProbá AWS
DigitalOceanProbá DigitalOcean
Divulgación: Algunos enlaces en este artículo son enlaces de afiliado. Si te registrás a través de ellos, puedo recibir una comisión sin costo adicional para vos. Solo recomiendo herramientas que uso y en las que confío personalmente.
Compartir
Seguime