Ctrl K
ComunidadGuías…

internal/server/analytics_retention.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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
}