check / check (push) Waiting to run
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
192 lines
5.1 KiB
Go
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++
|
|
}
|
|
}
|