Kristoffer Dalby c6736dd6d6 db: add sqlite "source of truth" schema
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-07-07 15:48:38 +01:00

346 lines
12 KiB
Go

// Package sqliteconfig provides type-safe configuration for SQLite databases
// with proper enum validation and URL generation for modernc.org/sqlite driver.
package sqliteconfig
import (
"errors"
"fmt"
"strings"
)
// Errors returned by config validation.
var (
ErrPathEmpty = errors.New("path cannot be empty")
ErrBusyTimeoutNegative = errors.New("busy_timeout must be >= 0")
ErrInvalidJournalMode = errors.New("invalid journal_mode")
ErrInvalidAutoVacuum = errors.New("invalid auto_vacuum")
ErrWALAutocheckpoint = errors.New("wal_autocheckpoint must be >= -1")
ErrInvalidSynchronous = errors.New("invalid synchronous")
)
const (
// DefaultBusyTimeout is the default busy timeout in milliseconds.
DefaultBusyTimeout = 10000
)
// JournalMode represents SQLite journal_mode pragma values.
// Journal modes control how SQLite handles write transactions and crash recovery.
//
// Performance vs Durability Tradeoffs:
//
// WAL (Write-Ahead Logging) - Recommended for production:
// - Best performance for concurrent reads/writes
// - Readers don't block writers, writers don't block readers
// - Excellent crash recovery with minimal data loss risk
// - Uses additional .wal and .shm files
// - Default choice for Headscale production deployments
//
// DELETE - Traditional rollback journal:
// - Good performance for single-threaded access
// - Readers block writers and vice versa
// - Reliable crash recovery but with exclusive locking
// - Creates temporary journal files during transactions
// - Suitable for low-concurrency scenarios
//
// TRUNCATE - Similar to DELETE but faster cleanup:
// - Slightly better performance than DELETE
// - Same concurrency limitations as DELETE
// - Faster transaction commit by truncating instead of deleting journal
//
// PERSIST - Journal file remains between transactions:
// - Avoids file creation/deletion overhead
// - Same concurrency limitations as DELETE
// - Good for frequent small transactions
//
// MEMORY - Journal kept in memory:
// - Fastest performance but NO crash recovery
// - Data loss risk on power failure or crash
// - Only suitable for temporary or non-critical data
//
// OFF - No journaling:
// - Maximum performance but NO transaction safety
// - High risk of database corruption on crash
// - Should only be used for read-only or disposable databases
type JournalMode string
const (
// JournalModeWAL enables Write-Ahead Logging (RECOMMENDED for production).
// Best concurrent performance + crash recovery. Uses additional .wal/.shm files.
JournalModeWAL JournalMode = "WAL"
// JournalModeDelete uses traditional rollback journaling.
// Good single-threaded performance, readers block writers. Creates temp journal files.
JournalModeDelete JournalMode = "DELETE"
// JournalModeTruncate is like DELETE but with faster cleanup.
// Slightly better performance than DELETE, same safety with exclusive locking.
JournalModeTruncate JournalMode = "TRUNCATE"
// JournalModePersist keeps journal file between transactions.
// Good for frequent transactions, avoids file creation/deletion overhead.
JournalModePersist JournalMode = "PERSIST"
// JournalModeMemory keeps journal in memory (DANGEROUS).
// Fastest performance but NO crash recovery - data loss on power failure.
JournalModeMemory JournalMode = "MEMORY"
// JournalModeOff disables journaling entirely (EXTREMELY DANGEROUS).
// Maximum performance but high corruption risk. Only for disposable databases.
JournalModeOff JournalMode = "OFF"
)
// IsValid returns true if the JournalMode is valid.
func (j JournalMode) IsValid() bool {
switch j {
case JournalModeWAL, JournalModeDelete, JournalModeTruncate,
JournalModePersist, JournalModeMemory, JournalModeOff:
return true
default:
return false
}
}
// String returns the string representation.
func (j JournalMode) String() string {
return string(j)
}
// AutoVacuum represents SQLite auto_vacuum pragma values.
// Auto-vacuum controls how SQLite reclaims space from deleted data.
//
// Performance vs Storage Tradeoffs:
//
// INCREMENTAL - Recommended for production:
// - Reclaims space gradually during normal operations
// - Minimal performance impact on writes
// - Database size shrinks automatically over time
// - Can manually trigger with PRAGMA incremental_vacuum
// - Good balance of space efficiency and performance
//
// FULL - Automatic space reclamation:
// - Immediately reclaims space on every DELETE/DROP
// - Higher write overhead due to page reorganization
// - Keeps database file size minimal
// - Can cause significant slowdowns on large deletions
// - Best for applications with frequent deletes and limited storage
//
// NONE - No automatic space reclamation:
// - Fastest write performance (no vacuum overhead)
// - Database file only grows, never shrinks
// - Deleted space is reused but file size remains large
// - Requires manual VACUUM to reclaim space
// - Best for write-heavy workloads where storage isn't constrained
type AutoVacuum string
const (
// AutoVacuumNone disables automatic space reclamation.
// Fastest writes, file only grows. Requires manual VACUUM to reclaim space.
AutoVacuumNone AutoVacuum = "NONE"
// AutoVacuumFull immediately reclaims space on every DELETE/DROP.
// Minimal file size but slower writes. Can impact performance on large deletions.
AutoVacuumFull AutoVacuum = "FULL"
// AutoVacuumIncremental reclaims space gradually (RECOMMENDED for production).
// Good balance: minimal write impact, automatic space management over time.
AutoVacuumIncremental AutoVacuum = "INCREMENTAL"
)
// IsValid returns true if the AutoVacuum is valid.
func (a AutoVacuum) IsValid() bool {
switch a {
case AutoVacuumNone, AutoVacuumFull, AutoVacuumIncremental:
return true
default:
return false
}
}
// String returns the string representation.
func (a AutoVacuum) String() string {
return string(a)
}
// Synchronous represents SQLite synchronous pragma values.
// Synchronous mode controls how aggressively SQLite flushes data to disk.
//
// Performance vs Durability Tradeoffs:
//
// NORMAL - Recommended for production:
// - Good balance of performance and safety
// - Syncs at critical moments (transaction commits in WAL mode)
// - Very low risk of corruption, minimal performance impact
// - Safe with WAL mode even with power loss
// - Default choice for most production applications
//
// FULL - Maximum durability:
// - Syncs to disk after every write operation
// - Highest data safety, virtually no corruption risk
// - Significant performance penalty (up to 50% slower)
// - Recommended for critical data where corruption is unacceptable
//
// EXTRA - Paranoid mode:
// - Even more aggressive syncing than FULL
// - Maximum possible data safety
// - Severe performance impact
// - Only for extremely critical scenarios
//
// OFF - Maximum performance, minimum safety:
// - No syncing, relies on OS to flush data
// - Fastest possible performance
// - High risk of corruption on power failure or crash
// - Only suitable for non-critical or easily recreatable data
type Synchronous string
const (
// SynchronousOff disables syncing (DANGEROUS).
// Fastest performance but high corruption risk on power failure. Avoid in production.
SynchronousOff Synchronous = "OFF"
// SynchronousNormal provides balanced performance and safety (RECOMMENDED).
// Good performance with low corruption risk. Safe with WAL mode on power loss.
SynchronousNormal Synchronous = "NORMAL"
// SynchronousFull provides maximum durability with performance cost.
// Syncs after every write. Up to 50% slower but virtually no corruption risk.
SynchronousFull Synchronous = "FULL"
// SynchronousExtra provides paranoid-level data safety (EXTREME).
// Maximum safety with severe performance impact. Rarely needed in practice.
SynchronousExtra Synchronous = "EXTRA"
)
// IsValid returns true if the Synchronous is valid.
func (s Synchronous) IsValid() bool {
switch s {
case SynchronousOff, SynchronousNormal, SynchronousFull, SynchronousExtra:
return true
default:
return false
}
}
// String returns the string representation.
func (s Synchronous) String() string {
return string(s)
}
// Config holds SQLite database configuration with type-safe enums.
// This configuration balances performance, durability, and operational requirements
// for Headscale's SQLite database usage patterns.
type Config struct {
Path string // file path or ":memory:"
BusyTimeout int // milliseconds (0 = default/disabled)
JournalMode JournalMode // journal mode (affects concurrency and crash recovery)
AutoVacuum AutoVacuum // auto vacuum mode (affects storage efficiency)
WALAutocheckpoint int // pages (-1 = default/not set, 0 = disabled, >0 = enabled)
Synchronous Synchronous // synchronous mode (affects durability vs performance)
ForeignKeys bool // enable foreign key constraints (data integrity)
}
// Default returns the production configuration optimized for Headscale's usage patterns.
// This configuration prioritizes:
// - Concurrent access (WAL mode for multiple readers/writers)
// - Data durability with good performance (NORMAL synchronous)
// - Automatic space management (INCREMENTAL auto-vacuum)
// - Data integrity (foreign key constraints enabled)
// - Reasonable timeout for busy database scenarios (10s)
func Default(path string) *Config {
return &Config{
Path: path,
BusyTimeout: DefaultBusyTimeout,
JournalMode: JournalModeWAL,
AutoVacuum: AutoVacuumIncremental,
WALAutocheckpoint: 1000,
Synchronous: SynchronousNormal,
ForeignKeys: true,
}
}
// Memory returns a configuration for in-memory databases.
func Memory() *Config {
return &Config{
Path: ":memory:",
WALAutocheckpoint: -1, // not set, use driver default
ForeignKeys: true,
}
}
// Validate checks if all configuration values are valid.
func (c *Config) Validate() error {
if c.Path == "" {
return ErrPathEmpty
}
if c.BusyTimeout < 0 {
return fmt.Errorf("%w, got %d", ErrBusyTimeoutNegative, c.BusyTimeout)
}
if c.JournalMode != "" && !c.JournalMode.IsValid() {
return fmt.Errorf("%w: %s", ErrInvalidJournalMode, c.JournalMode)
}
if c.AutoVacuum != "" && !c.AutoVacuum.IsValid() {
return fmt.Errorf("%w: %s", ErrInvalidAutoVacuum, c.AutoVacuum)
}
if c.WALAutocheckpoint < -1 {
return fmt.Errorf("%w, got %d", ErrWALAutocheckpoint, c.WALAutocheckpoint)
}
if c.Synchronous != "" && !c.Synchronous.IsValid() {
return fmt.Errorf("%w: %s", ErrInvalidSynchronous, c.Synchronous)
}
return nil
}
// ToURL builds a properly encoded SQLite connection string using _pragma parameters
// compatible with modernc.org/sqlite driver.
func (c *Config) ToURL() (string, error) {
if err := c.Validate(); err != nil {
return "", fmt.Errorf("invalid config: %w", err)
}
var pragmas []string
// Add pragma parameters only if they're set (non-zero/non-empty)
if c.BusyTimeout > 0 {
pragmas = append(pragmas, fmt.Sprintf("busy_timeout=%d", c.BusyTimeout))
}
if c.JournalMode != "" {
pragmas = append(pragmas, fmt.Sprintf("journal_mode=%s", c.JournalMode))
}
if c.AutoVacuum != "" {
pragmas = append(pragmas, fmt.Sprintf("auto_vacuum=%s", c.AutoVacuum))
}
if c.WALAutocheckpoint >= 0 {
pragmas = append(pragmas, fmt.Sprintf("wal_autocheckpoint=%d", c.WALAutocheckpoint))
}
if c.Synchronous != "" {
pragmas = append(pragmas, fmt.Sprintf("synchronous=%s", c.Synchronous))
}
if c.ForeignKeys {
pragmas = append(pragmas, "foreign_keys=ON")
}
// Handle different database types
var baseURL string
if c.Path == ":memory:" {
baseURL = ":memory:"
} else {
baseURL = "file:" + c.Path
}
// Add parameters without encoding = signs
if len(pragmas) > 0 {
var queryParts []string
for _, pragma := range pragmas {
queryParts = append(queryParts, "_pragma="+pragma)
}
baseURL += "?" + strings.Join(queryParts, "&")
}
return baseURL, nil
}