Shopping cart

Subtotal $0.00

View cartCheckout

Building better devs

TnewsTnews
  • Home
  • Banco de Dados
  • Bug de 16 Anos no SQLite: Como a Tailscale Caçou uma Corrupção Invisível
Banco de Dados

Bug de 16 Anos no SQLite: Como a Tailscale Caçou uma Corrupção Invisível

Email : 25

Um banco de dados “incorruptível” corrompendo em produção

O SQLite tem 92 milhões de linhas de testes. Noventa e dois milhões. É o banco de dados mais testado do planeta, roda em literalmente bilhões de dispositivos, e a comunidade inteira confia nele como se fosse lei da física. Então imagine a cara do time de infra da Tailscale quando, de repente, dados começaram a sumir sem nenhum erro, nenhum log, nenhum warning.

Writes simplesmente evaporavam. Uma transação confirmava com sucesso, e na leitura seguinte o dado não existia mais. Fantasma puro.

Esse mistério levou seis meses, 19 incidentes de corrupção e uma investigação digna de série policial para ser resolvido. No final, o culpado era um bug de race condition no WAL do SQLite que estava escondido no código-fonte há 16 anos. Desde a versão 3.7.0, lançada em julho de 2010.

Eu já vi muito post-mortem, mas esse é especial. Vamos destrinchar.

O que é o WAL (e por que todo mundo usa)

Antes de entrar no bug, precisa entender o mecanismo que ele quebrou.

WAL significa Write-Ahead Logging. Em vez de escrever direto no arquivo principal do banco, o SQLite joga as alterações primeiro num arquivo separado (o WAL file). Periodicamente, um processo chamado checkpoint copia essas páginas acumuladas de volta pro arquivo principal.

A vantagem? Performance absurda. Leituras e escritas podem acontecer em paralelo sem bloqueio, porque leitores olham tanto o arquivo principal quanto o WAL. É o modo padrão pra qualquer aplicação séria que usa SQLite hoje.

O fluxo simplificado:


Write → WAL file (append)
           ↓
Checkpoint → copia páginas do WAL → arquivo principal (.db)
           ↓
WAL reset → limpa o WAL pra começar de novo

O problema mora no terceiro passo.

Pra ativar WAL no SQLite, basta um pragma:


PRAGMA journal_mode=WAL;

Simples. E por baixo dos panos, o SQLite mantém duas variáveis críticas na shared memory:

  • mxFrame: o número total de frames (páginas) no WAL
  • nBackfill: quantas páginas já foram copiadas pro banco principal via checkpoint

O checkpoint lê mxFrame, copia as páginas até esse número, e atualiza nBackfill. Quando nBackfill == mxFrame, o WAL pode ser resetado (zerado) com segurança. Pelo menos em teoria.

Como a Tailscale usa SQLite (e por que isso importa)

A Tailscale usa SQLite no control plane da rede. Metadados sobre tailnets, dispositivos, configurações, políticas de ACL. Não é tráfego de rede, são dados de controle que definem quem pode acessar o quê na sua rede privada.

Quando uma corrupção acontecia nesse banco, o shard inteiro do control plane caía. Todos os dispositivos naquele shard perdiam acesso à rede. Não importava se só um cliente tinha dados corrompidos, o impacto era coletivo.

Pra garantir backups rápidos e consistentes, o time implementou checkpoints agressivos e manuais. Em vez de deixar o SQLite decidir quando fazer checkpoint (o padrão, que roda automaticamente a cada 1000 páginas acumuladas), eles forçavam o processo programaticamente com muito mais frequência.


// Checkpoint manual via API
sqlite3_wal_checkpoint_v2(db, NULL, SQLITE_CHECKPOINT_TRUNCATE, &nLog, &nCkpt);

Isso é perfeitamente válido pela API. Está na documentação. Não é um hack. Mas essa agressividade no checkpoint acabou sendo o gatilho pra um bug que ninguém encontrou em 16 anos justamente porque a maioria dos usuários deixa o checkpoint no automático, com frequência muito menor.

A anatomia do bug

O bug é uma race condition entre duas operações:

  1. Uma thread fazendo checkpoint (copiando páginas do WAL pro banco principal)
  2. Outra thread fazendo write (adicionando novas páginas ao WAL)

Quando as duas acontecem simultaneamente, em um timing muito específico, o processo de checkpoint fica confuso. Ele atualiza variáveis de controle na shared memory (mxFrame e nBackfill) na ordem errada, achando que certas páginas já foram copiadas pro banco principal quando na verdade ainda estavam só no WAL.

