Conexão · Interrompida

Algo não carregou

Parte desta página não chegou até você. Recarregue para tentar novamente — se persistir, verifique sua conexão.

Pular para o conteúdo principal
Backend8 min de leitura

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.

Todos os Posts
2/4

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.

rust
// 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::now e std::time::SystemTime::now dentro 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 do OsRng por construção. Use um BTreeMap, um HashMap com 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::net em vez de turmoil::net. A costura em tempo de compilação é desconfortável — o padrão usual é um bloco cfg(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".

Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.