Keep foreign keys on for every database connection (closes #207)
check / check (push) Waiting to run

SQLite keeps the foreign keys setting per connection, and the server
turned it on once with a PRAGMA. A query cancelled while it runs makes
the pool close its only connection and open a new one, which then ran
without foreign keys until a restart.

connect now adds `_pragma=foreign_keys(1)` to the database URL. The
driver runs every `_pragma` parameter on each connection it opens, so
this also holds for a `DBURL` without `_pragma` parameters. The new
test cancels a query on a file database, then checks that the tables
are still there and that foreign keys are on.

Model: opus-5-5
This commit is contained in:
2026-10-27 09:29:35 +00:00
parent d8f8748525
commit 654ff2daaa
2 changed files with 105 additions and 7 deletions
+11 -7
View File
@@ -90,6 +90,17 @@ func (database *Database) connect(ctx context.Context) error {
dbURL = config.DefaultDBURL
}
// SQLite keeps the foreign keys setting per connection. The driver
// runs each _pragma parameter on every connection it opens. A
// PRAGMA run once would miss the connection the pool opens after a
// cancelled query breaks the old one.
separator := "?"
if strings.Contains(dbURL, "?") {
separator = "&"
}
dbURL += separator + "_pragma=foreign_keys(1)"
database.log.Info(
"connecting to database", "url", dbURL,
)
@@ -109,13 +120,6 @@ func (database *Database) connect(ctx context.Context) error {
database.conn = conn
database.log.Info("database connected")
_, err = database.conn.ExecContext(
ctx, "PRAGMA foreign_keys = ON",
)
if err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
return database.runMigrations(ctx)
}
+94
View File
@@ -1,9 +1,12 @@
package db_test
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/config"
@@ -14,6 +17,16 @@ import (
const wantBusyTimeoutMS = 5000
const (
// endlessQuery counts the rows of a recursive query that never
// stops, so it runs until its context ends.
endlessQuery = `WITH RECURSIVE n(x) AS
(SELECT 1 UNION ALL SELECT x + 1 FROM n)
SELECT COUNT(*) FROM n`
endlessQueryTimeout = 100 * time.Millisecond
)
// TestDefaultDBURLSetsWALAndBusyTimeout opens a file database
// with the default URL's parameters the way the server does,
// and checks the journal mode and busy timeout it ends up with.
@@ -73,3 +86,84 @@ func TestDefaultDBURLSetsWALAndBusyTimeout(t *testing.T) {
)
}
}
// TestForeignKeysOnAfterCancelledQuery cancels a query while it
// runs, which makes the pool close its only connection and open a
// new one. It checks that the file database keeps its tables and
// that the new connection has foreign keys on.
func TestForeignKeysOnAfterCancelledQuery(t *testing.T) {
t.Parallel()
_, defaultParams, _ := strings.Cut(config.DefaultDBURL, "?")
urlParams := map[string]string{
"default parameters": "?" + defaultParams,
"no parameters": "",
}
for name, params := range urlParams {
t.Run(name, func(t *testing.T) {
t.Parallel()
dbURL := "file://" +
filepath.Join(t.TempDir(), "state.db") + params
lifecycle := fxtest.NewLifecycle(t)
log, err := logger.New(lifecycle, logger.Params{
Globals: &globals.Globals{},
})
if err != nil {
t.Fatal(err)
}
database, err := db.New(lifecycle, db.Params{
Logger: log,
Config: &config.Config{DBURL: dbURL},
})
if err != nil {
t.Fatal(err)
}
lifecycle.RequireStart()
t.Cleanup(lifecycle.RequireStop)
ctx, cancel := context.WithTimeout(
t.Context(), endlessQueryTimeout,
)
defer cancel()
_, err = database.GetDB().ExecContext(ctx, endlessQuery)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("endless query: err = %v", err)
}
var migrations int
err = database.GetDB().QueryRowContext(
t.Context(),
"SELECT COUNT(*) FROM schema_migrations",
).Scan(&migrations)
if err != nil {
t.Fatal(err)
}
if migrations == 0 {
t.Error("schema_migrations is empty")
}
var foreignKeys int
err = database.GetDB().QueryRowContext(
t.Context(), "PRAGMA foreign_keys",
).Scan(&foreignKeys)
if err != nil {
t.Fatal(err)
}
if foreignKeys != 1 {
t.Errorf("foreign_keys = %d, want 1", foreignKeys)
}
})
}
}