Resultado: o WAL é resetado (limpo), mas aquelas páginas nunca chegaram ao arquivo principal. Os dados simplesmente desaparecem.


Thread A (checkpoint):          Thread B (write):
  ├─ Lê mxFrame = 100           │
  ├─ Copia páginas 1..100       │
  │                              ├─ Escreve páginas 101..105
  │                              ├─ Atualiza mxFrame = 105
  ├─ Atualiza nBackfill = 100   │
  ├─ "Checkpoint completo"      │
  │                              │
  └─ WAL reset!                 │
     (limpa TUDO, incluindo     │
      páginas 101..105 que      │
      NUNCA foram copiadas)     │

O detalhe cruel: nenhum erro é retornado. O SQLite acha que deu tudo certo. A aplicação acha que deu tudo certo. O sqlite3_wal_checkpoint_v2 retorna SQLITE_OK. Só na próxima leitura você descobre que o dado sumiu. E boa sorte reproduzindo isso.

Tem uma sutileza extra que torna esse bug particularmente traiçoeiro. O checkpoint olha nBackfill e mxFrame pra decidir se pode resetar o WAL. Se nBackfill >= mxFrame no momento da verificação, ele reseta. Mas entre o momento que a Thread A leu mxFrame = 100 e o momento que ela decidiu resetar, a Thread B já escreveu novas páginas e atualizou mxFrame = 105. O reset apaga tudo, incluindo as 5 páginas que nunca passaram pelo checkpoint.

Seis meses de investigação

O time da Tailscale encontrou o primeiro caso de corrupção e começou a investigar. Nos meses seguintes, foram 19 incidentes. O problema? Nenhum padrão óbvio.

Não era um shard específico. Não era um cliente específico. Não era um horário. Não era carga alta nem baixa. A corrupção aparecia de forma completamente aleatória, o que é a assinatura clássica de uma race condition: determinística no nível da CPU, mas imprevisível no nível da aplicação.

Cada incidente derrubava um shard inteiro do control plane. A equipe precisava restaurar backup, replayar logs e rezar pra não corromper de novo. Com 19 incidentes em seis meses, a pressão era real.

A sacada do transaction log

Pra reduzir o tempo de recovery, a equipe construiu um pipeline de logging que gravava todas as modificações do banco num arquivo separado, fora do SQLite. Se algo corrompesse, era só pegar o último backup limpo e replayar as transações.

Funcionou. Até que não funcionou mais.

Em dois incidentes, o replay das transações contra backups limpos falhou. Dados que o log dizia terem sido escritos simplesmente não apareciam no banco reconstruído. O log de transações mostrava: “INSERT confirmado, COMMIT retornou OK”. O banco reconstruído mostrava: nada. A linha não existia.

Esse foi o momento “eureka às avessas”. O problema não era no código da Tailscale, nem no log, nem no backup. Era no SQLite. Uma escrita tinha evaporado entre o COMMIT e o próximo checkpoint, sem gerar nenhum erro em nenhuma camada.

Contrato de suporte profissional

Aqui entra uma decisão que muitos comentaristas no Hacker News elogiaram: a Tailscale paga por suporte profissional do SQLite. Acesso direto aos desenvolvedores core, incluindo o Richard Hipp. Não é open source freeloading, é investimento em infraestrutura crítica.

Com os devs do SQLite envolvidos, começaram a mapear teorias: locks POSIX quebrados, problemas de memória, uso incorreto de multi-threading, filesystem com comportamento não-POSIX. Uma a uma, todas foram descartadas com evidências.

O tmstmpvfs: a arma secreta

Os desenvolvedores do SQLite criaram uma ferramenta específica pra esse caso: um shim de virtual filesystem chamado tmstmpvfs. O código está público no repositório do SQLite.

O VFS (Virtual File System) é a camada de abstração que o SQLite usa pra operações de I/O. O tmstmpvfs funciona como um wrapper em torno do VFS padrão, interceptando cada operação de leitura e escrita e adicionando timestamps, tamanhos de bloco e metadados detalhados.


// Conceito do tmstmpvfs (simplificado)
static int tmstmpWrite(sqlite3_file *pFile, const void *pBuf, int iAmt, sqlite3_int64 iOfst) {
    log_operation("WRITE", iOfst, iAmt, current_timestamp());
    return originalVfs->xWrite(pFile, pBuf, iAmt, iOfst);
}

