diff --git a/internal/db/db.go b/internal/db/db.go index e2dce3a..5fec112 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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) } diff --git a/internal/db/db_test.go b/internal/db/db_test.go index c79fdfc..d5719b1 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -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) + } + }) + } +}