internal/server/analytics_retention.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
appdb "github.com/X4Applegate/caddyui/internal/db"
"github.com/X4Applegate/caddyui/internal/models"
)
// v2.43.0: analytics retention.
//
// pruneAccessLoop existed since v2.7.0 but nothing ever started it, so
// access_events grew without bound: one production database reached 22 GB
// with 63 million rows older than the intended 30 days. The loop is now
// started with the other pollers, the retention is a setting, deletes run
// in batches so the write lock is never held for long, and Settings →
// Analytics shows what the table holds with Prune now and Reclaim space
// actions. Reclaiming (VACUUM) is what actually shrinks the SQLite file —
// deleting rows only frees pages inside it.
const (
settingAnalyticsRetentionDays = "analytics_retention_days" // "" = default; "0" = keep forever
settingAnalyticsPruneStatus = "analytics_prune_status"
settingAnalyticsVacuumStatus = "analytics_vacuum_status"
defaultAnalyticsRetentionDays = 30
maxAnalyticsRetentionDays = 3650
// v2.43.1: 5,000 rows per batch and a pause between batches. The SQLite
// pool is one connection, so a prune that never yields blocks every page
// request until it finishes — 20,000-row batches back to back made the
// UI take six seconds per page for the whole first run.
accessPruneBatch = 5000
accessPruneBatchPause = 200 * time.Millisecond
accessPruneEvery = time.Hour
accessPruneFirstAfter = 60 * time.Second
)
// analyticsRetentionDays is the configured retention: default 30, 0 = keep
// forever, clamped to [1, 3650] otherwise.
func analyticsRetentionDays(s *Server) int {
raw := strings.TrimSpace(mustGetSetting(s.DB, settingAnalyticsRetentionDays))
if raw == "" {
return defaultAnalyticsRetentionDays
}
n, err := strconv.Atoi(raw)
if err != nil {
return defaultAnalyticsRetentionDays
}
if n <= 0 {
return 0
}
if n > maxAnalyticsRetentionDays {
return maxAnalyticsRetentionDays
}
return n
}
type accessPruneStatus struct {
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Cutoff *time.Time `json:"cutoff,omitempty"`
Deleted int64 `json:"deleted"`
Remaining int64 `json:"remaining"` // -1 = not counted
Error string `json:"error,omitempty"`
Manual bool `json:"manual,omitempty"`
}
type analyticsVacuumStatus struct {
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
BeforeBytes int64 `json:"before_bytes"`
AfterBytes int64 `json:"after_bytes"`
Error string `json:"error,omitempty"`
}
func (s *Server) storeJSONSetting(key string, v any) {
raw, err := json.Marshal(v)
if err != nil {
return
}
if err := models.SetSetting(s.DB, key, string(raw)); err != nil {
log.Printf("analytics: store %s: %v", key, err)
}
}
func (s *Server) lastPruneStatus() *accessPruneStatus {
raw, err := models.GetSetting(s.DB, settingAnalyticsPruneStatus)
if err != nil || strings.TrimSpace(raw) == "" {
return nil
}
var st accessPruneStatus
if json.Unmarshal([]byte(raw), &st) != nil {
return nil
}
return &st
}
func (s *Server) lastVacuumStatus() *analyticsVacuumStatus {
raw, err := models.GetSetting(s.DB, settingAnalyticsVacuumStatus)
if err != nil || strings.TrimSpace(raw) == "" {
return nil
}
var st analyticsVacuumStatus
if json.Unmarshal([]byte(raw), &st) != nil {
return nil
}
return &st
}
// runAccessPrune deletes events older than the retention, in batches, and
// records the outcome. It is serialized: a scheduled pass and a manual one
// never overlap. Returns the status it recorded.
func (s *Server) runAccessPrune(manual bool) accessPruneStatus {
s.accessPruneMu.Lock()
defer s.accessPruneMu.Unlock()
st := accessPruneStatus{StartedAt: time.Now().UTC(), Remaining: -1, Manual: manual}
days := analyticsRetentionDays(s)
if days == 0 {
done := time.Now().UTC()
st.FinishedAt = &done
s.storeJSONSetting(settingAnalyticsPruneStatus, st)
return st
}
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour).UTC()
st.Cutoff = &cutoff
s.storeJSONSetting(settingAnalyticsPruneStatus, st) // visible as "running"
mariadb := appdb.BackendOf(s.DB) == appdb.BackendMariaDB
var lastLogged int64
deleted, err := models.PruneAccessEventsBatched(s.DB, cutoff, accessPruneBatch, mariadb, accessPruneBatchPause, nil, func(total int64) {
if total-lastLogged >= 1000000 {
lastLogged = total
log.Printf("analytics: prune in progress — %d events removed so far", total)
}
})
st.Deleted = deleted
if err != nil {
st.Error = err.Error()
log.Printf("analytics: prune error after %d rows: %v", deleted, err)
} else {
if n, countErr := models.CountAccessEvents(s.DB); countErr == nil {
st.Remaining = n
}
if deleted > 0 {
log.Printf("analytics: pruned %d events older than %s (%d days); %d remain", deleted, cutoff.Format(time.RFC3339), days, st.Remaining)
}
}
done := time.Now().UTC()
st.FinishedAt = &done
s.storeJSONSetting(settingAnalyticsPruneStatus, st)
return st
}
// pruneAccessLoop runs the first prune shortly after start-up, then hourly.
// Started by New with the other pollers (it never was before v2.43.0).
func (s *Server) pruneAccessLoop() {
timer := time.NewTimer(accessPruneFirstAfter)
defer timer.Stop()
for {
<-timer.C
s.runAccessPrune(false)
timer.Reset(accessPruneEvery)
}
}
// startAccessPrune runs a manual prune in the background unless one is
// already running. Returns false when it was.
func (s *Server) startAccessPrune() bool {
if !s.accessPruneMu.TryLock() {
return false
}
s.accessPruneMu.Unlock()
go s.runAccessPrune(true)
return true
}
// analyticsStorage is what Settings → Analytics shows about the table and
// the database file.
type analyticsStorage struct {
SQLite bool
DBPath string
DBBytes int64
FreePageBytes int64 // pages freed by deletes, not yet returned to the OS
DiskFreeBytes int64
Oldest time.Time
Newest time.Time
RetentionDays int
LastPrune *accessPruneStatus
PruneRunning bool
LastVacuum *analyticsVacuumStatus
VacuumRunning bool
VacuumPossible bool
VacuumBlockedBy string
}
func (s *Server) analyticsStorageView() analyticsStorage {
v := analyticsStorage{
SQLite: appdb.BackendOf(s.DB) != appdb.BackendMariaDB,
DBPath: s.DBPath,
RetentionDays: analyticsRetentionDays(s),
LastPrune: s.lastPruneStatus(),
LastVacuum: s.lastVacuumStatus(),
}
if v.LastPrune != nil && v.LastPrune.FinishedAt == nil {
v.PruneRunning = true
}
if v.LastVacuum != nil && v.LastVacuum.FinishedAt == nil {
v.VacuumRunning = true
}
if oldest, newest, err := models.AccessEventBounds(s.DB); err == nil {
v.Oldest, v.Newest = oldest, newest
}
if v.SQLite {
if s.DBPath != "" {
if fi, err := os.Stat(s.DBPath); err == nil {
v.DBBytes = fi.Size()
}
v.DiskFreeBytes = diskFreeBytes(filepath.Dir(s.DBPath))
}
var pageSize, freelist int64
if err := s.DB.QueryRow(`PRAGMA page_size`).Scan(&pageSize); err == nil {
if err := s.DB.QueryRow(`PRAGMA freelist_count`).Scan(&freelist); err == nil {
v.FreePageBytes = pageSize * freelist
}
}
switch {
case s.DBPath == "":
v.VacuumBlockedBy = "the database path is not known to this process"
case v.VacuumRunning:
v.VacuumBlockedBy = "a reclaim is already running"
case v.DiskFreeBytes > 0 && v.DiskFreeBytes < v.DBBytes+v.DBBytes/10:
v.VacuumBlockedBy = fmt.Sprintf("only %s free on the data volume; reclaiming needs room for a full copy (%s)", fmtBytesShort(v.DiskFreeBytes), fmtBytesShort(v.DBBytes))
default:
v.VacuumPossible = true
}
}
return v
}
func diskFreeBytes(dir string) int64 {
var st syscall.Statfs_t
if err := syscall.Statfs(dir, &st); err != nil {
return 0
}
return int64(st.Bavail) * int64(st.Bsize)
}
func fmtBytesShort(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
// startVacuum runs VACUUM in the background (SQLite only). Deleted rows only
// free pages inside the file; VACUUM rewrites it without them. It needs room
// for a full copy next to the database and blocks writers while it runs, so
// it is an explicit action with a status, never automatic.
func (s *Server) startVacuum() error {
view := s.analyticsStorageView()
if !view.SQLite {
return fmt.Errorf("reclaiming space is a SQLite operation; on MariaDB run OPTIMIZE TABLE access_events")
}
if !view.VacuumPossible {
return fmt.Errorf("cannot reclaim space now: %s", view.VacuumBlockedBy)
}
if !s.vacuumMu.TryLock() {
return fmt.Errorf("a reclaim is already running")
}
st := analyticsVacuumStatus{StartedAt: time.Now().UTC(), BeforeBytes: view.DBBytes}
s.storeJSONSetting(settingAnalyticsVacuumStatus, st)
// SQLite writes VACUUM's working copy to a temp directory; the scratch
// image has no /tmp, so point it at the data volume when nothing else
// was set (the Dockerfile sets SQLITE_TMPDIR=/data too).
if os.Getenv("SQLITE_TMPDIR") == "" {
_ = os.Setenv("SQLITE_TMPDIR", filepath.Dir(s.DBPath))
}
go func() {
defer s.vacuumMu.Unlock()
_, err := s.DB.Exec("VACUUM")
if err == nil {
// In WAL mode the rewritten database reaches the main file, and
// the file is truncated, only at a checkpoint — do it now so the
// new size is real rather than pending.
_, _ = s.DB.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
}
done := time.Now().UTC()
st.FinishedAt = &done
if err != nil {
st.Error = err.Error()
log.Printf("analytics: reclaim space (VACUUM): %v", err)
} else if fi, statErr := os.Stat(s.DBPath); statErr == nil {
st.AfterBytes = fi.Size()
log.Printf("analytics: reclaimed space: database %s → %s", fmtBytesShort(st.BeforeBytes), fmtBytesShort(st.AfterBytes))
}
s.storeJSONSetting(settingAnalyticsVacuumStatus, st)
_ = models.LogActivity(s.DB, 0, "system", "analytics_vacuum", "database", fmt.Sprintf("%s → %s", fmtBytesShort(st.BeforeBytes), fmtBytesShort(st.AfterBytes)), err == nil)
}()
return nil
}
// pruneAnalyticsHandler: POST /settings/analytics/prune — Prune now.
func (s *Server) pruneAnalyticsHandler(w http.ResponseWriter, r *http.Request) {
if !s.startAccessPrune() {
log.Printf("analytics: manual prune requested while one is running")
} else {
_ = models.LogActivity(s.DB, 0, s.currentUserEmail(r), "analytics_prune", "access_events", fmt.Sprintf("manual prune, retention %d days", analyticsRetentionDays(s)), true)
}
http.Redirect(w, r, "/settings/analytics", http.StatusSeeOther)
}
// vacuumAnalyticsHandler: POST /settings/analytics/vacuum — Reclaim space.
func (s *Server) vacuumAnalyticsHandler(w http.ResponseWriter, r *http.Request) {
if err := s.startVacuum(); err != nil {
_ = models.LogActivity(s.DB, 0, s.currentUserEmail(r), "analytics_vacuum", "database", err.Error(), false)
}
http.Redirect(w, r, "/settings/analytics", http.StatusSeeOther)
}
// parseAnalyticsRetentionDays validates the settings form value: blank keeps
// the current value, 0 = keep forever, else 1..3650.
func parseAnalyticsRetentionDays(raw string, current int) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return current, nil
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 || n > maxAnalyticsRetentionDays {
return 0, fmt.Errorf("analytics retention must be between 0 (keep forever) and %d days", maxAnalyticsRetentionDays)
}
return n, nil
}