Skip to content

Windows: concurrent WAL writes across pooled connections corrupt the database #404

Description

@franchb

Summary

On Windows, using a single *sql.DB with more than one open connection
(SetMaxOpenConns(N>1)) against a WAL database reliably corrupts the
database file under a concurrent write workload. The writers use ordinary
BEGIN DEFERRED transactions (so they serialize on the WAL write lock via
busy_timeout) — no BEGIN CONCURRENT, nothing exotic.

The same program is clean on Linux and clean as native C SQLite (see
Observations), which points at the Go Windows VFS rather than SQLite itself.

Environment

  • OS: Windows Server 2022 (build 10.0.20348), amd64, 4 vCPU
  • Go: go1.26.4
  • github.com/ncruces/go-sqlite3 v0.35.1
  • github.com/ncruces/go-sqlite3-wasm/v3 v3.1.35302 (indirect)

Reproducer

A single *sql.DB, SetMaxOpenConns(64), WAL, _txlock=deferred, 64 goroutines
each doing parent+child inserts in a transaction, with an occasional
wal_checkpoint(TRUNCATE); then close, cold-reopen, PRAGMA integrity_check.

go.mod:

module walrepro

go 1.26

require github.com/ncruces/go-sqlite3 v0.35.1

main.go:

package main

import (
        "context"
        "crypto/rand"
        "database/sql"
        "fmt"
        "os"
        "path/filepath"
        "sync"

        "github.com/ncruces/go-sqlite3/driver"
)

const (
        workers   = 64
        iters     = 2000
        blobBytes = 8192
        ckptEvery = 25
)

const schema = `
CREATE TABLE parent(id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL UNIQUE);
CREATE TABLE child(id INTEGER PRIMARY KEY AUTOINCREMENT,
    parent_id INTEGER NOT NULL REFERENCES parent(id) ON DELETE CASCADE, data BLOB NOT NULL);
CREATE INDEX child_parent_idx ON child(parent_id);
`

func dsn(path string) string {
        return "file:" + path +
                "?_pragma=busy_timeout(10000)&_pragma=journal_mode(wal)&_pragma=synchronous(normal)&_txlock=deferred"
}

func main() {
        dir, err := os.MkdirTemp("", "walrepro")
        must(err)
        defer os.RemoveAll(dir)
        path := filepath.Join(dir, "repro.db")

        db, err := driver.Open(dsn(path))
        must(err)
        db.SetMaxOpenConns(workers)
        _, err = db.Exec(schema)
        must(err)

        blob := make([]byte, blobBytes)
        _, _ = rand.Read(blob)

        var wg sync.WaitGroup
        errCh := make(chan error, workers)
        for w := 0; w < workers; w++ {
                wg.Add(1)
                go func(gid int) {
                        defer wg.Done()
                        for i := 0; i < iters; i++ {
                                if err := doTx(db, gid, i, blob); err != nil {
                                        errCh <- fmt.Errorf("worker %d iter %d: %w", gid, i, err)
                                        return
                                }
                                if i%ckptEvery == 0 {
                                        if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
                                                errCh <- fmt.Errorf("checkpoint: %w", err)
                                                return
                                        }
                                }
                        }
                }(w)
        }
        wg.Wait()
        close(errCh)
        workErr := <-errCh // first error, if any (nil when none)

        must(db.Close())

        db2, err := driver.Open(dsn(path))
        if err != nil {
                fmt.Printf("RESULT=CORRUPT reopen: %v (workErr=%v)\n", err, workErr)
                os.Exit(1)
        }
        defer db2.Close()
        db2.SetMaxOpenConns(1)
        var first string
        if err := db2.QueryRow("PRAGMA integrity_check").Scan(&first); err != nil {
                fmt.Printf("RESULT=CORRUPT integrity_check: %v (workErr=%v)\n", err, workErr)
                os.Exit(1)
        }
        if first != "ok" {
                fmt.Printf("RESULT=CORRUPT integrity_check=%q (workErr=%v)\n", first, workErr)
                os.Exit(1)
        }
        if workErr != nil {
                fmt.Printf("RESULT=CORRUPT workErr=%v\n", workErr)
                os.Exit(1)
        }
        fmt.Println("RESULT=CLEAN")
}

func doTx(db *sql.DB, gid, i int, blob []byte) error {
        ctx := context.Background()
        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
                return err
        }
        var pid int64
        if err := tx.QueryRowContext(ctx,
                "INSERT INTO parent(hash) VALUES(?) RETURNING id",
                fmt.Sprintf("%d-%d", gid, i)).Scan(&pid); err != nil {
                _ = tx.Rollback()
                return err
        }
        if _, err := tx.ExecContext(ctx,
                "INSERT INTO child(parent_id, data) VALUES(?, ?)", pid, blob); err != nil {
                _ = tx.Rollback()
                return err
        }
        return tx.Commit()
}

func must(err error) {
        if err != nil {
                panic(err)
        }
}

Run:

go mod tidy
go run .

(A clean run writes ~1 GiB to the temp dir before cleanup; point TMPDIR/%TEMP%
at a volume with room.)

Expected vs actual

  • Expected: RESULT=CLEAN (this is a normal multi-connection WAL usage pattern).
  • Actual on Windows: RESULT=CORRUPT on every run (3/3 here). The symptom
    varies run to run, all consistent with on-disk corruption:
    • sqlite3: file is not a database
    • sqlite3: database disk image is malformed
    • mid-workload SQLITE_PROTOCOL ("locking protocol")
    • integrity_check reports b-tree/index damage, e.g.:
      wrong # of entries in index child_parent_idx
      Tree 5 page 18419 cell 2: overflow list length is 1 but should be 2
      Tree 5 page 18402: btreeInitPage() returns error code 11
      Tree 2 page 16435 cell 138: Rowid 8912 out of order
      Page 7556: never used
      ...
      
  • Linux: RESULT=CLEAN on the same code (control).

Observations / narrowing

  • Requires multiple concurrent connections. The trigger is SetMaxOpenConns(>1)
    writing to the same WAL DB; reducing the pool/worker count lowers the rate.
  • Not WAL2 / BEGIN CONCURRENT. Plain journal_mode(wal) + BEGIN DEFERRED.
  • Not the checkpoint. It also reproduces with the interleaved
    wal_checkpoint(TRUNCATE) removed.
  • Timing-sensitive. Higher core counts / more workers raise the reproduction rate.
  • Native C SQLite is clean. As a control I compiled the same SQLite version
    (3.53.2) as native C — using SQLite's own os_win.c VFS — and ran an equivalent
    64-thread BEGIN DEFERRED + plain-WAL + checkpoint workload on the same machine.
    It completes with integrity_check = ok every time. Combined with the Linux
    result, this suggests the defect is in the Go Windows VFS layer rather than
    SQLite core.

Suspected area

Given the above, the wal-index (-shm) coordination and/or file locking in
vfs/os_windows.go (LockFileEx/UnlockFileEx) and vfs/shm_windows.go
(memory-mapped -shm) look like the likely culprits — the shared cross-connection
state that native os_win.c handles but the Go port appears to mishandle under
concurrency on Windows. This may be related to earlier Windows-WAL work
(e.g. #200, #252, #253, #254).

Happy to test patches, try build tags, or provide additional traces / captured
corrupt databases

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions