fix(console): apply durable SQLite settings on every connection
2 files changed,
+227
−16
internal/console/store.go+65 −16internal/console/store_durability_test.go+162 −0
CI passed — run details
diff --git a/internal/console/store.go b/internal/console/store.go| index 62bacd848..564ad7591 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -18,6 +18,7 @@ import ( |
| "errors" |
| "fmt" |
| "log" |
| + "net/url" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -52,29 +53,23 @@ type Store struct { |
| // Open opens (creating if needed) the console DB at path. |
| func Open(path string) (*Store, error) { |
| - if dir := filepath.Dir(path); dir != "." && dir != "" { |
| - if err := os.MkdirAll(dir, 0o755); err != nil { |
| - return nil, err |
| + if !strings.HasPrefix(path, "file:") { |
| + if dir := filepath.Dir(path); dir != "." && dir != "" { |
| + if err := os.MkdirAll(dir, 0o755); err != nil { |
| + return nil, err |
| + } |
| } |
| } |
| - db, err := sql.Open("sqlite", path) |
| + dsn, err := consoleSQLiteDSN(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + db, err := sql.Open("sqlite", dsn) |
| if err != nil { |
| return nil, err |
| } |
| // One writer at a time keeps WAL happy. |
| db.SetMaxOpenConns(1) |
| - // busy_timeout FIRST: journal_mode=WAL takes a lock, and with no timeout |
| - // set yet a concurrent opener fails immediately with SQLITE_BUSY. |
| - for _, p := range []string{ |
| - "PRAGMA busy_timeout=5000", |
| - "PRAGMA journal_mode=WAL", |
| - "PRAGMA synchronous=NORMAL", |
| - } { |
| - if _, err := db.Exec(p); err != nil { |
| - db.Close() |
| - return nil, err |
| - } |
| - } |
| s := &Store{db: db} |
| if err := s.migrate(); err != nil { |
| db.Close() |
| @@ -83,6 +78,60 @@ func Open(path string) (*Store, error) { |
| return s, nil |
| } |
| +// consoleSQLiteDSN puts the console's durability settings on every physical |
| +// connection opened by database/sql. A plain path is first made into a file |
| +// URI so query and fragment characters remain filename bytes rather than DSN |
| +// syntax. Explicit SQLite URIs retain their unrelated parameters. |
| +func consoleSQLiteDSN(path string) (string, error) { |
| + if path == ":memory:" { |
| + q := make(url.Values) |
| + setConsoleSQLitePragmas(q) |
| + return path + "?" + q.Encode(), nil |
| + } |
| + dsn := path |
| + if !strings.HasPrefix(path, "file:") { |
| + dsn = "file:" + (&url.URL{Path: path}).EscapedPath() |
| + } |
| + u, err := url.Parse(dsn) |
| + if err != nil { |
| + return "", err |
| + } |
| + q, err := url.ParseQuery(u.RawQuery) |
| + if err != nil { |
| + return "", err |
| + } |
| + setConsoleSQLitePragmas(q) |
| + u.RawQuery = q.Encode() |
| + return u.String(), nil |
| +} |
| + |
| +func setConsoleSQLitePragmas(q url.Values) { |
| + for _, key := range []string{ |
| + "_busy_timeout", "_timeout", |
| + "_journal_mode", "_journal", |
| + "_synchronous", "_sync", |
| + } { |
| + q.Del(key) |
| + } |
| + pragmas := q["_pragma"][:0] |
| + for _, pragma := range q["_pragma"] { |
| + lower := strings.ToLower(pragma) |
| + if !strings.Contains(lower, "busy_timeout") && |
| + !strings.Contains(lower, "journal_mode") && |
| + !strings.Contains(lower, "synchronous") { |
| + pragmas = append(pragmas, pragma) |
| + } |
| + } |
| + if len(pragmas) == 0 { |
| + q.Del("_pragma") |
| + } else { |
| + q["_pragma"] = pragmas |
| + } |
| + q.Set("_busy_timeout", "5000") |
| + q.Set("_journal_mode", "WAL") |
| + q.Set("_synchronous", "FULL") |
| +} |
| + |
| // Close releases the underlying database handle. |
| func (s *Store) Close() error { return s.db.Close() } |
diff --git a/internal/console/store_durability_test.go b/internal/console/store_durability_test.go| new file mode 100644 |
| index 000000000..297ccd6d1 |
| --- /dev/null |
| +++ b/internal/console/store_durability_test.go |
| @@ -0,0 +1,162 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "database/sql/driver" |
| + "errors" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func assertConsolePragmas(t *testing.T, s *Store, wantJournal string) { |
| + t.Helper() |
| + var busy, synchronous int |
| + var journal string |
| + if err := s.db.QueryRow(`PRAGMA busy_timeout`).Scan(&busy); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.db.QueryRow(`PRAGMA synchronous`).Scan(&synchronous); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.db.QueryRow(`PRAGMA journal_mode`).Scan(&journal); err != nil { |
| + t.Fatal(err) |
| + } |
| + if busy != 5000 || synchronous != 2 || journal != wantJournal { |
| + t.Fatalf("pragmas busy_timeout=%d synchronous=%d journal_mode=%q", busy, synchronous, journal) |
| + } |
| +} |
| + |
| +func TestOpenAppliesDurabilityPragmasToReplacementConnections(t *testing.T) { |
| + s, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { s.Close() }) |
| + assertConsolePragmas(t, s, "wal") |
| + |
| + // Returning ErrBadConn from Raw tells database/sql that this physical |
| + // connection cannot be reused. The next query must open a replacement. |
| + conn, err := s.db.Conn(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + err = conn.Raw(func(any) error { return driver.ErrBadConn }) |
| + if closeErr := conn.Close(); closeErr != nil && !errors.Is(closeErr, sql.ErrConnDone) { |
| + t.Fatal(closeErr) |
| + } |
| + if !errors.Is(err, driver.ErrBadConn) { |
| + t.Fatalf("discard connection: %v", err) |
| + } |
| + assertConsolePragmas(t, s, "wal") |
| + |
| + now := time.Date(2026, 10, 4, 4, 40, 0, 0, time.UTC) |
| + if err := s.CreateAccount(42, "cdef", "Account", overageOwner, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + receipt, err := s.changeOveragePreference(overageChange(42, overageOwner, 0, false, 61), now.Add(time.Second)) |
| + if err != nil || receipt.Revision != 1 || receipt.RequestedOn { |
| + t.Fatalf("replacement-connection OFF receipt=%+v err=%v", receipt, err) |
| + } |
| +} |
| + |
| +func TestOpenOverridesConflictingExplicitURIPragmas(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + query string |
| + }{ |
| + { |
| + name: "shorthand aliases", |
| + query: "_timeout=1&_journal=OFF&_sync=OFF", |
| + }, |
| + { |
| + name: "pragma list", |
| + query: "_pragma=busy_timeout%281%29&_pragma=journal_mode%28OFF%29&_pragma=synchronous%28NORMAL%29", |
| + }, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "console.db") |
| + dsn := "file:" + (&url.URL{Path: path}).EscapedPath() + "?mode=rwc&" + tc.query |
| + s, err := Open(dsn) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { s.Close() }) |
| + assertConsolePragmas(t, s, "wal") |
| + }) |
| + } |
| +} |
| + |
| +func TestOpenEscapesFilenameDSNSyntax(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "console ?#& name.db") |
| + s, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(path); err != nil { |
| + t.Fatalf("database was not created at the literal path %q: %v", path, err) |
| + } |
| +} |
| + |
| +func TestOpenPreservesMemoryDatabase(t *testing.T) { |
| + s, err := Open(":memory:") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { s.Close() }) |
| + assertConsolePragmas(t, s, "memory") |
| + var tables int |
| + if err := s.db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name='accounts'`).Scan(&tables); err != nil { |
| + t.Fatal(err) |
| + } |
| + if tables != 1 { |
| + t.Fatalf("migrations in memory: accounts tables=%d", tables) |
| + } |
| +} |
| + |
| +func TestOpenPreservesExplicitFileURI(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "uri ?#& name.db") |
| + dsn := "file:" + (&url.URL{Path: path}).EscapedPath() + "?mode=rwc&cache=private&_foreign_keys=on&_pragma=cache_size%28123%29" |
| + s, err := Open(dsn) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + assertConsolePragmas(t, s, "wal") |
| + var foreignKeys, cacheSize int |
| + if err := s.db.QueryRow(`PRAGMA foreign_keys`).Scan(&foreignKeys); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.db.QueryRow(`PRAGMA cache_size`).Scan(&cacheSize); err != nil { |
| + t.Fatal(err) |
| + } |
| + if foreignKeys != 1 || cacheSize != 123 { |
| + t.Fatalf("unrelated URI pragmas foreign_keys=%d cache_size=%d", foreignKeys, cacheSize) |
| + } |
| + if err := s.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(path); err != nil { |
| + t.Fatalf("database URI did not retain its literal path %q: %v", path, err) |
| + } |
| +} |
| + |
| +func TestOpenRejectsMalformedExplicitURIQueryBeforeCreatingDatabase(t *testing.T) { |
| + for _, query := range []string{"mode=ro%zz", "mode=rwc;cache=shared"} { |
| + t.Run(query, func(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "must-not-exist.db") |
| + dsn := "file:" + (&url.URL{Path: path}).EscapedPath() + "?" + query |
| + if _, err := Open(dsn); err == nil { |
| + t.Fatal("Open accepted malformed URI query") |
| + } |
| + if _, err := os.Stat(path); !os.IsNotExist(err) { |
| + t.Fatalf("malformed URI created database %q: %v", path, err) |
| + } |
| + }) |
| + } |
| +} |