Artifact cd23760fe6584df2cd779f81eb22333e7f0c2ab4c4882e3a6fe5e2979ac9f469:
- File
db/driver/ncruces/ncruces_test.go
— part of check-in
[202576c58c]
at
2026-05-13 19:09:49
on branch trunk
— fix: DSN _txlock=immediate to serialize writers at BEGIN (#33) (#34)
Two concurrent libfossil.Repo handles on the same SQLite file fail their writes with SQLITE_BUSY immediately, well under the configured busy_timeout=5000 window. Reproduces ~50% of the time on contended manifest.Checkin paths.
Root cause: db.WithTx opens BEGIN DEFERRED. Under WAL, a deferred transaction acquires SHARED on first read, then upgrades SHARED→RESERVED on first write. If two connections race that upgrade, SQLite's deadlock-avoidance returns SQLITE_BUSY *immediately*, bypassing busy_timeout entirely (documented behavior, sqlite.org/c3ref/busy_timeout.html).
Fix: prepend _txlock=immediate to the DSN in both modernc and ncruces drivers. Every BEGIN now acquires RESERVED upfront, so the second writer blocks at BEGIN — where busy_timeout *does* apply — and the two transactions serialize cleanly.
Regression tests added to both driver packages: - TestBuildDSN_TxlockImmediate covers the DSN shape across empty/non-empty pragma maps. - TestConcurrentWritersDoNotRace reproduces the issue-#33 contention pattern (two conns, SELECT then INSERT under DEFERRED) over 25 iterations and asserts zero SQLITE_BUSY. Without the fix, this test fails 25/25; with the fix, 0/25.
Closes #33. (user: [email protected], size: 2911) [annotate] [blame] [check-ins using]
//go:build !js package ncruces import ( "context" "database/sql" "path/filepath" "strings" "sync" "testing" "time" "github.com/danmestas/libfossil/db" ) // TestBuildDSN_TxlockImmediate verifies the registered DSN builder always // emits _txlock=immediate, regardless of how many pragmas are merged in. // Mirrors the modernc driver test for the SHARED→RESERVED upgrade race // documented in libfossil issue #33. func TestBuildDSN_TxlockImmediate(t *testing.T) { t.Parallel() cases := []struct { name string pragmas map[string]string }{ {"no pragmas", nil}, {"empty pragmas", map[string]string{}}, {"with pragmas", map[string]string{"journal_mode": "WAL", "busy_timeout": "5000"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() dsn := buildDSN("/tmp/x.db", tc.pragmas) if !strings.Contains(dsn, "_txlock=immediate") { t.Fatalf("buildDSN missing _txlock=immediate: %q", dsn) } }) } } // TestConcurrentWritersDoNotRace exercises the failure mode from issue #33 // against the ncruces driver. See the modernc-side test for the full // motivation. With _txlock=immediate in the DSN, both writers serialize // at BEGIN and neither fails with SQLITE_BUSY. func TestConcurrentWritersDoNotRace(t *testing.T) { t.Parallel() const iterations = 25 path := filepath.Join(t.TempDir(), "concurrent.db") dsn := buildDSN(path, db.DefaultPragmas()) dbA, err := sql.Open("sqlite3", dsn) if err != nil { t.Fatalf("open A: %v", err) } defer dbA.Close() dbB, err := sql.Open("sqlite3", dsn) if err != nil { t.Fatalf("open B: %v", err) } defer dbB.Close() if _, err := dbA.Exec("CREATE TABLE t(x INTEGER)"); err != nil { t.Fatalf("create table: %v", err) } var ok, fail int var failErrs []error for range iterations { var wg sync.WaitGroup wg.Add(2) errs := make(chan error, 2) begin := make(chan struct{}) work := func(d *sql.DB, val int) { defer wg.Done() <-begin tx, err := d.BeginTx(context.Background(), nil) if err != nil { errs <- err return } var n int if err := tx.QueryRow("SELECT count(*) FROM t").Scan(&n); err != nil { _ = tx.Rollback() errs <- err return } time.Sleep(5 * time.Millisecond) if _, err := tx.Exec("INSERT INTO t VALUES (?)", val); err != nil { _ = tx.Rollback() errs <- err return } errs <- tx.Commit() } go work(dbA, 1) go work(dbB, 2) close(begin) wg.Wait() close(errs) for e := range errs { if e == nil { ok++ } else { fail++ failErrs = append(failErrs, e) } } } if fail != 0 { var sample error if len(failErrs) > 0 { sample = failErrs[0] } t.Fatalf("expected zero SQLITE_BUSY across %d iterations, got fail=%d ok=%d; first error: %v", iterations, fail, ok, sample) } if ok != 2*iterations { t.Fatalf("expected ok=%d, got %d", 2*iterations, ok) } }