Capturando uma Race Condition de Retry com Uma Seed: Simulação Determinística em Rust usando turmoil
Eu tinha três testes de retry flaky que ninguém conseguia reproduzir em um laptop. Reescrevi um deles em Rust em cima do turmoil, o simulador determinístico do Tokio, e uma única seed de 8 bytes fixou a race condition de partição byte por byte. Estas são minhas anotações sobre o que a seed realmente controla, o que escapa dela e quando o teste de simulação determinística vale a pena.
A maioria dos testes flaky não me ensina nada. A linha de saída diz "esperava 1, obteve 2" uma vez a cada 4.000 execuções, o screenshot do CI sai do dashboard e a próxima pessoa a tocar no arquivo faz rebase por cima. Eu tenho anotações de três deles do último ano — todos sobre retries durante partição, todos em Rust, todos irreproduzíveis no meu laptop.
Isso mudou quando me sentei com o turmoil. É o framework de simulação determinística do próprio Tokio: cada host roda em uma thread, o tempo e a rede são mockados, e toda a simulação é dirigida por um RNG com seed. A promessa é a parte que torna essa categoria de bug interessante. Uma única seed de 8 bytes replica as mesmas decisões de scheduling, os mesmos timings de partição, a mesma ordem de bytes do TCP. O flake deixa de ser uma história e se torna um identificador.
O teste de simulação determinística deixou de ser folclore do FoundationDB. Uma palestra na QCon London 2026 percorreu uma DST baseada em máquina de estados em Rust, a WarpStream publicou sobre rodar todo o seu SaaS através do Antithesis em março, e o time da S2 publicou um artigo sobre combinar turmoil com shims libc para código de armazenamento em Rust. Este post é o que aprendi montando um pequeno exemplo, mais os vazamentos que tive que tampar antes que ele realmente se sustentasse.
O que o turmoil simula e o que ele não simula
Um teste turmoil constrói um Sim, registra alguns "hosts" (cada um é uma closure async), registra um "client" (o driver do teste) e chama sim.run(). Os hosts recebem TCP/UDP virtual via turmoil::net, hostnames virtuais e um tempo do tokio que também é mockado. O simulador tem um loop de passos. A cada passo, a rede entrega quaisquer pacotes que estejam vencidos, quaisquer timers que estejam vencidos disparam, e qualquer task que esteja pronta progride. Com uma seed fixa, a ordem é totalmente determinada. A crate atual é turmoil = "0.7", que adiciona um shim parcial de filesystem atrás de unstable-fs para testes de consistência em crash.
O que o turmoil não controla é qualquer coisa que escape de sua superfície: syscalls reais, threads criadas fora do runtime, bibliotecas que mantêm seus próprios clocks, qualquer coisa apoiada em std::collections::HashMap. O HashMap do Rust faz seed do seu hasher por processo a partir do OsRng na construção, então a ordem de iteração muda entre execuções mesmo quando o resto do código é determinístico. Perdi uma tarde com isso.
Uma race condition de retry que sobrevive a 10.000 reexecuções
A forma do bug que quero que um teste encontre é pequena. Um cliente envia uma requisição "apply 42" para um servidor. O servidor aplica, incrementa um contador e responde com "ok". A rede descarta o ack. O cliente faz retry. O servidor aplica "42" de novo. O bug é a falta de checagem de idempotência. O flake é que ele só dispara quando o timing da partição se alinha com o timer de retry da aplicação.
A forma, desenhada, fica assim:
Aqui está o menor teste turmoil que consegui escrever que fixa o problema.
// Cargo.toml
// [dependencies]
// tokio = { version = "1", features = ["full"] }
// turmoil = "0.7"
// rand = "0.9"
//
// Run with: cargo test --release retry_race -- --nocapture
use std::sync::{Arc, atomic::{AtomicU32, Ordering}};
use std::time::Duration;
use rand::SeedableRng;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use turmoil::{net::{TcpListener, TcpStream}, Builder};
fn run_one(seed: u64) -> u32 {
let applied = Arc::new(AtomicU32::new(0));
let mut sim = Builder::new()
.simulation_duration(Duration::from_secs(30))
.build_with_rng(Box::new(rand::rngs::StdRng::seed_from_u64(seed)));
let counter = applied.clone();
sim.host("server", move || {
let counter = counter.clone();
async move {
let listener = TcpListener::bind("0.0.0.0:80").await?;
loop {
let (mut s, _) = listener.accept().await?;
let counter = counter.clone();
tokio::spawn(async move {
let mut id = [0u8; 4];
if s.read_exact(&mut id).await.is_ok() {
// BUG: no dedupe on id — every arrival applies.
counter.fetch_add(1, Ordering::Relaxed);
let _ = s.write_all(b"ok").await;
}
});
}
}
});
sim.client("client", async {
let id: u32 = 42;
for attempt in 0..2 {
let mut s = TcpStream::connect("server:80").await?;
s.write_all(&id.to_le_bytes()).await?;
if attempt == 0 { turmoil::partition("client", "server"); }
let mut ack = [0u8; 2];
let _ = tokio::time::timeout(
Duration::from_secs(2),
s.read_exact(&mut ack),
).await;
turmoil::repair("client", "server");
}
Ok(())
});
sim.run().unwrap();
applied.load(Ordering::Relaxed)
}
#[test]
fn retry_race() {
for seed in 0..32 {
let n = run_one(seed);
assert!(n <= 1, "seed={seed} applied={n}: not idempotent");
}
}Execute com cargo test --release retry_race -- --nocapture. A primeira seed que falha imprime applied=2 e o assert para o teste. A correção é nada surpreendente — manter um HashSet<u32> de ids aplicados no servidor e pular o incremento quando o id já estiver presente. O ponto não é a correção. O ponto é que a falha é a mesma na primeira execução, na milionésima execução, no meu laptop, no CI, em um M3 emprestado, e permanece a mesma enquanto eu segurar a seed.
As duas linhas não óbvias são build_with_rng(...), que faz cada fonte de aleatoriedade que o simulador possui ser derivada da seed, e partition("client", "server") imediatamente depois do write. Os bytes de "42" podem ou não ter cruzado para o servidor antes que essa partição entre em vigor. Com a variância da seed, ambos os resultados acontecem ao longo das 32 execuções. A invariante que o teste verifica (applied <= 1) captura o caminho ruim sem precisar prever quais seeds o expõem.
O que a seed realmente fixa
Uma execução do turmoil passa um único RNG por toda escolha que o simulador faz — ordem de scheduling de tasks, jitter de entrega de pacotes, o timing que as primitivas de manipulação de rede usam. O tempo do tokio também é mockado, então tokio::time::timeout retorna no mesmo instante lógico para a mesma seed. Isso é suficiente para tornar a race de timing de partição acima estável entre execuções.
É também onde a técnica se vende abaixo do que entrega. A seed fixa as escolhas do simulador, não as do seu programa. Se o código chama std::time::Instant::now, ele vê tempo real do relógio de parede e o teste deixa de ser determinístico. O mesmo vale para getrandom, quanta, rdtsc, qualquer coisa que busca entropia ou tempo fora do tokio. O writeup do time da S2 sobre combinar turmoil com uma camada de shimming libc (a derivada deles do madsim chamada mad-turmoil) foi o primeiro lugar onde vi isso explicado de forma clara: o ecossistema Rust tem tantas crates transitivas puxando tempo e aleatoriedade por fora que "determinístico" precisa de uma costura no nível do libc, não só no nível do runtime.
Para o meu próprio código, mantenho a regra curta. Passe um Clock e um Rng por trait, troque-os por versões determinísticas nos testes, nunca chame Instant::now de qualquer lugar que cargo test alcance. Não pega todo vazamento — uma iteração de HashMap transitiva ainda morde — mas é os 80% mais baratos do ganho.
O que isso não é
Turmoil não é chaos engineering. Chaos roda contra sistemas com formato de produção e expõe problemas com o deployment, com o monitoramento e com as pessoas. DST roda em um único processo em uma única thread e expõe problemas com o protocolo — o tipo de problema em que a pergunta inteira é "o que acontece se essas duas mensagens chegarem nesta ordem exata e este pacote for descartado primeiro?". Os dois respondem perguntas diferentes. O farm VOPR do projeto TigerBeetle roda em 1.000 cores 24/7 e acelera o tempo simulado em aproximadamente 700x — eles pegam bugs de protocolo antes dos deploys e depois rodam chaos real em pré-produção para todo o resto.
Eu também evitaria recorrer a ele para código que não tenha uma costura clara de rede. Se um "sistema distribuído" é um pool de conexões Postgres mais um handler HTTP, o tempo vai para encaixar nas traits de turmoil::net, não para debugar flake. DST justifica seu peso quando um serviço tem seu próprio protocolo de mensagens, múltiplos participantes e correção dependente de timing — replicação, eleição de líder, retry-com-dedupe, roteamento de sessão sticky, transações distribuídas.
Uma pequena lista de vazamentos para tampar antes de confiar na seed
- Chamadas diretas para
std::time::Instant::nowestd::time::SystemTime::nowdentro de qualquer caminho que o teste alcance. Elas passam por cima do tempo mockado do turmoil. - Ordem de iteração de
HashMap. O hasher do Rust tem seed doOsRngpor construção. Use umBTreeMap, umHashMapcom hasher determinístico, ou ordene antes de observar. getrandom,OsRng, qualquer coisa que puxe entropia do/dev/urandom. Plumbe o RNG. Não deixe que dependências criem o seu próprio.- Qualquer coisa que crie threads do SO. O turmoil agenda tasks em sua única thread. Threads fora dela são invisíveis para o simulador.
- Uso direto de
tokio::netem vez deturmoil::net. A costura em tempo de compilação é desconfortável — o padrão usual é um blococfg(feature = "turmoil")— mas é a linha entre uma rede simulada e chamadas de rede reais escapando do teste.
Quando recorrer ao turmoil: um serviço com seu próprio protocolo, onde retries, partições ou janelas de timing já queimaram um teste flaky que ninguém quer debugar. Quando passar reto: serviços simples de request/response que cabem em um mock e alguns #[tokio::test], ou qualquer coisa em que a classe de bug viva no formato do tráfego de produção e não na ordenação de mensagens.
A conclusão que eu escreveria em um post-it: um teste flaky de sistemas distribuídos não é científico, mas só porque eu ainda não fixei sua seed. O trabalho para tornar um serviço executável sob um simulador com seed é real, e nem todos os serviços pagam de volta. Os que pagam retornam algo melhor que "o teste passou" — eles retornam "o teste falhou na seed 7 e vai continuar falhando na seed 7 até o protocolo estar certo".
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
Execução Durável Não É Sobre Agentes — É Sobre Workflows de Backend com Replay
Cheguei aos runtimes de execução durável pela hype dos agentes, mas a restrição que surpreende todo mundo é o determinismo no replay. Estas são minhas anotações trabalhando uma reconciliação de pagamentos de seis passos como um workflow do Restate em TypeScript — a linha que quebrou o replay, o modelo mental que consertou, e os trade-offs que vêm com o padrão.
Idempotência É um Protocolo, Não uma Chave
Na primeira vez em que entreguei idempotência como um header UUID e uma consulta no Redis, uma cobrança duplicada escapou uma semana depois. Estas são minhas notas sobre tratar idempotência como um protocolo de quatro partes — deduplicação, determinismo, segurança concorrente, propagação downstream — com uma implementação mínima em Kotlin mais Postgres que se mantém firme sob retry.
O que `dbos ontime` realmente está perguntando: construindo um cron distribuído com leases do etcd em Go
Uma busca 0-click por `dbos ontime` apareceu no meu Search Console na semana passada. Quem digitou isso não está perguntando sobre DBOS — está perguntando como rodar um job a cada minuto, exatamente uma vez, em uma frota de máquinas. Pelas minhas próprias anotações, um lease do etcd, o pacote `concurrency.Election` e um fencing token cobrem esse caso em menos de 100 linhas de Go, sem precisar trazer um workflow engine.