Files
silo-server/internal/userdb/pool.go

229 lines
6.1 KiB
Go

package userdb
import (
"context"
"fmt"
"path/filepath"
"sort"
"sync"
"time"
)
const (
defaultMaxOpen = 500
defaultIdleTimeout = 12 * time.Hour
hotThreshold = 5 * time.Minute
criticalPercent = 0.95
)
// PoolConfig controls how many SQLite connections the pool keeps open
// and when idle connections become eligible for eviction.
type PoolConfig struct {
MaxOpen int // max open SQLite connections (default 500)
IdleTimeout time.Duration // how long before db considered cold (default 12h)
DataDir string // directory where SQLite files are stored
}
// UserDBPool manages per-user SQLite database connections with LRU eviction.
// Connections are cached and reused for the same userID. Active playback
// connections can be pinned so they are never evicted.
type UserDBPool struct {
config PoolConfig
mu sync.Mutex
dbs map[int]*poolEntry // userID -> entry
pinned map[int]bool // userID -> true if pinned (active playback)
}
type poolEntry struct {
db *UserDB
lastAccess time.Time
}
// evictionTier classifies a pool entry for eviction priority.
// Lower values are evicted first.
type evictionTier int
const (
tierCold evictionTier = iota // past idle_timeout — first to evict
tierWarm // within idle_timeout — standard LRU when pool full
tierHot // activity within last 5 min — only when pool >95% full
tierPinned // active playback — never evict
)
// NewUserDBPool creates a new pool with the given configuration.
// Zero-value fields in config are replaced with defaults.
func NewUserDBPool(config PoolConfig) *UserDBPool {
if config.MaxOpen <= 0 {
config.MaxOpen = defaultMaxOpen
}
if config.IdleTimeout <= 0 {
config.IdleTimeout = defaultIdleTimeout
}
return &UserDBPool{
config: config,
dbs: make(map[int]*poolEntry),
pinned: make(map[int]bool),
}
}
// Get returns the cached UserDB for the given userID, or creates a new
// SQLite database at {DataDir}/{userID}.db, initialises its schema, and
// adds it to the pool. The context is checked for cancellation before
// potentially expensive I/O.
func (p *UserDBPool) Get(ctx context.Context, userID int) (*UserDB, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
p.mu.Lock()
defer p.mu.Unlock()
// Return cached entry and refresh its access time.
if entry, ok := p.dbs[userID]; ok {
entry.lastAccess = time.Now()
return entry.db, nil
}
// Evict if we are at capacity before opening a new connection.
if len(p.dbs) >= p.config.MaxOpen {
p.evict()
}
// If still at capacity after eviction (e.g. everything is pinned),
// we still proceed — the caller should not be blocked.
dbPath := filepath.Join(p.config.DataDir, fmt.Sprintf("%d.db", userID))
udb, err := NewUserDB(dbPath, userID)
if err != nil {
return nil, fmt.Errorf("userdb pool: creating db for user %d: %w", userID, err)
}
p.dbs[userID] = &poolEntry{
db: udb,
lastAccess: time.Now(),
}
return udb, nil
}
// Pin marks a userID as having active playback. Pinned connections are
// never evicted.
func (p *UserDBPool) Pin(userID int) {
p.mu.Lock()
defer p.mu.Unlock()
p.pinned[userID] = true
}
// Unpin removes the active-playback mark from a userID, making it
// eligible for normal eviction again.
func (p *UserDBPool) Unpin(userID int) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.pinned, userID)
}
// Close closes every open database in the pool and resets internal state.
func (p *UserDBPool) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
var firstErr error
for uid, entry := range p.dbs {
if err := entry.db.Close(); err != nil && firstErr == nil {
firstErr = fmt.Errorf("closing db for user %d: %w", uid, err)
}
delete(p.dbs, uid)
}
// Clear pinned set.
for uid := range p.pinned {
delete(p.pinned, uid)
}
return firstErr
}
// tierFor returns the eviction tier of a pool entry.
func (p *UserDBPool) tierFor(userID int, entry *poolEntry, now time.Time) evictionTier {
if p.pinned[userID] {
return tierPinned
}
age := now.Sub(entry.lastAccess)
if age > p.config.IdleTimeout {
return tierCold
}
if age <= hotThreshold {
return tierHot
}
return tierWarm
}
// evictionCandidate pairs a userID with its tier and last-access time
// for sorting during eviction.
type evictionCandidate struct {
userID int
tier evictionTier
lastAccess time.Time
}
// evict removes entries from the pool until we are below MaxOpen.
// Eviction order (first evicted to last):
// - Cold (past idle_timeout) — always eligible
// - Warm (within idle_timeout) — standard LRU when pool full
// - Hot (within last 5 min) — only when pool >95% full
// - Pinned (active playback) — never evicted
//
// Within a tier, the least-recently-accessed entry is evicted first.
// Must be called with p.mu held.
func (p *UserDBPool) evict() {
now := time.Now()
target := len(p.dbs) - p.config.MaxOpen + 1 // free at least 1 slot
if target <= 0 {
return
}
isCritical := float64(len(p.dbs)) >= criticalPercent*float64(p.config.MaxOpen)
candidates := make([]evictionCandidate, 0, len(p.dbs))
for uid, entry := range p.dbs {
tier := p.tierFor(uid, entry, now)
if tier == tierPinned {
continue
}
// Hot entries only considered when critical.
if tier == tierHot && !isCritical {
continue
}
candidates = append(candidates, evictionCandidate{
userID: uid,
tier: tier,
lastAccess: entry.lastAccess,
})
}
// Sort: lower tier first (cold before warm before hot), then oldest
// lastAccess first within the same tier.
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].tier != candidates[j].tier {
return candidates[i].tier < candidates[j].tier
}
return candidates[i].lastAccess.Before(candidates[j].lastAccess)
})
evicted := 0
for _, c := range candidates {
if evicted >= target {
break
}
entry := p.dbs[c.userID]
entry.db.Close()
delete(p.dbs, c.userID)
evicted++
}
}
// Len returns the number of currently open connections. Useful for tests
// and metrics.
func (p *UserDBPool) Len() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.dbs)
}