aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/config/config.go20
-rw-r--r--internal/monitor/scheduler.go33
-rw-r--r--internal/storage/maintenance.go338
-rw-r--r--internal/storage/sqlite.go107
4 files changed, 473 insertions, 25 deletions
diff --git a/internal/config/config.go b/internal/config/config.go
index f97add5..250827b 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -73,8 +73,24 @@ type ServerConfig struct {
// StorageConfig contains database settings
type StorageConfig struct {
- Path string `yaml:"path"`
- HistoryDays int `yaml:"history_days"`
+ Path string `yaml:"path"`
+ HistoryDays int `yaml:"history_days"`
+ Maintenance MaintenanceConfig `yaml:"maintenance,omitempty"`
+}
+
+// MaintenanceConfig controls database maintenance/pruning behavior
+type MaintenanceConfig struct {
+ // Mode: "never" (default), "backup" (rename with epoch suffix), "reset" (delete in place)
+ Mode string `yaml:"mode,omitempty"`
+ Triggers MaintenanceTriggers `yaml:"triggers,omitempty"`
+}
+
+// MaintenanceTriggers defines when maintenance should occur (OR'd together)
+type MaintenanceTriggers struct {
+ Size string `yaml:"size,omitempty"` // e.g., "100MB", "1GB"
+ Checks int64 `yaml:"checks,omitempty"` // total check count threshold
+ Cron string `yaml:"cron,omitempty"` // cron expression, e.g., "0 3 * * 0"
+ Daily string `yaml:"daily,omitempty"` // daily time, e.g., "03:00"
}
// GroupConfig represents a group of monitors
diff --git a/internal/monitor/scheduler.go b/internal/monitor/scheduler.go
index ad1b7f6..809bab5 100644
--- a/internal/monitor/scheduler.go
+++ b/internal/monitor/scheduler.go
@@ -67,9 +67,11 @@ func (s *Scheduler) Start() {
go s.runMonitor(mon)
}
- // Start cleanup routine
s.wg.Add(1)
go s.runCleanup()
+
+ s.wg.Add(1)
+ go s.runMaintenance()
}
// Stop gracefully stops all monitors
@@ -193,13 +195,14 @@ func (s *Scheduler) executeCheck(mon Monitor) {
"id", mon.ID(),
"error", err)
}
+
+ s.storage.RecordCheck()
}
// runCleanup periodically cleans up old data
func (s *Scheduler) runCleanup() {
defer s.wg.Done()
- // Run cleanup daily
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
@@ -218,6 +221,32 @@ func (s *Scheduler) runCleanup() {
}
}
+func (s *Scheduler) runMaintenance() {
+ defer s.wg.Done()
+
+ if s.storage.GetMaintenanceMode() == "never" {
+ return
+ }
+
+ ticker := time.NewTicker(1 * time.Minute)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-s.ctx.Done():
+ return
+ case <-ticker.C:
+ ran, err := s.storage.CheckMaintenance()
+ if err != nil {
+ s.logger.Error("maintenance check failed", "error", err)
+ } else if ran {
+ s.logger.Info("database maintenance completed",
+ "mode", s.storage.GetMaintenanceMode())
+ }
+ }
+ }
+}
+
// GetMonitors returns all registered monitors
func (s *Scheduler) GetMonitors() []Monitor {
return s.monitors
diff --git a/internal/storage/maintenance.go b/internal/storage/maintenance.go
new file mode 100644
index 0000000..3018398
--- /dev/null
+++ b/internal/storage/maintenance.go
@@ -0,0 +1,338 @@
+package storage
+
+import (
+ "fmt"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Fuwn/kaze/internal/config"
+)
+
+type MaintenanceState struct {
+ lastCheck time.Time
+ lastDaily time.Time
+ nextCronTime time.Time
+ totalChecks int64
+ cfg config.MaintenanceConfig
+ dbPath string
+ sizeBytes int64
+ cronFields []cronField
+}
+
+type cronField struct {
+ values map[int]bool
+ any bool
+}
+
+func NewMaintenanceState(dbPath string, cfg config.MaintenanceConfig) (*MaintenanceState, error) {
+ m := &MaintenanceState{
+ cfg: cfg,
+ dbPath: dbPath,
+ lastCheck: time.Now(),
+ lastDaily: time.Now(),
+ }
+
+ if cfg.Triggers.Size != "" {
+ bytes, err := parseSize(cfg.Triggers.Size)
+ if err != nil {
+ return nil, fmt.Errorf("invalid size %q: %w", cfg.Triggers.Size, err)
+ }
+ m.sizeBytes = bytes
+ }
+
+ if cfg.Triggers.Cron != "" {
+ fields, err := parseCron(cfg.Triggers.Cron)
+ if err != nil {
+ return nil, fmt.Errorf("invalid cron %q: %w", cfg.Triggers.Cron, err)
+ }
+ m.cronFields = fields
+ m.nextCronTime = m.calculateNextCron(time.Now())
+ }
+
+ return m, nil
+}
+
+func (m *MaintenanceState) IncrementChecks() {
+ m.totalChecks++
+}
+
+func (m *MaintenanceState) ShouldRun() bool {
+ if m.cfg.Mode == "" || m.cfg.Mode == "never" {
+ return false
+ }
+
+ if m.checkSizeTrigger() {
+ return true
+ }
+
+ if m.checkChecksTrigger() {
+ return true
+ }
+
+ if m.checkCronTrigger() {
+ return true
+ }
+
+ if m.checkDailyTrigger() {
+ return true
+ }
+
+ return false
+}
+
+func (m *MaintenanceState) checkSizeTrigger() bool {
+ if m.sizeBytes == 0 {
+ return false
+ }
+
+ info, err := os.Stat(m.dbPath)
+ if err != nil {
+ return false
+ }
+
+ return info.Size() >= m.sizeBytes
+}
+
+func (m *MaintenanceState) checkChecksTrigger() bool {
+ if m.cfg.Triggers.Checks == 0 {
+ return false
+ }
+ return m.totalChecks >= m.cfg.Triggers.Checks
+}
+
+func (m *MaintenanceState) checkCronTrigger() bool {
+ if len(m.cronFields) == 0 {
+ return false
+ }
+
+ now := time.Now()
+ if now.After(m.nextCronTime) || now.Equal(m.nextCronTime) {
+ return true
+ }
+ return false
+}
+
+func (m *MaintenanceState) checkDailyTrigger() bool {
+ if m.cfg.Triggers.Daily == "" {
+ return false
+ }
+
+ parts := strings.Split(m.cfg.Triggers.Daily, ":")
+ if len(parts) != 2 {
+ return false
+ }
+
+ hour, err1 := strconv.Atoi(parts[0])
+ minute, err2 := strconv.Atoi(parts[1])
+ if err1 != nil || err2 != nil {
+ return false
+ }
+
+ now := time.Now()
+ today := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
+
+ if now.After(today) && m.lastDaily.Before(today) {
+ return true
+ }
+ return false
+}
+
+func (m *MaintenanceState) Execute() error {
+ switch m.cfg.Mode {
+ case "backup":
+ return m.executeBackup()
+ case "reset":
+ return m.executeReset()
+ default:
+ return nil
+ }
+}
+
+func (m *MaintenanceState) executeBackup() error {
+ epoch := time.Now().Unix()
+ backupPath := fmt.Sprintf("%s.%d", m.dbPath, epoch)
+
+ if err := os.Rename(m.dbPath, backupPath); err != nil {
+ return fmt.Errorf("failed to backup database: %w", err)
+ }
+
+ m.resetState()
+ return nil
+}
+
+func (m *MaintenanceState) executeReset() error {
+ if err := os.Remove(m.dbPath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to remove database: %w", err)
+ }
+
+ walPath := m.dbPath + "-wal"
+ if err := os.Remove(walPath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to remove WAL file: %w", err)
+ }
+
+ shmPath := m.dbPath + "-shm"
+ if err := os.Remove(shmPath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to remove SHM file: %w", err)
+ }
+
+ m.resetState()
+ return nil
+}
+
+func (m *MaintenanceState) resetState() {
+ m.totalChecks = 0
+ m.lastDaily = time.Now()
+ if len(m.cronFields) > 0 {
+ m.nextCronTime = m.calculateNextCron(time.Now())
+ }
+}
+
+func (m *MaintenanceState) calculateNextCron(from time.Time) time.Time {
+ t := from.Add(time.Minute).Truncate(time.Minute)
+
+ for i := 0; i < 366*24*60; i++ {
+ if m.cronMatches(t) {
+ return t
+ }
+ t = t.Add(time.Minute)
+ }
+
+ return from.Add(24 * time.Hour)
+}
+
+func (m *MaintenanceState) cronMatches(t time.Time) bool {
+ if len(m.cronFields) != 5 {
+ return false
+ }
+
+ minute := t.Minute()
+ hour := t.Hour()
+ dayOfMonth := t.Day()
+ month := int(t.Month())
+ dayOfWeek := int(t.Weekday())
+
+ return (m.cronFields[0].any || m.cronFields[0].values[minute]) &&
+ (m.cronFields[1].any || m.cronFields[1].values[hour]) &&
+ (m.cronFields[2].any || m.cronFields[2].values[dayOfMonth]) &&
+ (m.cronFields[3].any || m.cronFields[3].values[month]) &&
+ (m.cronFields[4].any || m.cronFields[4].values[dayOfWeek])
+}
+
+func parseSize(s string) (int64, error) {
+ s = strings.TrimSpace(strings.ToUpper(s))
+
+ re := regexp.MustCompile(`^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$`)
+ matches := re.FindStringSubmatch(s)
+ if matches == nil {
+ return 0, fmt.Errorf("invalid size format")
+ }
+
+ value, err := strconv.ParseFloat(matches[1], 64)
+ if err != nil {
+ return 0, err
+ }
+
+ unit := matches[2]
+ if unit == "" {
+ unit = "B"
+ }
+
+ multipliers := map[string]float64{
+ "B": 1,
+ "KB": 1024,
+ "MB": 1024 * 1024,
+ "GB": 1024 * 1024 * 1024,
+ "TB": 1024 * 1024 * 1024 * 1024,
+ }
+
+ return int64(value * multipliers[unit]), nil
+}
+
+func parseCron(expr string) ([]cronField, error) {
+ parts := strings.Fields(expr)
+ if len(parts) != 5 {
+ return nil, fmt.Errorf("cron must have 5 fields (minute hour day month weekday)")
+ }
+
+ limits := []struct{ min, max int }{
+ {0, 59},
+ {0, 23},
+ {1, 31},
+ {1, 12},
+ {0, 6},
+ }
+
+ fields := make([]cronField, 5)
+ for i, part := range parts {
+ f, err := parseCronField(part, limits[i].min, limits[i].max)
+ if err != nil {
+ return nil, fmt.Errorf("field %d: %w", i+1, err)
+ }
+ fields[i] = f
+ }
+
+ return fields, nil
+}
+
+func parseCronField(field string, min, max int) (cronField, error) {
+ cf := cronField{values: make(map[int]bool)}
+
+ if field == "*" {
+ cf.any = true
+ return cf, nil
+ }
+
+ for _, part := range strings.Split(field, ",") {
+ if strings.Contains(part, "/") {
+ stepParts := strings.Split(part, "/")
+ if len(stepParts) != 2 {
+ return cf, fmt.Errorf("invalid step %q", part)
+ }
+
+ step, err := strconv.Atoi(stepParts[1])
+ if err != nil || step <= 0 {
+ return cf, fmt.Errorf("invalid step value %q", stepParts[1])
+ }
+
+ start := min
+ end := max
+ if stepParts[0] != "*" {
+ rangeParts := strings.Split(stepParts[0], "-")
+ start, _ = strconv.Atoi(rangeParts[0])
+ if len(rangeParts) == 2 {
+ end, _ = strconv.Atoi(rangeParts[1])
+ } else {
+ end = max
+ }
+ }
+
+ for i := start; i <= end; i += step {
+ cf.values[i] = true
+ }
+ } else if strings.Contains(part, "-") {
+ rangeParts := strings.Split(part, "-")
+ if len(rangeParts) != 2 {
+ return cf, fmt.Errorf("invalid range %q", part)
+ }
+ start, err1 := strconv.Atoi(rangeParts[0])
+ end, err2 := strconv.Atoi(rangeParts[1])
+ if err1 != nil || err2 != nil {
+ return cf, fmt.Errorf("invalid range values")
+ }
+ for i := start; i <= end; i++ {
+ cf.values[i] = true
+ }
+ } else {
+ val, err := strconv.Atoi(part)
+ if err != nil {
+ return cf, fmt.Errorf("invalid value %q", part)
+ }
+ cf.values[val] = true
+ }
+ }
+
+ return cf, nil
+}
diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go
index 3d89cb1..db0ed52 100644
--- a/internal/storage/sqlite.go
+++ b/internal/storage/sqlite.go
@@ -7,13 +7,16 @@ import (
"strings"
"time"
+ "github.com/Fuwn/kaze/internal/config"
_ "modernc.org/sqlite"
)
// Storage handles all database operations
type Storage struct {
db *sql.DB
+ dbPath string
historyDays int
+ maintenance *MaintenanceState
}
// CheckResult represents a single monitor check result
@@ -71,11 +74,40 @@ type TickData struct {
// New creates a new storage instance
func New(dbPath string, historyDays int) (*Storage, error) {
- // Add connection parameters for better concurrency handling
- // _txlock=immediate ensures transactions acquire locks immediately
- // _busy_timeout=5000 waits up to 5 seconds for locks
- // _journal_mode=WAL enables write-ahead logging for better concurrency
- // _synchronous=NORMAL balances safety and performance
+ return NewWithMaintenance(dbPath, historyDays, config.MaintenanceConfig{})
+}
+
+// NewWithMaintenance creates a new storage instance with maintenance configuration
+func NewWithMaintenance(dbPath string, historyDays int, maintenanceCfg config.MaintenanceConfig) (*Storage, error) {
+ db, err := openDB(dbPath)
+ if err != nil {
+ return nil, err
+ }
+
+ s := &Storage{
+ db: db,
+ dbPath: dbPath,
+ historyDays: historyDays,
+ }
+
+ if maintenanceCfg.Mode != "" && maintenanceCfg.Mode != "never" {
+ m, err := NewMaintenanceState(dbPath, maintenanceCfg)
+ if err != nil {
+ db.Close()
+ return nil, fmt.Errorf("failed to initialize maintenance: %w", err)
+ }
+ s.maintenance = m
+ }
+
+ if err := s.migrate(); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("failed to run migrations: %w", err)
+ }
+
+ return s, nil
+}
+
+func openDB(dbPath string) (*sql.DB, error) {
dsn := fmt.Sprintf("%s?_txlock=immediate&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL", dbPath)
db, err := sql.Open("sqlite", dsn)
@@ -83,36 +115,22 @@ func New(dbPath string, historyDays int) (*Storage, error) {
return nil, fmt.Errorf("failed to open database: %w", err)
}
- // CRITICAL: Set max open connections to 1 for SQLite
- // SQLite only supports one writer at a time, so we serialize all writes
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
- db.SetConnMaxLifetime(0) // Don't close idle connections
+ db.SetConnMaxLifetime(0)
- // Verify WAL mode is enabled
var journalMode string
if err := db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
db.Close()
return nil, fmt.Errorf("failed to check journal mode: %w", err)
}
- // Enable foreign keys (must be done per-connection, but with 1 conn it's fine)
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
}
- s := &Storage{
- db: db,
- historyDays: historyDays,
- }
-
- if err := s.migrate(); err != nil {
- db.Close()
- return nil, fmt.Errorf("failed to run migrations: %w", err)
- }
-
- return s, nil
+ return db, nil
}
// migrate creates the database schema
@@ -769,3 +787,50 @@ func (s *Storage) ResetMonitorData(ctx context.Context, monitorName string) erro
func (s *Storage) Close() error {
return s.db.Close()
}
+
+func (s *Storage) RecordCheck() {
+ if s.maintenance != nil {
+ s.maintenance.IncrementChecks()
+ }
+}
+
+func (s *Storage) CheckMaintenance() (bool, error) {
+ if s.maintenance == nil {
+ return false, nil
+ }
+
+ if !s.maintenance.ShouldRun() {
+ return false, nil
+ }
+
+ if err := s.db.Close(); err != nil {
+ return false, fmt.Errorf("failed to close database before maintenance: %w", err)
+ }
+
+ if err := s.maintenance.Execute(); err != nil {
+ db, openErr := openDB(s.dbPath)
+ if openErr == nil {
+ s.db = db
+ }
+ return false, fmt.Errorf("maintenance failed: %w", err)
+ }
+
+ db, err := openDB(s.dbPath)
+ if err != nil {
+ return true, fmt.Errorf("failed to reopen database after maintenance: %w", err)
+ }
+ s.db = db
+
+ if err := s.migrate(); err != nil {
+ return true, fmt.Errorf("failed to migrate after maintenance: %w", err)
+ }
+
+ return true, nil
+}
+
+func (s *Storage) GetMaintenanceMode() string {
+ if s.maintenance == nil {
+ return "never"
+ }
+ return s.maintenance.cfg.Mode
+}