munin/internal/stats/stats.go
2026-10-19 10:13:56 +02:00

290 lines
9.9 KiB
Go

// Package stats zählt die Kennzahlen aller Workspaces für die Systemseite
// zusammen (AUSBAUSTUFEN.md 5.16): Bestand, Platz, Stand des Schemas und die
// Aktivität der letzten 30 Tage.
//
// # Warum nicht bei jedem Seitenaufruf?
//
// Für die Zahlen fragt Munin jeden Workspace einzeln ab — bei 40 Workspaces
// rund 300 Abfragen. Das ist kein Problem, wenn es selten geschieht, aber
// unnötig bei jedem Neuladen der Seite. Der Sammler rechnet deshalb höchstens
// alle MinInterval neu und gibt dazwischen das gemerkte Ergebnis zurück.
// Die Seite nennt den Stand ("Stand 14:05").
//
// Gerechnet wird beim Aufruf der Seite, nicht in einer eigenen Goroutine: Wer
// die Seite nie öffnet, kostet nichts, und es gibt keinen Hintergrundlauf,
// der beim Beenden aufgeräumt werden müsste.
package stats
import (
"context"
"fmt"
"sort"
"sync"
"time"
"munin/internal/store"
"munin/internal/workspace"
)
// Days ist der Zeitraum der Aktivität. Derselbe wie in der Nutzung eines
// Projekts (store.UsageWindow) — und nicht länger, weil das Suchprotokoll
// nach 30 Tagen aufgeräumt wird. Ältere Tage stünden sonst ohne Suchen da.
const Days = store.UsageWindowDays
// MinInterval ist die Zeit, die ein Ergebnis gilt, bevor neu gerechnet
// wird.
const MinInterval = 5 * time.Minute
// Snapshot ist das Ergebnis einer Erhebung.
type Snapshot struct {
CollectedAt time.Time
Duration time.Duration // wie lange die Erhebung brauchte
Workspaces []Workspace // nach Name sortiert
Total store.Inventory
DiskUsage store.DiskUsage // aller Workspaces zusammen
// SchemaVersion: die neueste Migration, die dieses Programm kennt. Ein
// Workspace, der davon abweicht, steht mit seinem Stand in Workspace.Schema.
SchemaVersion string
Days []Tag // genau Tage Einträge, der älteste zuerst, heute zuletzt
Projects []Project // mit Aktivität, die aktivsten zuerst
Idle []Project // ohne jede Aktivität im Zeitraum, nach Workspace und Name
// WaitCount zählt, wie oft Anfragen seit dem Start auf eine freie
// Datenbankverbindung warten mussten (alle Pools zusammen), zum Zeitpunkt
// der Erhebung. PrevWaitCount und PrevCollectedAt sind dieselbe Zahl bei
// der vorigen Erhebung (0 und Nullzeitpunkt bei der ersten). Die
// Differenz zeigt, ob gerade gewartet wird — die Summe seit dem Start
// sagt das nach Tagen Laufzeit nicht mehr.
WaitCount int64
PrevWaitCount int64
PrevCollectedAt time.Time
// Errors sind Workspaces, die sich nicht lesen ließen. Ihre Zahlen fehlen
// in den Summen; die übrigen sind trotzdem da.
Errors []string
}
// Workspace sind die Kennzahlen eines Workspace.
type Workspace struct {
ID, Name string
Location string // Datei oder Schema (store.Backend.Ort)
Inventory store.Inventory
DiskUsage store.DiskUsage
Schema string // zuletzt angewandte Migration
}
// SchemaMismatch sagt, ob der Workspace auf einem anderen Stand ist als das
// Programm.
func (w Workspace) SchemaMismatch(expected string) bool { return w.Schema != expected }
// Tag ist die Aktivität eines Tages über alle Workspaces.
type Tag struct {
Date time.Time // 0 Uhr UTC
AgentWrites int
HumanWrites int
Searches int
}
// Writes sind alle Schreibvorgänge des Tages.
func (t Tag) Writes() int { return t.AgentWrites + t.HumanWrites }
// Project ist die Aktivität eines Projekts im Zeitraum.
type Project struct {
Workspace string // Name
ID string
Slug string
Name string
AgentWrites int
HumanWrites int
Searches int
}
// Writes sind alle Schreibvorgänge des Projekts im Zeitraum.
func (p Project) Writes() int { return p.AgentWrites + p.HumanWrites }
// Total ist alles zusammen — danach wird sortiert.
func (p Project) Total() int { return p.Writes() + p.Searches }
// Collector erhebt den Stand und merkt ihn sich für MinInterval.
type Collector struct {
control *store.ControlStore
workspaces *workspace.Manager
// mu schützt stand und sorgt zugleich dafür, dass bei zwei
// gleichzeitigen Aufrufen nur einer rechnet; der zweite wartet und
// bekommt dasselbe Ergebnis.
mu sync.Mutex
snapshot Snapshot
}
// NewCollector legt einen Sammler an.
func NewCollector(control *store.ControlStore, workspaces *workspace.Manager) *Collector {
return &Collector{control: control, workspaces: workspaces}
}
// Current liefert einen Stand, der höchstens MinInterval alt ist, und
// erhebt dafür nötigenfalls neu.
func (s *Collector) Current(ctx context.Context, now time.Time) Snapshot {
s.mu.Lock()
defer s.mu.Unlock()
if !s.snapshot.CollectedAt.IsZero() && now.Sub(s.snapshot.CollectedAt) < MinInterval {
return s.snapshot
}
newer := s.collect(ctx, now)
newer.PrevWaitCount, newer.PrevCollectedAt = s.snapshot.WaitCount, s.snapshot.CollectedAt
s.snapshot = newer
return s.snapshot
}
// WaitCount zählt die Wartevorgänge aller gerade offenen Pools zusammen: der
// Kontroll-Datenbank und jedes geöffneten Workspace.
func (s *Collector) WaitCount() int64 {
total := s.control.PoolStats().WaitCount()
for _, w := range s.workspaces.OpenWorkspaces() {
total += w.Store.PoolStats().WaitCount()
}
return total
}
// ForWorkspace erhebt den Stand eines einzelnen Workspace — für den
// Überblick des Workspace-Admins (AUSBAUSTUFEN.md 5.17). Ohne Zwischenspeicher:
// Ein Workspace sind nur eine Handvoll Abfragen, und der Admin soll nach
// einer Änderung (Key angelegt) sofort den neuen Stand sehen.
func (s *Collector) ForWorkspace(ctx context.Context, w store.Workspace, now time.Time) Snapshot {
return s.collectAll(ctx, now, []store.Workspace{w})
}
// collect fragt jeden Workspace ab und zählt zusammen.
//
// Dafür werden alle Workspaces geöffnet, auch lange nicht benutzte. Das tun
// das tägliche Aufräumen und der Export (main.go) ohnehin schon beim Start;
// zusätzliche Verbindungen kostet die Erhebung also nicht.
func (s *Collector) collect(ctx context.Context, now time.Time) Snapshot {
all, err := s.control.ListWorkspaces(ctx)
if err != nil {
return Snapshot{CollectedAt: now, Errors: []string{fmt.Sprintf("Workspaces konnten nicht gelesen werden: %v", err)}}
}
return s.collectAll(ctx, now, all)
}
// collectAll zählt die gegebenen Workspaces zusammen.
func (s *Collector) collectAll(ctx context.Context, now time.Time, all []store.Workspace) Snapshot {
begin := time.Now()
snapshot := Snapshot{CollectedAt: now}
// Die Tage stehen von vornherein fest, auch die ohne jede Aktivität —
// sonst hätte das Diagramm Lücken, die man nicht sieht.
today := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
first := today.AddDate(0, 0, -(Days - 1))
tagNum := map[string]int{}
for i := 0; i < Days; i++ {
d := first.AddDate(0, 0, i)
snapshot.Days = append(snapshot.Days, Tag{Date: d})
tagNum[d.Format("2006-01-02")] = i
}
for _, w := range all {
ws, projects, err := s.collectWorkspace(ctx, w, now, first, &snapshot, tagNum)
if err != nil {
snapshot.Errors = append(snapshot.Errors, fmt.Sprintf("%s: %v", w.Name, err))
continue
}
snapshot.Workspaces = append(snapshot.Workspaces, ws)
snapshot.Total = snapshot.Total.Plus(ws.Inventory)
snapshot.DiskUsage.Data += ws.DiskUsage.Data
snapshot.DiskUsage.WAL += ws.DiskUsage.WAL
for _, p := range projects {
if p.Total() > 0 {
snapshot.Projects = append(snapshot.Projects, p)
} else {
snapshot.Idle = append(snapshot.Idle, p)
}
}
}
sort.Slice(snapshot.Workspaces, func(i, j int) bool { return snapshot.Workspaces[i].Name < snapshot.Workspaces[j].Name })
// Die aktivsten zuerst; bei Gleichstand alphabetisch, damit die
// Reihenfolge beim Neuladen nicht springt.
sort.Slice(snapshot.Projects, func(i, j int) bool {
a, b := snapshot.Projects[i], snapshot.Projects[j]
if a.Total() != b.Total() {
return a.Total() > b.Total()
}
return a.Workspace+a.Name < b.Workspace+b.Name
})
sort.Slice(snapshot.Idle, func(i, j int) bool {
a, b := snapshot.Idle[i], snapshot.Idle[j]
return a.Workspace+a.Name < b.Workspace+b.Name
})
snapshot.WaitCount = s.WaitCount()
snapshot.Duration = time.Since(begin)
return snapshot
}
// collectWorkspace sammelt die Zahlen eines Workspace. Die Aktivität je Tag
// zählt sie direkt in snapshot.Days ein; die je Projekt gibt sie zurück.
func (s *Collector) collectWorkspace(ctx context.Context, w store.Workspace, now, since time.Time,
snapshot *Snapshot, tagNum map[string]int) (Workspace, []Project, error) {
result := Workspace{ID: w.ID, Name: w.Name, Location: s.workspaces.Location(w.ID)}
opened, err := s.workspaces.Get(w.ID)
if err != nil {
return result, nil, err
}
st := opened.Store
if result.Inventory, err = st.Inventory(ctx, now); err != nil {
return result, nil, err
}
if result.DiskUsage, err = st.DiskUsage(ctx); err != nil {
return result, nil, err
}
actual, expected, err := st.SchemaVersion(ctx)
if err != nil {
return result, nil, err
}
result.Schema = actual
snapshot.SchemaVersion = expected // für alle Workspaces derselbe: er kommt aus dem Programm
// Jedes Projekt bekommt einen Eintrag, auch ohne Aktivität — gerade die
// ruhigen sind interessant.
list, err := st.ListProjects(ctx, w.ID)
if err != nil {
return result, nil, err
}
projects := make([]Project, len(list))
toID := map[string]*Project{}
for i, p := range list {
projects[i] = Project{Workspace: w.Name, ID: p.ID, Slug: p.Slug, Name: p.Name}
toID[p.ID] = &projects[i]
}
activity, err := st.Activity(ctx, since)
if err != nil {
return result, nil, err
}
for _, a := range activity {
if i, ok := tagNum[a.Tag]; ok {
snapshot.Days[i].AgentWrites += a.AgentWrites
snapshot.Days[i].HumanWrites += a.HumanWrites
snapshot.Days[i].Searches += a.Searches
}
// Zeilen eines inzwischen gelöschten Projekts zählen für den Tag,
// haben aber kein Projekt mehr, dem sie gehören.
if p := toID[a.ProjectID]; p != nil {
p.AgentWrites += a.AgentWrites
p.HumanWrites += a.HumanWrites
p.Searches += a.Searches
}
}
return result, projects, nil
}