check / check (push) Canceled after 0s
Takes .golangci.yml, .dockerignore, .gitignore, .editorconfig, the workflow and REPO_POLICIES.md from sneak/prompts 0653579, with this repository's own entries after the shared content. Lint (golangci-lint v2.14.0) and test are Dockerfile phases that script/lint and script/test build alone with a new CACHEBUST value, keeping Go's and golangci-lint's caches; the image build depends on both. make fmt and make fmt-check also format the markdown with prettier, pinned in yarn.lock. The agent guidance moves to AGENTS.md. The Go code is fixed for what the new configuration finds, and the AS and prefix detail reads now return the rows' error after their loops. 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++
|
|
}
|
|
}
|