De 16 jaar oude WAL-reset bug in SQLite: wat je .NET-app moet doen

Tailscale spoorde een corruptiebug op die zestien jaar in SQLite's WAL-checkpoint zat. Hij verdwijnt rijen zonder de structuur te breken, dus je integrity check merkt het niet. Wat dat betekent voor je .NET-app, en hoe je het detecteert.

Jean-Pierre Broeders

Freelance .NET Developer

13 augustus 20268 min. leestijd
De 16 jaar oude WAL-reset bug in SQLite: wat je .NET-app moet doen

Er stond deze week een post bovenaan Hacker News die me even stil hield. Tailscale heeft een bug in SQLite opgespoord die zestien jaar in de code zat. Geen crash, geen foutmelding, geen stacktrace. Gewoon rijen die er de ene keer wel waren en de andere keer niet, en niets in het systeem dat het merkte.

De bug zit in het WAL-checkpoint-proces. Onder de juiste, zeldzame timing reset SQLite het write-ahead log op een manier waarbij commits die al in het WAL stonden verloren gaan voordat ze in het hoofdbestand landen. Het resultaat is data die verdwijnt. En omdat de rest van de databasestructuur intact blijft, ziet je database er kerngezond uit terwijl er rijen ontbreken.

Gefixt in SQLite 3.51.3. Maar de fix is het minst interessante deel van dit verhaal.

Wat er precies misging

Write-ahead logging werkt zo: nieuwe schrijfacties gaan eerst naar een apart WAL-bestand, niet direct naar de database. Lezers zien de oude toestand, schrijvers hangen wijzigingen achteraan in het log, en op gezette tijden verplaatst een checkpoint die wijzigingen naar het hoofddatabasebestand. Daarna kan het WAL worden gereset en begint het opnieuw.

In dat resetmoment zat het gat. Bij een specifieke samenloop van een checkpoint en gelijktijdige verbindingen kon SQLite het WAL terugzetten terwijl er nog frames in stonden die nog niet naar de database waren geschreven. Die frames waren daarna weg. De transactie was volgens de applicatie gecommit, want COMMIT gaf netjes succes terug. Op schijf was hij nooit aangekomen.

Het venster is minuscuul. Je hebt gelijktijdige verbindingen nodig, een checkpoint op precies het verkeerde moment, en dan nog geluk. Daarom overleefde het zestien jaar in een van de best geteste stukken software die er bestaan.

Waarom je testsuite dit niet ving

SQLite draait bij elke release meer dan honderd miljoen testcases. Regel voor regel coverage, fuzzing, crash-recovery-tests, stroomuitval-simulaties. En toch glipte dit erdoorheen, zestien jaar lang.

Dat komt doordat de bug alleen bestaat in de wisselwerking tussen timing en concurrency. Een deterministische test die een checkpoint uitvoert en de rijen naleest, ziet niets. Je moet twee verbindingen op microseconden van elkaar laten botsen om het venster te raken, en zelfs dan is het kans. Dit soort fouten leeft in de ruimte tussen threads, en die ruimte is bijna oneindig groot.

De les is niet dat SQLite onbetrouwbaar is. SQLite is waarschijnlijk betrouwbaarder dan de code die jij en ik eromheen schrijven. De les is dat je onderste laag, de laag waar je nooit naar kijkt omdat hij altijd werkt, ook een failure mode heeft die je nog niet bent tegengekomen.

Wat dit betekent voor je .NET-app

Als je Microsoft.Data.Sqlite gebruikt met WAL-mode, en dat doe je waarschijnlijk in productie, dan is de eerste vraag simpel: welke SQLite-versie zit er in je container?

using var connection = new SqliteConnection("Data Source=app.db");
connection.Open();

using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT sqlite_version()";
var version = (string)cmd.ExecuteScalar()!;

logger.LogInformation("SQLite-versie in gebruik: {Version}", version);

De versie die Microsoft.Data.Sqlite meebrengt hangt aan de SQLitePCLRaw-bundle in je dependency-tree, niet aan de SQLite die toevallig op je host staat. Draai die query dus op een echte verbinding in je draaiende app, niet in een shell op de server. Zit je onder 3.51.3, dan update je het NuGet-pakket en herdeploy je. Dat is de hele fix.

Gebruik je geen WAL-mode maar de standaard DELETE journal mode? Dan raakt deze bug je niet. Hij zit puur in het WAL-checkpoint-pad. WAL blijft trouwens de betere keuze voor apps met gelijktijdige lezers, dus het advies is niet om WAL te laten vallen. Het advies is: update en houd WAL aan.

Detectie is het echte werk

Hier wordt het ongemakkelijk. Je eerste reflex is PRAGMA integrity_check, en die reflex schiet net tekort. Integrity check verifieert de B-tree-consistentie en de paginastructuur. Bij deze bug klopt de structuur helemaal. Er ontbreken alleen rijen. Een structureel gave database met een gat erin passeert de check zonder klagen.

Wat wel werkt is verificatie op applicatieniveau. Als je kritieke records een checksum of een verwacht aantal hebben dat je onafhankelijk kunt narekenen, dan heb je een detectiemechanisme dat losstaat van SQLite's eigen boekhouding. Hang dat aan je health-check-endpoint:

app.MapHealthChecks("/health/db", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("db")
});

