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:
(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
Summary
On Windows, using a single
*sql.DBwith more than one open connection(
SetMaxOpenConns(N>1)) against a WAL database reliably corrupts thedatabase file under a concurrent write workload. The writers use ordinary
BEGIN DEFERREDtransactions (so they serialize on the WAL write lock viabusy_timeout) — noBEGIN 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
go1.26.4github.com/ncruces/go-sqlite3v0.35.1github.com/ncruces/go-sqlite3-wasm/v3v3.1.35302(indirect)Reproducer
A single
*sql.DB,SetMaxOpenConns(64), WAL,_txlock=deferred, 64 goroutineseach doing
parent+childinserts in a transaction, with an occasionalwal_checkpoint(TRUNCATE); then close, cold-reopen,PRAGMA integrity_check.go.mod:main.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
RESULT=CLEAN(this is a normal multi-connection WAL usage pattern).RESULT=CORRUPTon every run (3/3 here). The symptomvaries run to run, all consistent with on-disk corruption:
sqlite3: file is not a databasesqlite3: database disk image is malformedSQLITE_PROTOCOL("locking protocol")integrity_checkreports b-tree/index damage, e.g.:RESULT=CLEANon the same code (control).Observations / narrowing
SetMaxOpenConns(>1)writing to the same WAL DB; reducing the pool/worker count lowers the rate.
journal_mode(wal)+BEGIN DEFERRED.wal_checkpoint(TRUNCATE)removed.(3.53.2) as native C — using SQLite's own
os_win.cVFS — and ran an equivalent64-thread
BEGIN DEFERRED+ plain-WAL + checkpoint workload on the same machine.It completes with
integrity_check = okevery time. Combined with the Linuxresult, 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 invfs/os_windows.go(LockFileEx/UnlockFileEx) andvfs/shm_windows.go(memory-mapped
-shm) look like the likely culprits — the shared cross-connectionstate that native
os_win.chandles but the Go port appears to mishandle underconcurrency 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