A Tailscale deployou isso em produção. No próximo incidente de corrupção, finalmente tinham dados suficientes pra identificar a race condition exata.

O logging mostrou algo que as métricas já sugeriam mas ninguém conseguia provar: durante a corrupção, o SQLite reportava ter copiado mais páginas do WAL do que realmente existiam. O checkpoint dizia “copiei 100 páginas” quando o WAL só tinha 95. As cinco páginas extras eram as que tinham sido escritas por outra thread depois que o checkpoint já tinha lido o mxFrame original.

O fix (surpreendentemente simples)

Depois de seis meses de caçada, o fix foi uma checagem adicional na função de checkpoint. Basicamente: antes de resetar o WAL, verificar se outra thread não o modificou durante o processo.


// Pseudo-código simplificado do fix
// Antes: resetava direto se nBackfill >= mxFrame
// Depois: re-lê mxFrame antes de resetar
int currentMxFrame = read_shared_memory(WAL_MXFRAME);
if (nBackfill >= currentMxFrame && currentMxFrame == originalMxFrame) {
    // Seguro resetar, ninguém escreveu no WAL durante o checkpoint
    wal_reset();
} else {
    // WAL foi modificado, precisa de novo checkpoint
    retry_checkpoint();
}

Uma re-leitura. Era isso que faltava. Dezesseis anos e bilhões de instalações depois, o fix era checar um valor uma segunda vez.

O SQLite lançou o fix inicial na versão 3.51.3. A correção definitiva, com testes de regressão completos, veio na 3.53.0.

92 milhões de testes não foram suficientes

Esse é o ponto que faz qualquer engenheiro de software ficar pensativo.

O SQLite tem a suíte de testes mais agressiva da indústria. Cobertura de branch de 100%. Testes de fuzzing. Testes de injeção de falha em disco. Testes de crash recovery. Testes em dezenas de plataformas e arquiteturas. São tantos testes que rodar a suíte completa leva dias.

E mesmo assim, esse bug viveu 16 anos sem ser detectado.

Por quê? Porque a condição de disparo era tão rara que os desenvolvedores tiveram que escrever código específico pra forçar o timing exato nos testes. Em uso normal, as chances de hit são microscópicas. A Tailscale só encontrou porque fazia checkpoint numa frequência que praticamente ninguém faz.

Isso levanta uma questão séria sobre os limites do testing. Testes provam que bugs conhecidos não voltaram. Não provam que bugs desconhecidos não existem. É a diferença entre “testei tudo que pensei” e “não existe nada que eu não pensei”. Race conditions são o exemplo máximo dessa limitação, porque o espaço de estados possíveis é astronomicamente grande demais pra cobrir com testes sequenciais.

Canonical verificou com TLA+ (e dqlite escapou)

Quando o bug veio a público, a equipe do dqlite (o SQLite distribuído da Canonical, usado no LXD e MicroCloud) quis saber se estavam vulneráveis. Em vez de tentar reproduzir o bug em produção, o que poderia levar meses, usaram verificação formal com TLA+.

TLA+ é uma linguagem de especificação criada por Leslie Lamport que modela sistemas concorrentes matematicamente. Você define os estados possíveis, as transições permitidas, as invariantes que devem ser mantidas, e o model checker (TLC) verifica exaustivamente se alguma combinação de estados viola suas invariantes.

A equipe criou três modelos:

  1. Modelo base do SQLite: operações de append e checkpoint no WAL, com as variáveis mxFrame e nBackfill
  2. Reprodução do bug: o verificador encontrou um contra-exemplo em apenas 20 estados, mostrando exatamente como a race condition corrompia dados
  3. Modelo do dqlite: incluiu o detalhe que diferencia o dqlite, o write lock é mantido durante tanto o append quanto o checkpoint

Modelo Resultado
——– ———–
SQLite padrão Bug reproduzido em 20 estados
dqlite Nenhuma violação encontrada

A razão? O dqlite segura o write lock durante append E checkpoint, impedindo que as duas operações rodem simultaneamente. Mutual exclusion resolve race condition. Básico de concorrência, mas que o SQLite original não aplicava nesse caminho específico porque, na arquitetura original, separar as operações era um requisito de performance.

Isso é verificação formal mostrando valor real em produção. Não é exercício acadêmico. A Canonical respondeu à pergunta “estamos vulneráveis?” com certeza matemática, sem precisar de um único teste de integração.

