munin/internal/store/store.go
2026-09-15 20:36:26 +02:00

278 lines
9.3 KiB
Go

// Package store ist die einzige Stelle im Programm, die SQL kennt.
//
// # Warum ein eigenes Package?
//
// Alles andere arbeitet mit Go-Typen (Project, Entry, APIKey) und ruft Methoden
// auf. Wenn die Datenbank später von SQLite auf PostgreSQL wechselt, ändert sich
// nur dieses Package — kein Handler, kein MCP-Tool, kein Template muss angefasst
// werden.
//
// # Portabilität
//
// Das SQL hier ist bewusst schlicht gehalten und vermeidet alles, was nur SQLite
// kann. Die Regeln stehen als Kommentar in migrations/001_init.sql.
//
// # Warum modernc.org/sqlite?
//
// Es gibt zwei verbreitete SQLite-Treiber für Go. Der bekanntere
// (mattn/go-sqlite3) bindet die originale C-Bibliothek ein und braucht dafür CGO.
// Damit ließe sich das Programm nicht mehr als statisches Binary bauen — und
// genau das macht das Deployment hier so einfach (ein Alpine-Image ohne Runtime).
//
// modernc.org/sqlite ist eine nach Go übersetzte Fassung derselben Bibliothek:
// etwas langsamer, dafür reines Go. Für ein internes Werkzeug ist das der
// richtige Handel.
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"sort"
"strings"
_ "modernc.org/sqlite" // meldet sich als Treiber "sqlite" an
)
// migrationsFS enthält die .sql-Dateien, die das Schema aufbauen.
// Durch das Einbetten wandern sie mit ins Binary — beim Start ist kein
// zusätzlicher Ordner nötig.
//
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Store hält die Datenbankverbindungen und stellt alle Abfragen bereit.
//
// # Warum zwei Verbindungspools?
//
// SQLite erlaubt im WAL-Modus beliebig viele gleichzeitige LESER neben genau
// einem Schreiber. Wer beides über einen Pool laufen lässt, muss sich zwischen
// zwei Übeln entscheiden:
//
// - Eine Verbindung für alles: Schreibvorgänge kollidieren nie, aber auch
// Lesezugriffe stehen Schlange. Gemessen bei 8 gleichzeitigen Agenten:
// 2.700 Abläufe/s statt 9.700 — der Löwenanteil der Anfragen an
// ein Gedächtnis sind Lesezugriffe, das ist also genau der falsche Handel.
//
// - Mehrere Verbindungen für alles: Lesen wird schnell, aber zwei gleichzeitig
// begonnene Schreibtransaktionen laufen in "database is locked". Gemessen:
// von 320 gleichzeitigen Schreibvorgängen scheiterten 296.
//
// Deshalb getrennt: viele Verbindungen zum Lesen, genau eine zum Schreiben. Die
// Schreiber warten damit in Go aufeinander statt in SQLite zu kollidieren, und
// die Leser sind davon nicht betroffen.
type Store struct {
lesen *sql.DB // mehrere Verbindungen — nur lesende Abfragen
schreiben *sql.DB // genau eine Verbindung — alles, was verändert
}
// Open öffnet die Datenbankdatei, setzt die nötigen Einstellungen und bringt das
// Schema auf den aktuellen Stand.
func Open(path string) (*Store, error) {
// Die Parameter in der URL setzen drei Dinge, ohne die SQLite unter
// gleichzeitigen Zugriffen unangenehm wird:
//
// _pragma=journal_mode(WAL)
// "Write-Ahead Logging". Ohne WAL sperrt jeder Schreibvorgang die ganze
// Datei, auch für Leser. Mit WAL können Leser weiterarbeiten, während
// geschrieben wird — bei einem Server mit vielen parallelen Requests
// ist das der Unterschied zwischen "läuft" und "hängt".
//
// _pragma=busy_timeout(5000)
// Trifft ein Schreiber auf eine Sperre, wartet er bis zu 5 Sekunden,
// statt sofort mit "database is locked" abzubrechen.
//
// _pragma=foreign_keys(1)
// SQLite prüft Fremdschlüssel nur, wenn man es ausdrücklich verlangt.
// Ohne das wären die REFERENCES im Schema reine Dokumentation, und ein
// gelöschtes Projekt würde verwaiste Einträge hinterlassen.
dsn := path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
// Der Schreibpool zuerst: Er legt die Datei an und wendet die Migrationen an.
//
// _txlock=immediate lässt jede Transaktion die Schreibsperre sofort nehmen
// statt erst beim ersten Schreibbefehl. Ohne das kann eine Transaktion
// mitten drin auf die Sperre laufen — und dort hilft busy_timeout nicht mehr,
// weil SQLite den Wartenden nicht zurücksetzen kann, ohne die Transaktion
// abzubrechen. Genau daran scheiterten die 296 Schreibvorgänge oben.
schreiben, err := sql.Open("sqlite", dsn+"&_txlock=immediate")
if err != nil {
return nil, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err)
}
schreiben.SetMaxOpenConns(1)
if err := schreiben.Ping(); err != nil {
schreiben.Close()
return nil, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err)
}
s := &Store{schreiben: schreiben}
if err := s.migrate(); err != nil {
schreiben.Close()
return nil, err
}
// Der Lesepool wird erst danach geöffnet — dann steht das Schema bereits.
lesen, err := sql.Open("sqlite", dsn)
if err != nil {
schreiben.Close()
return nil, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err)
}
lesen.SetMaxOpenConns(maxLeseverbindungen)
if err := lesen.Ping(); err != nil {
schreiben.Close()
lesen.Close()
return nil, fmt.Errorf("Datenbank ist nicht lesbar: %w", err)
}
s.lesen = lesen
return s, nil
}
// maxLeseverbindungen begrenzt die gleichzeitigen Lesezugriffe.
//
// Mehr als eine Handvoll bringt nichts: Ab etwa acht Verbindungen ist der
// Durchsatz erreicht, weil dann die Rechenkerne und nicht mehr das Warten die
// Grenze setzen. Jede Verbindung kostet dafür Arbeitsspeicher.
const maxLeseverbindungen = 8
// Close schließt beide Verbindungspools.
func (s *Store) Close() error {
fehlerLesen := s.lesen.Close()
if err := s.schreiben.Close(); err != nil {
return err
}
return fehlerLesen
}
// migrate wendet alle noch nicht ausgeführten Migrationen an.
//
// # Wie das funktioniert
//
// Die Dateien in migrations/ sind durchnummeriert (001_…, 002_…). Eine Tabelle
// merkt sich, welche davon schon gelaufen sind. Beim Start wird die Liste
// durchgegangen und alles Fehlende ausgeführt — in einer Transaktion pro Datei,
// damit eine halb angewandte Migration nicht möglich ist.
//
// Das sind bewusst nur ein paar Zeilen statt einer Bibliothek: Für ein Schema
// dieser Größe ist der Mechanismus leicht zu überblicken, und wer ihn versteht,
// versteht auch, was beim Start mit seiner Datenbank passiert.
func (s *Store) migrate() error {
_, err := s.schreiben.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`)
if err != nil {
return fmt.Errorf("Migrationstabelle konnte nicht angelegt werden: %w", err)
}
angewandt, err := s.appliedMigrations()
if err != nil {
return err
}
dateien, err := migrationFiles()
if err != nil {
return err
}
for _, name := range dateien {
if angewandt[name] {
continue
}
inhalt, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("Migration %s konnte nicht gelesen werden: %w", name, err)
}
if err := s.applyMigration(name, string(inhalt)); err != nil {
return err
}
}
return nil
}
// appliedMigrations liest die Namen der bereits ausgeführten Migrationen.
func (s *Store) appliedMigrations() (map[string]bool, error) {
rows, err := s.schreiben.Query(`SELECT name FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
angewandt := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
angewandt[name] = true
}
return angewandt, rows.Err()
}
// migrationFiles listet die Migrationsdateien in aufsteigender Reihenfolge.
func migrationFiles() ([]string, error) {
eintraege, err := migrationsFS.ReadDir("migrations")
if err != nil {
return nil, fmt.Errorf("Migrationen konnten nicht gelesen werden: %w", err)
}
var namen []string
for _, e := range eintraege {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
namen = append(namen, e.Name())
}
}
// Die Nummerierung am Anfang macht die alphabetische Sortierung zur
// richtigen Reihenfolge.
sort.Strings(namen)
return namen, nil
}
// applyMigration führt eine einzelne Migration in einer Transaktion aus.
func (s *Store) applyMigration(name, sqlText string) error {
tx, err := s.schreiben.Begin()
if err != nil {
return fmt.Errorf("Transaktion für %s konnte nicht gestartet werden: %w", name, err)
}
// Rollback nach einem erfolgreichen Commit ist wirkungslos — deshalb kann
// das hier bedenkenlos per defer stehen und deckt jeden Fehlerpfad ab.
defer tx.Rollback()
if _, err := tx.Exec(sqlText); err != nil {
return fmt.Errorf("Migration %s fehlgeschlagen: %w", name, err)
}
if _, err := tx.Exec(
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`,
name, NowString(),
); err != nil {
return fmt.Errorf("Migration %s konnte nicht vermerkt werden: %w", name, err)
}
return tx.Commit()
}
// tx führt fn innerhalb einer Transaktion aus und übernimmt Commit bzw. Rollback.
//
// Damit steht die Fehlerbehandlung einmal hier statt in jeder schreibenden
// Methode — und es kann nicht passieren, dass irgendwo ein Rollback vergessen wird.
func (s *Store) tx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := s.schreiben.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("Transaktion konnte nicht gestartet werden: %w", err)
}
defer tx.Rollback()
if err := fn(tx); err != nil {
return err
}
return tx.Commit()
}