// Registratie
services.AddHealthChecks().AddCheck<SqliteIntegrityCheck>(
    "sqlite-integrity", tags: new[] { "db" });

public sealed class SqliteIntegrityCheck : IHealthCheck
{
    private readonly SqliteConnection _connection;

    public SqliteIntegrityCheck(SqliteConnection connection)
        => _connection = connection;

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken ct = default)
    {
        using var cmd = _connection.CreateCommand();
        cmd.CommandText = "PRAGMA integrity_check";
        var result = (string?)await cmd.ExecuteScalarAsync(ct);

        if (result != "ok")
            return HealthCheckResult.Unhealthy($"integrity_check: {result}");

        // De echte controle: reken je eigen invariant na.
        cmd.CommandText = "SELECT COUNT(*) FROM orders WHERE status = 'paid'";
        var paidOrders = (long)(await cmd.ExecuteScalarAsync(ct))!;

        return paidOrders >= await ExpectedMinimumAsync(ct)
            ? HealthCheckResult.Healthy()
            : HealthCheckResult.Degraded("Aantal betaalde orders lager dan verwacht");
    }
}

De structurele check laat ik erin staan, want gratis is gratis. De regel die er echt toe doet is de tweede: een invariant uit je eigen domein die je onafhankelijk kunt bevestigen. Betaalde orders die verdwijnen, saldi die niet optellen, een teller die daalt terwijl hij alleen mag stijgen. Dat is wat een gemiste commit zichtbaar maakt.

De WAL-bestandsgrootte in de gaten houden

SQLite triggert standaard een automatische checkpoint zodra het WAL-bestand 1000 pagina's bereikt. Bij de standaard paginagrootte van 4096 bytes is dat ongeveer 4 MB. Je kunt dat gedrag uitlezen en bijsturen:

using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA wal_autocheckpoint";
var pages = (long)cmd.ExecuteScalar()!;
logger.LogInformation("Auto-checkpoint bij {Pages} WAL-pagina's", pages);

Een WAL-bestand dat blijft groeien en nooit terugvalt, betekent dat checkpoints niet slagen. Dat is precies het soort signaal dat je wilt zien voordat het misgaat. Meet de grootte van het -wal-bestand op een interval en zet er een alert op. Het kost je een cronjob en het geeft je zicht op een laag die je anders nooit ziet.

Toen Tailscale dit onderzocht, deden ze het met een grondige postmortem, degelijke logging en direct contact met het SQLite-team. Dat is geen luxe-aanpak. Dat is de minimale aanpak voor elk systeem dat je productiedata bewaart.

De bredere les

Zestien jaar. Meer dan honderd miljoen testcases per release. En de bug wachtte gewoon op de juiste timing.

Dat is geen reden om SQLite te wantrouwen. Het is een reden om je infrastructuurlaag nooit als gegeven te nemen. Elk systeem dat data persistent maakt heeft een failure mode die jij nog niet hebt gezien. De enige vraag die telt is of je de observability hebt om hem te vinden voordat je klanten dat doen.

Veelgestelde vragen over de SQLite WAL-reset bug en .NET

Welke versie van SQLite bevat de fix voor de WAL-reset bug? SQLite 3.51.3 bevat de fix. Voer SELECT sqlite_version() uit op een openstaande verbinding om te zien welke versie jouw applicatie werkelijk gebruikt, want dat is de versie in je SQLitePCLRaw-bundle en niet die op de host. Zit je onder 3.51.3, dan update je het Microsoft.Data.Sqlite NuGet-pakket en herdeploy je.

Kan ik deze bug tegenkomen als ik WAL-mode niet gebruik? Nee. De bug zit specifiek in het WAL-checkpoint-proces, dus applicaties op de standaard DELETE journal mode zijn niet kwetsbaar voor dit probleem. WAL-mode blijft aanbevolen voor productie-apps met gelijktijdige lezers, dus het advies blijft simpel: update naar 3.51.3 en houd WAL aan.

Is PRAGMA integrity_check betrouwbaar om deze corruptie te detecteren? Niet voor dit geval. De bug verwijdert rijen maar laat de structurele integriteit intact, en integrity_check verifieert B-tree-consistentie en paginaintegriteit, niet of er rijen ontbreken. Een checksum of een narekenbare invariant op je kritieke records is een onafhankelijk detectiemechanisme dat wel aanslaat.

Hoe weet ik of mijn app automatisch WAL-checkpoints uitvoert? Standaard triggert SQLite een automatische PASSIVE checkpoint zodra het WAL-bestand 1000 pagina's bereikt, wat bij de standaard paginagrootte van 4096 bytes neerkomt op ongeveer 4 MB. Je leest het gedrag uit met PRAGMA wal_autocheckpoint, en door de grootte van het -wal-bestand op een interval te meten zie je of checkpoints daadwerkelijk slagen.

Verder lezen


Conclusie: Update naar SQLite 3.51.3, hang een narekenbare invariant aan je health-check-endpoint en meet de WAL-bestandsgrootte. De bug is gepatcht, maar de les is groter: observability en periodieke integriteitscontrole zijn geen extra's in een systeem dat productiedata bewaart.

Bronnen: We tracked down the 16-year-old WAL-reset SQLite bug · Hacker News-discussie.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op