Files
routewatch/internal/routewatch/dbmaintainer.go
T
sneak 793c75751d
check / check (push) Waiting to run
Re-vendor the shared files from sneak/prompts at dd4027b (closes #52)
Takes .golangci.yml, .dockerignore, .gitignore, .editorconfig, the
workflow and REPO_POLICIES.md from sneak/prompts dd4027b, keeping this
repository's own entries after the shared content. Lint (golangci-lint
v2.14.0) and test become Dockerfile phases that script/lint and
script/test build alone and uncached; the image build depends on both,
and script/cibuild bootstraps, runs script/check, then builds the image.
The agent guidance file moves to AGENTS.md. The Go code is fixed for
what the new configuration finds; reads that iterate rows now also
report an iteration error.

Model: opus-5-5
2026-10-14 20:03:00 +00:00

192 lines
5.1 KiB
Go

package routewatch
import (
"context"
"log/slog"
"sync"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
)
// Database maintenance configuration constants.
const (
// checkpointInterval is how often to run WAL checkpoint.
// Frequent checkpoints keep the WAL small, improving read performance.
// Under heavy write load, we need aggressive checkpointing.
checkpointInterval = 5 * time.Second
// vacuumInterval is how often to run incremental vacuum.
// Since incremental vacuum only frees ~1000 pages (~4MB) per run,
// we run it frequently to keep up with deletions.
vacuumInterval = 10 * time.Minute
// analyzeInterval is how often to run ANALYZE.
analyzeInterval = 1 * time.Hour
// checkpointTimeout is the max time for WAL checkpoint.
checkpointTimeout = 10 * time.Second
// vacuumTimeout is the max time for incremental vacuum (should be quick).
vacuumTimeout = 30 * time.Second
// analyzeTimeout is the max time for ANALYZE.
analyzeTimeout = 5 * time.Minute
)
// DBMaintainer handles background database maintenance tasks.
type DBMaintainer struct {
db database.Store
logger *slog.Logger
stopCh chan struct{}
wg sync.WaitGroup
// Stats tracking
statsMu sync.Mutex
lastCheckpoint time.Time
lastVacuum time.Time
lastAnalyze time.Time
checkpointCount int
vacuumCount int
analyzeCount int
lastCheckpointError error
lastVacuumError error
lastAnalyzeError error
}
// NewDBMaintainer creates a new database maintainer.
func NewDBMaintainer(db database.Store, logger *slog.Logger) *DBMaintainer {
return &DBMaintainer{
db: db,
logger: logger.With("component", "db_maintainer"),
stopCh: make(chan struct{}),
}
}
// Start begins the background maintenance goroutine. It runs until Stop:
// the end of ctx does not stop it.
func (m *DBMaintainer) Start(ctx context.Context) {
m.wg.Add(1)
go m.run(context.WithoutCancel(ctx))
m.logger.Info("Database maintainer started",
"checkpoint_interval", checkpointInterval,
"vacuum_interval", vacuumInterval,
"analyze_interval", analyzeInterval,
)
}
// Stop gracefully shuts down the maintainer.
func (m *DBMaintainer) Stop() {
close(m.stopCh)
m.wg.Wait()
m.logger.Info("Database maintainer stopped")
}
// run is the main background loop.
func (m *DBMaintainer) run(ctx context.Context) {
defer m.wg.Done()
// Use different timers for each task
checkpointTimer := time.NewTimer(checkpointInterval)
defer checkpointTimer.Stop()
vacuumTimer := time.NewTimer(vacuumInterval)
defer vacuumTimer.Stop()
analyzeTimer := time.NewTimer(analyzeInterval)
defer analyzeTimer.Stop()
for {
select {
case <-m.stopCh:
return
case <-checkpointTimer.C:
m.runCheckpoint(ctx)
checkpointTimer.Reset(checkpointInterval)
case <-vacuumTimer.C:
m.runVacuum(ctx)
vacuumTimer.Reset(vacuumInterval)
case <-analyzeTimer.C:
m.runAnalyze(ctx)
analyzeTimer.Reset(analyzeInterval)
}
}
}
// runCheckpoint performs a WAL checkpoint to keep the WAL file small.
func (m *DBMaintainer) runCheckpoint(ctx context.Context) {
duration, err := runWithTimeout(ctx, checkpointTimeout, m.db.Checkpoint)
m.recordResult(&m.lastCheckpoint, &m.lastCheckpointError, &m.checkpointCount, err)
if err != nil {
m.logger.Error("WAL checkpoint failed", "error", err, "duration", duration)
} else {
m.logger.Debug("WAL checkpoint completed", "duration", duration)
}
}
// runVacuum performs an incremental vacuum operation on the database.
func (m *DBMaintainer) runVacuum(ctx context.Context) {
m.logger.Debug("Running incremental vacuum")
duration, err := runWithTimeout(ctx, vacuumTimeout, m.db.Vacuum)
m.recordResult(&m.lastVacuum, &m.lastVacuumError, &m.vacuumCount, err)
if err != nil {
m.logger.Error("Incremental vacuum failed", "error", err, "duration", duration)
} else {
m.logger.Debug("Incremental vacuum completed", "duration", duration)
}
}
// runAnalyze performs an ANALYZE operation on the database.
func (m *DBMaintainer) runAnalyze(ctx context.Context) {
m.logger.Info("Starting database ANALYZE")
duration, err := runWithTimeout(ctx, analyzeTimeout, m.db.Analyze)
m.recordResult(&m.lastAnalyze, &m.lastAnalyzeError, &m.analyzeCount, err)
if err != nil {
m.logger.Error("ANALYZE failed", "error", err, "duration", duration)
} else {
m.logger.Info("ANALYZE completed", "duration", duration)
}
}
// runWithTimeout runs operation with a context that ends after timeout and
// returns how long it took and its error.
func runWithTimeout(
ctx context.Context,
timeout time.Duration,
operation func(context.Context) error,
) (time.Duration, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
startTime := time.Now()
err := operation(ctx)
return time.Since(startTime), err
}
// recordResult stores when a maintenance task last ran and its error, and
// counts it when it succeeded.
func (m *DBMaintainer) recordResult(
lastRun *time.Time, lastErr *error, successCount *int, err error,
) {
m.statsMu.Lock()
defer m.statsMu.Unlock()
*lastRun = time.Now()
*lastErr = err
if err == nil {
*successCount++
}
}