From 2907a8ac1d17e724766099ac73cdfacb6b75cb25 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 15 May 2026 07:57:10 -0700 Subject: [PATCH] Phase C2+C3: atomic RunMaintenance + weekly integrity_check C2: wrap the 19-statement purge loop in a single BEGIN IMMEDIATE / COMMIT so a mid-maintenance crash can't leave half the cache tables purged. SetMaxOpenConns(1) guarantees the BEGIN, DELETEs, and COMMIT all share one SQLite connection. C3: run PRAGMA integrity_check at most once per week, gated on the mtime of a sentinel file (.integrity_check_last) under dataPath. Any result other than 'ok' is logged via slog.Error. The sentinel is touched even on failure so a corrupted DB doesn't re-run the check every cycle and spam logs. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/db/db.go | 74 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/internal/db/db.go b/internal/db/db.go index 8b29e68..35889b9 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -560,7 +560,23 @@ func RunMaintenance() { {"market_daily_summary", `DELETE FROM market_daily_summary WHERE snapshot_date < ?`, []interface{}{now.AddDate(-1, 0, 0).Format("2006-01-02")}}, } + // Wrap the purge loop in a single BEGIN IMMEDIATE / COMMIT so a crash + // mid-maintenance can't leave half the tables purged. SetMaxOpenConns(1) + // in Init guarantees the BEGIN, the DELETEs, and the COMMIT all share + // the same connection. totalDeleted := int64(0) + if _, err := d.Exec(`BEGIN IMMEDIATE`); err != nil { + slog.Error("maintenance: begin tx", "err", err) + return + } + committed := false + defer func() { + if !committed { + if _, err := d.Exec(`ROLLBACK`); err != nil { + slog.Error("maintenance: rollback", "err", err) + } + } + }() for _, q := range queries { res, err := d.Exec(q.sql, q.args...) if err != nil { @@ -573,15 +589,73 @@ func RunMaintenance() { totalDeleted += n } } + if _, err := d.Exec(`COMMIT`); err != nil { + slog.Error("maintenance: commit", "err", err) + return + } + committed = true // SQLite optimization if _, err := d.Exec(`PRAGMA optimize`); err != nil { slog.Error("maintenance: pragma optimize", "err", err) } + // Weekly integrity check, gated on a sentinel file's mtime under dataPath + // so it runs at most once every 7 days regardless of maintenance cadence. + runIntegrityCheckIfDue(d) + slog.Info("maintenance: complete", "total_purged", totalDeleted) } +// runIntegrityCheckIfDue runs `PRAGMA integrity_check` at most once per week. +// Cadence is tracked via the mtime of a sentinel file under dataPath; if the +// sentinel is missing or older than 7 days, the check runs and the sentinel +// is touched. Any result other than "ok" is logged via slog.Error. +func runIntegrityCheckIfDue(d *sql.DB) { + if dataPath == "" { + return + } + sentinel := filepath.Join(dataPath, ".integrity_check_last") + if info, err := os.Stat(sentinel); err == nil { + if time.Since(info.ModTime()) < 7*24*time.Hour { + return + } + } + + rows, err := d.Query(`PRAGMA integrity_check`) + if err != nil { + slog.Error("maintenance: integrity_check", "err", err) + return + } + var problems []string + for rows.Next() { + var line string + if err := rows.Scan(&line); err != nil { + slog.Error("maintenance: integrity_check scan", "err", err) + continue + } + if line != "ok" { + problems = append(problems, line) + } + } + rows.Close() + + if len(problems) > 0 { + slog.Error("maintenance: integrity_check failed", "problems", problems) + } else { + slog.Info("maintenance: integrity_check ok") + } + + // Touch the sentinel even on failure — otherwise a corrupted DB would + // re-run the (expensive) check every maintenance cycle and spam logs. + now := time.Now() + if err := os.WriteFile(sentinel, nil, 0o644); err != nil { + slog.Error("maintenance: write integrity sentinel", "err", err) + return + } + _ = os.Chtimes(sentinel, now, now) +} + // Exec runs a write query, logging any error with the given label. // Use for fire-and-forget writes where the error doesn't affect control flow. func Exec(label, query string, args ...interface{}) {