Bound SQLite memory across the whole connection pool (closes #8) #15
@@ -38,6 +38,22 @@ const (
|
|||||||
maxIPv4 = 0xFFFFFFFF
|
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
|
// Common errors
|
||||||
var (
|
var (
|
||||||
// ErrInvalidIP is returned when an IP address is malformed
|
// 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)
|
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add connection parameters for go-sqlite3
|
// Per-connection SQLite settings go in the DSN so every pooled connection
|
||||||
// Configure SQLite connection parameters
|
// gets them, not just the one that runs the Initialize pragmas.
|
||||||
dsn := fmt.Sprintf(
|
dsn := fmt.Sprintf(
|
||||||
"file:%s",
|
"file:%s?_cache_size=%d&_synchronous=OFF&_busy_timeout=%d&_journal_mode=WAL",
|
||||||
dbPath,
|
dbPath,
|
||||||
|
sqliteCacheSizeKiB,
|
||||||
|
sqliteBusyTimeoutMs,
|
||||||
)
|
)
|
||||||
db, err := sql.Open("sqlite3", dsn)
|
db, err := sql.Open("sqlite3", dsn)
|
||||||
if err != nil {
|
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.
|
// Initialize creates the database schema if it doesn't exist.
|
||||||
func (d *Database) Initialize() error {
|
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{
|
pragmas := []string{
|
||||||
"PRAGMA journal_mode=WAL", // Write-Ahead Logging
|
"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 analysis_limit=0", // Disable automatic ANALYZE
|
||||||
"PRAGMA auto_vacuum=INCREMENTAL", // Enable incremental vacuum
|
"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 {
|
for _, pragma := range pragmas {
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"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) {
|
func TestIPToUint32(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
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) {
|
func BenchmarkIPToUint32(b *testing.B) {
|
||||||
ip := net.ParseIP("192.168.1.1")
|
ip := net.ParseIP("192.168.1.1")
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
|
|||||||
Reference in New Issue
Block a user