diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/config/config.go | 20 | ||||
| -rw-r--r-- | internal/monitor/scheduler.go | 33 | ||||
| -rw-r--r-- | internal/storage/maintenance.go | 338 | ||||
| -rw-r--r-- | internal/storage/sqlite.go | 107 |
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 +} |