Quem mais foi afetado?

O bug afetava todas as versões do SQLite de 3.7.0 (2010) até 3.51.2 (janeiro de 2026). Dezesseis anos de código vulnerável. Qualquer aplicação que usa WAL mode com checkpoints manuais e agressivos estava em risco.

Nos fóruns da Plex, usuários reportaram corrupções de banco no Windows que batiam exatamente com o perfil do WAL-reset bug. O Plex Media Server bundla uma versão do SQLite dentro da faixa vulnerável e faz operações frequentes no banco de metadados de mídia.

Outro projeto mencionado como potencialmente afetado é o Litestream, que faz replicação contínua de bancos SQLite manipulando o processo de checkpoint de forma similar à Tailscale. Se você usa Litestream em produção, vale checar a versão do SQLite que está por baixo.

A lição: se você roda SQLite em WAL mode e faz qualquer tipo de manipulação manual de checkpoint, atualize pra 3.53.0 ou superior. Não amanhã. Agora.

O bug secundário (porque nunca é só um)

Quando a Tailscale atualizou pro SQLite 3.52.0 (que incluía o fix do WAL), 13 bancos de dados geraram warnings de corrupção. Pânico instantâneo. “Será que o fix quebrou outra coisa?”

Mas era outro bug, completamente não relacionado. Índices de expressão computada (expression indexes) ficaram inconsistentes. O que aconteceu: a lógica de cálculo de algumas expressões mudou entre versões do SQLite, mas os valores já armazenados no índice não foram recomputados. O banco achava que o índice estava corrompido quando na verdade ele estava só desatualizado.


-- Exemplo de expression index
CREATE INDEX idx_lower_name ON users(LOWER(name));
-- Se LOWER() mudar comportamento entre versões, o índice fica stale

O SQLite 3.53.0 trouxe um mecanismo de self-healing que detecta e reconstrói esses índices automaticamente na primeira query. Se você pular direto pra 3.53.0 (e deveria), nem vai notar esse problema intermediário.

Checklist: você está vulnerável?

Antes de fechar essa aba, passe por essa lista rápida:

Condição Risco
———- ——-
SQLite < 3.53.0 Potencialmente vulnerável
WAL mode ativado Pré-requisito do bug
Checkpoint manual/agressivo Alto risco
Múltiplas threads escrevendo Alto risco
Checkpoint automático padrão Risco baixo (mas não zero)
WAL mode desativado (rollback journal) Não afetado

Pra checar sua versão:


sqlite3 --version
# Ou via Python
python3 -c "import sqlite3; print(sqlite3.sqlite_version)"

Se retornar qualquer coisa abaixo de 3.53.0, atualize. Se você usa uma linguagem que bundla SQLite (Python, por exemplo), cheque se a versão do runtime já inclui o fix.

O que aprender com tudo isso

Essa história tem camadas. Não é só “acharam um bug no SQLite”. É sobre como software que consideramos perfeito ainda nos surpreende depois de 16 anos.

Checkpoints agressivos não são um anti-pattern, mas mudam o perfil de risco. A documentação permite, o comportamento é “suportado”, mas você está exercitando caminhos de código que 99.9% dos usuários nunca tocam. Quanto mais você sai do caminho feliz, mais importa ter observabilidade robusta.

Suporte profissional paga dividendos. A Tailscale investiu em contrato com os devs do SQLite, e isso foi decisivo. Sem acesso direto ao Richard Hipp e equipe, esse bug poderia levar mais seis meses ou mais. Open source gratuito não significa que a manutenção é grátis.

Verificação formal funciona pra sistemas reais. A Canonical provou em horas, com TLA+, algo que testes de integração talvez nunca provassem. Se você tem código concorrente crítico, vale aprender TLA+, Alloy, ou P. O investimento se paga na primeira race condition que você previne em vez de debugar por meses.

Observabilidade salva. Sem o pipeline de transaction logging, a Tailscale ainda estaria tentando entender o que acontecia. E sem o tmstmpvfs, o SQLite team não teria os dados pra isolar a race condition. Instrumentação não é luxo, é seguro.

Rode sqlite3 --version agora. Se for menor que 3.53.0, esse artigo acabou de te poupar seis meses de debugging.

Fonte de inspiração: How Tailscale helped find the SQLite WAL-Reset bug

Leave a Reply

Your email address will not be published. Required fields are marked *

Related Posts