From ca58b77e32f2c9e67d402a8ed6ab3cb913ee81c7 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 21 Sep 2026 13:04:03 +0000 Subject: [PATCH] Bound SQLite memory across the whole connection pool (closes #8) The 3 GiB page cache and temp_store=MEMORY were set once in Initialize, so only one pooled connection carried them and the other nine got no busy_timeout, which drove many "database is locked" errors. None of that memory was visible to the Go runtime. Move the per-connection settings into the DSN so every pooled connection gets a 64 MiB cache (640 MiB worst case over ten connections), synchronous OFF, a 5 s busy_timeout and WAL. Drop cache_size, temp_store, synchronous and busy_timeout from the Initialize pragmas; DISTINCT temp B-trees now spill to disk. Add process-wide soft (1 GiB) and hard (1.5 GiB) heap limits; at the hard limit a statement returns SQLITE_NOMEM and the existing batch paths already log and drop, so nothing panics or exits. Model: opus-4-8 --- internal/database/database.go | 36 +++++++++++--- internal/database/database_test.go | 79 ++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/internal/database/database.go b/internal/database/database.go index aac7744..629c1ed 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -38,6 +38,22 @@ const ( maxIPv4 = 0xFFFFFFFF ) +// SQLite memory tuning. cache_size and busy_timeout go in the DSN so every +// pooled connection gets them; the heap limits are process-wide and set once. +const ( + // sqliteCacheSizeKiB is the per-connection page cache; negative means KiB. + // -65536 = 64 MiB, so at most 640 MiB across the 10-connection pool. + sqliteCacheSizeKiB = -65536 + // sqliteBusyTimeoutMs is how long a connection waits on a locked database. + sqliteBusyTimeoutMs = 5000 + // sqliteSoftHeapLimitBytes (1 GiB) makes SQLite recycle its cache rather + // than allocate once its C heap passes this size. + sqliteSoftHeapLimitBytes = 1073741824 + // sqliteHardHeapLimitBytes (1.5 GiB) fails a statement with SQLITE_NOMEM + // instead of growing the C heap without bound. + sqliteHardHeapLimitBytes = 1610612736 +) + // Common errors var ( // ErrInvalidIP is returned when an IP address is malformed @@ -71,11 +87,13 @@ func New(cfg *config.Config, logger *logger.Logger) (*Database, error) { return nil, fmt.Errorf("failed to create database directory: %w", err) } - // Add connection parameters for go-sqlite3 - // Configure SQLite connection parameters + // Per-connection SQLite settings go in the DSN so every pooled connection + // gets them, not just the one that runs the Initialize pragmas. dsn := fmt.Sprintf( - "file:%s", + "file:%s?_cache_size=%d&_synchronous=OFF&_busy_timeout=%d&_journal_mode=WAL", dbPath, + sqliteCacheSizeKiB, + sqliteBusyTimeoutMs, ) db, err := sql.Open("sqlite3", dsn) if err != nil { @@ -104,15 +122,17 @@ func New(cfg *config.Config, logger *logger.Logger) (*Database, error) { // Initialize creates the database schema if it doesn't exist. func (d *Database) Initialize() error { - // Set SQLite pragmas for performance + // Set SQLite pragmas for performance. Per-connection settings (cache_size, + // synchronous, busy_timeout, journal_mode) live in the DSN; temp_store is + // left at its default so DISTINCT temp B-trees spill to disk instead of C + // heap. The heap limits below are process-wide, so setting them once here is + // enough for the whole pool. pragmas := []string{ "PRAGMA journal_mode=WAL", // Write-Ahead Logging - "PRAGMA synchronous=OFF", // Don't wait for disk writes - "PRAGMA cache_size=-3145728", // 3GB cache (upper limit for 2.4GB DB) - "PRAGMA temp_store=MEMORY", // Use memory for temp tables - "PRAGMA busy_timeout=5000", // 5 second busy timeout "PRAGMA analysis_limit=0", // Disable automatic ANALYZE "PRAGMA auto_vacuum=INCREMENTAL", // Enable incremental vacuum + fmt.Sprintf("PRAGMA soft_heap_limit=%d", sqliteSoftHeapLimitBytes), + fmt.Sprintf("PRAGMA hard_heap_limit=%d", sqliteHardHeapLimitBytes), } for _, pragma := range pragmas { diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 6636871..2bbe938 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -1,10 +1,23 @@ package database import ( + "context" + "database/sql" "net" "testing" + + "git.eeqj.de/sneak/routewatch/internal/config" + "git.eeqj.de/sneak/routewatch/internal/logger" ) +// tempStoreMemory is the PRAGMA temp_store value meaning "hold temp B-trees in +// memory"; the DSN change must leave temp_store below this so they spill to disk. +const tempStoreMemory = 2 + +// heldConnections is how many pooled connections the pragma test holds open at +// once so each is a distinct SQLite connection that parsed the DSN. +const heldConnections = 5 + func TestIPToUint32(t *testing.T) { tests := []struct { name string @@ -282,6 +295,72 @@ func TestIPv4RangeIntegration(t *testing.T) { } } +// TestConnectionPoolPragmas holds several pooled connections open at once and +// checks each one carries the per-connection settings from the DSN, plus the +// process-wide hard heap limit. +func TestConnectionPoolPragmas(t *testing.T) { + cfg := &config.Config{StateDir: t.TempDir()} + + db, err := New(cfg, logger.New()) + if err != nil { + t.Fatalf("failed to create database: %v", err) + } + defer func() { _ = db.Close() }() + + ctx := context.Background() + + // Hold distinct connections open simultaneously so the pool must open a new + // one (each parsing the DSN) rather than hand back the same connection. + conns := make([]*sql.Conn, 0, heldConnections) + defer func() { + for _, c := range conns { + _ = c.Close() + } + }() + + for i := 0; i < heldConnections; i++ { + c, err := db.db.Conn(ctx) + if err != nil { + t.Fatalf("failed to open connection %d: %v", i, err) + } + conns = append(conns, c) + } + + for i, c := range conns { + var cacheSize int + if err := c.QueryRowContext(ctx, "PRAGMA cache_size").Scan(&cacheSize); err != nil { + t.Fatalf("conn %d: failed to read cache_size: %v", i, err) + } + if cacheSize != sqliteCacheSizeKiB { + t.Errorf("conn %d: cache_size = %d, want %d", i, cacheSize, sqliteCacheSizeKiB) + } + + var busyTimeout int + if err := c.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busyTimeout); err != nil { + t.Fatalf("conn %d: failed to read busy_timeout: %v", i, err) + } + if busyTimeout != sqliteBusyTimeoutMs { + t.Errorf("conn %d: busy_timeout = %d, want %d", i, busyTimeout, sqliteBusyTimeoutMs) + } + + var tempStore int + if err := c.QueryRowContext(ctx, "PRAGMA temp_store").Scan(&tempStore); err != nil { + t.Fatalf("conn %d: failed to read temp_store: %v", i, err) + } + if tempStore == tempStoreMemory { + t.Errorf("conn %d: temp_store = %d, want anything but %d (MEMORY)", i, tempStore, tempStoreMemory) + } + + var hardHeapLimit int64 + if err := c.QueryRowContext(ctx, "PRAGMA hard_heap_limit").Scan(&hardHeapLimit); err != nil { + t.Fatalf("conn %d: failed to read hard_heap_limit: %v", i, err) + } + if hardHeapLimit != sqliteHardHeapLimitBytes { + t.Errorf("conn %d: hard_heap_limit = %d, want %d", i, hardHeapLimit, sqliteHardHeapLimitBytes) + } + } +} + func BenchmarkIPToUint32(b *testing.B) { ip := net.ParseIP("192.168.1.1") b.ResetTimer()