2026-07-31 15:41:57 +02:00
|
|
|
package db_test
|
|
|
|
|
|
|
|
|
|
// pool_test.go — file-backed reader/writer pool split tests.
|
|
|
|
|
//
|
|
|
|
|
// db.Open gives file-backed databases two pools: a single-connection writer
|
|
|
|
|
// and a multi-connection reader (see db.go). The per-connection PRAGMAs move
|
|
|
|
|
// into the DSN in that mode, because an Exec'd PRAGMA would only configure
|
|
|
|
|
// one arbitrary pooled connection. These tests pin the properties that split
|
|
|
|
|
// must preserve: foreign_keys=ON on every reader connection, WAL journaling,
|
|
|
|
|
// FK enforcement on the write path, and reads proceeding while a write
|
|
|
|
|
// transaction is open.
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"sync"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
"github.com/J3vb/OwnCord/Server/db"
|
2026-07-31 15:41:57 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// openFileDB opens a temp-file-backed database with the full embedded
|
|
|
|
|
// migration set applied.
|
|
|
|
|
func openFileDB(t *testing.T) *db.DB {
|
|
|
|
|
t.Helper()
|
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "pool_test.db")
|
|
|
|
|
database, err := db.Open(dbPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open(%q) error: %v", dbPath, err)
|
|
|
|
|
}
|
|
|
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
|
|
|
if err := db.Migrate(database); err != nil {
|
|
|
|
|
t.Fatalf("Migrate() error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return database
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// seedChannelAndUser creates one text channel and one member user for
|
|
|
|
|
// message-write tests, returning their IDs.
|
|
|
|
|
func seedChannelAndUser(t *testing.T, database *db.DB) (channelID, userID int64) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
channelID, err := database.AdminCreateChannel(ctx, "pool-test", "text", "", "", 99)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("AdminCreateChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
userID, err = database.CreateUser(ctx, "pooluser", "x", 4)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateUser: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return channelID, userID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestFilePool_ForeignKeysOnReaderConnections asserts PRAGMA foreign_keys
|
|
|
|
|
// returns 1 on many reader-pool connections. The PRAGMA read routes to the
|
|
|
|
|
// reader pool, and the sequential + parallel mix below forces the pool to
|
|
|
|
|
// grow and to serve the checks from different physical connections — the
|
|
|
|
|
// regression this catches is the DSN `_pragma=` parameters being dropped,
|
|
|
|
|
// which would leave fresh pooled connections with foreign_keys=OFF.
|
|
|
|
|
func TestFilePool_ForeignKeysOnReaderConnections(t *testing.T) {
|
|
|
|
|
database := openFileDB(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
checkFK := func() error {
|
|
|
|
|
var fk int
|
|
|
|
|
if err := database.QueryRowContext(ctx, "PRAGMA foreign_keys;").Scan(&fk); err != nil {
|
|
|
|
|
return fmt.Errorf("PRAGMA foreign_keys: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if fk != 1 {
|
|
|
|
|
return fmt.Errorf("foreign_keys = %d, want 1", fk)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sequential warm-up checks.
|
|
|
|
|
for i := range 20 {
|
|
|
|
|
if err := checkFK(); err != nil {
|
|
|
|
|
t.Fatalf("sequential check %d: %v", i, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parallel: 16 goroutines interleaving reads and PRAGMA checks so the
|
|
|
|
|
// pool opens multiple connections and the checks land on different ones.
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
for range 16 {
|
|
|
|
|
wg.Go(func() {
|
|
|
|
|
for range 25 {
|
|
|
|
|
var n int
|
|
|
|
|
if err := database.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&n); err != nil {
|
|
|
|
|
t.Errorf("read query: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := checkFK(); err != nil {
|
|
|
|
|
t.Errorf("parallel check: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
|
|
|
|
// journal_mode must be WAL on the reader connections too.
|
|
|
|
|
var mode string
|
|
|
|
|
if err := database.QueryRowContext(ctx, "PRAGMA journal_mode;").Scan(&mode); err != nil {
|
|
|
|
|
t.Fatalf("PRAGMA journal_mode: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if mode != "wal" {
|
|
|
|
|
t.Errorf("journal_mode = %q, want %q", mode, "wal")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestFilePool_ConcurrentReadsAndWrites hammers the split with 8 writer
|
|
|
|
|
// goroutines (both the sqlc INSERT...RETURNING path, which travels through
|
|
|
|
|
// QueryRowContext and must be routed to the writer, and the raw ExecContext
|
|
|
|
|
// path) against 8 reader goroutines, then asserts nothing errored and every
|
|
|
|
|
// row landed.
|
|
|
|
|
func TestFilePool_ConcurrentReadsAndWrites(t *testing.T) {
|
|
|
|
|
database := openFileDB(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
channelID, userID := seedChannelAndUser(t, database)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
writers = 8
|
|
|
|
|
readers = 8
|
|
|
|
|
perWriter = 25
|
|
|
|
|
wantRows int64 = writers * perWriter
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
|
|
|
|
|
// Writers: CreateMessage exercises INSERT ... RETURNING via the dbtx
|
|
|
|
|
// router; PersistEvent exercises the plain ExecContext write path.
|
|
|
|
|
for w := range writers {
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
go func(w int) {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
for i := range perWriter {
|
|
|
|
|
if _, err := database.CreateMessage(ctx, channelID, userID, fmt.Sprintf("msg %d-%d", w, i), nil); err != nil {
|
|
|
|
|
t.Errorf("CreateMessage: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
seq := int64(w*perWriter + i + 1)
|
|
|
|
|
if err := database.PersistEvent(ctx, seq, "test_event", channelID, []byte(`{}`)); err != nil {
|
|
|
|
|
t.Errorf("PersistEvent: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}(w)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Readers: list messages and events while the writers run.
|
|
|
|
|
for range readers {
|
|
|
|
|
wg.Go(func() {
|
|
|
|
|
for range perWriter {
|
|
|
|
|
if _, err := database.GetMessages(ctx, channelID, 0, 50); err != nil {
|
|
|
|
|
t.Errorf("GetMessages: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if _, err := database.GetEventsSince(ctx, 0, 50); err != nil {
|
|
|
|
|
t.Errorf("GetEventsSince: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
|
|
|
|
var msgCount, evtCount int64
|
|
|
|
|
if err := database.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&msgCount); err != nil {
|
|
|
|
|
t.Fatalf("count messages: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if msgCount != wantRows {
|
|
|
|
|
t.Errorf("messages = %d, want %d", msgCount, wantRows)
|
|
|
|
|
}
|
|
|
|
|
if err := database.QueryRowContext(ctx, "SELECT COUNT(*) FROM events").Scan(&evtCount); err != nil {
|
|
|
|
|
t.Fatalf("count events: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if evtCount != wantRows {
|
|
|
|
|
t.Errorf("events = %d, want %d", evtCount, wantRows)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestFilePool_FKViolationRejectedOnFile proves foreign key enforcement is
|
|
|
|
|
// live on the writer connection of a file-backed database, through both the
|
|
|
|
|
// sqlc RETURNING write path and a raw ExecContext insert.
|
|
|
|
|
func TestFilePool_FKViolationRejectedOnFile(t *testing.T) {
|
|
|
|
|
database := openFileDB(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
channelID, userID := seedChannelAndUser(t, database)
|
|
|
|
|
|
|
|
|
|
// Nonexistent channel via the sqlc INSERT ... RETURNING path.
|
|
|
|
|
if _, err := database.CreateMessage(ctx, 999999, userID, "orphan", nil); err == nil {
|
|
|
|
|
t.Error("CreateMessage with nonexistent channel succeeded, want FK violation")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Nonexistent user via the raw ExecContext path.
|
|
|
|
|
if _, err := database.ExecContext(ctx,
|
|
|
|
|
`INSERT INTO messages (channel_id, user_id, content) VALUES (?, ?, 'orphan')`,
|
|
|
|
|
channelID, int64(999999),
|
|
|
|
|
); err == nil {
|
|
|
|
|
t.Error("raw insert with nonexistent user succeeded, want FK violation")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The valid combination still works.
|
|
|
|
|
if _, err := database.CreateMessage(ctx, channelID, userID, "valid", nil); err != nil {
|
|
|
|
|
t.Errorf("CreateMessage with valid FKs: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestFilePool_ReadDuringOpenWriteTx verifies the WAL property the split
|
|
|
|
|
// exists for: a read on the reader pool completes while the writer holds an
|
|
|
|
|
// open (BEGIN IMMEDIATE) write transaction, seeing the pre-transaction
|
|
|
|
|
// snapshot, and sees the new data once the transaction commits.
|
|
|
|
|
func TestFilePool_ReadDuringOpenWriteTx(t *testing.T) {
|
|
|
|
|
database := openFileDB(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
channelID, userID := seedChannelAndUser(t, database)
|
|
|
|
|
|
|
|
|
|
if _, err := database.CreateMessage(ctx, channelID, userID, "before", nil); err != nil {
|
|
|
|
|
t.Fatalf("CreateMessage: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tx, err := database.BeginTx(ctx, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("BeginTx: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
|
|
|
`INSERT INTO messages (channel_id, user_id, content) VALUES (?, ?, 'uncommitted')`,
|
|
|
|
|
channelID, userID,
|
|
|
|
|
); err != nil {
|
|
|
|
|
_ = tx.Rollback()
|
|
|
|
|
t.Fatalf("tx insert: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The read must not block on the open write transaction. Bound it with a
|
|
|
|
|
// timeout so a lock conflict fails fast instead of hanging the test.
|
|
|
|
|
readCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
var count int64
|
|
|
|
|
if err := database.QueryRowContext(readCtx,
|
|
|
|
|
"SELECT COUNT(*) FROM messages WHERE channel_id = ?", channelID,
|
|
|
|
|
).Scan(&count); err != nil {
|
|
|
|
|
_ = tx.Rollback()
|
|
|
|
|
t.Fatalf("read during open write tx: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if count != 1 {
|
|
|
|
|
t.Errorf("read during open tx saw %d messages, want 1 (pre-tx snapshot)", count)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
t.Fatalf("Commit: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := database.QueryRowContext(ctx,
|
|
|
|
|
"SELECT COUNT(*) FROM messages WHERE channel_id = ?", channelID,
|
|
|
|
|
).Scan(&count); err != nil {
|
|
|
|
|
t.Fatalf("read after commit: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if count != 2 {
|
|
|
|
|
t.Errorf("read after commit saw %d messages, want 2", count)
|
|
|
|
|
}
|
|
|
|
|
}
|