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

342 lines
12 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/sqlite/001_init.sql.
//
// Seit AUSBAUSTUFEN.md 4.10 läuft derselbe Store wahlweise auf SQLite oder
// PostgreSQL. Was sich unterscheidet, steht im dialect (dialect.go); welche
// Datenbank benutzt wird, entscheidet das Backend (backend.go). Alle
// Abfragen laufen über pool (pool.go), damit keine am Dialekt
// vorbeikommt.
//
// # 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 eines Workspace
// aufbauen (Projekte, Einträge, Keys, Protokoll — siehe internal/store/migrations).
// Durch das Einbetten wandern sie mit ins Binary — beim Start ist kein
// zusätzlicher Ordner nötig.
//
// Je Datenbank gibt es einen eigenen Unterordner (sqlite/, postgres/) mit
// denselben Dateinamen — siehe AUSBAUSTUFEN.md 4.10.
//
// Das Schema der zentralen Kontroll-Datenbank (Workspaces, Key-Routing) liegt
// getrennt in migrations_control — siehe control.go.
//
//go:embed migrations/sqlite/*.sql migrations/postgres/*.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.
//
// Unter PostgreSQL gilt dieselbe Aufteilung, nur mit anderen Größen (siehe
// backend_postgres.go).
type Store struct {
read pool // mehrere Verbindungen — nur lesende Abfragen
write pool // genau eine Verbindung — alles, was verändert
}
// Open öffnet die Workspace-Datenbankdatei, setzt die nötigen Einstellungen und
// bringt das Schema auf den aktuellen Stand.
//
// Jede Workspace-Datei bekommt dieselbe Behandlung — unabhängig davon, ob es
// sich um die einzige Abteilung handelt oder eine von vierzig.
//
// Open ist der Weg für SQLite. Welche Datenbank der Server benutzt, entscheidet
// der Speicher (backend.go); Tests und Werkzeuge, die einfach eine Datei
// brauchen, rufen Open direkt auf.
func Open(path string) (*Store, error) {
read, write, err := openSQLite(path, migrationsFS, "migrations")
if err != nil {
return nil, err
}
return &Store{read: read, write: write}, nil
}
// openSQLite öffnet eine SQLite-Datei mit getrenntem Lese- und Schreibpool und
// bringt ihr Schema auf den aktuellen Stand. Verwendet von Open
// (Workspace-Dateien) und OpenControl (Kontroll-Datenbank) — beide brauchen
// exakt dieselben Einstellungen, nur unterschiedliche Dateien und Migrationen.
//
// Das Gegenstück für PostgreSQL ist PostgresBackend.oeffnePools.
func openSQLite(path string, migrationsFS embed.FS, migrationsDir string) (read, write pool, err error) {
d := sqliteDialect{}
// 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 296 von 320 gleichzeitigen
// Schreibvorgängen in einer früheren Messung (siehe AUSBAUSTUFEN.md).
writeDB, err := sql.Open("sqlite", dsn+"&_txlock=immediate")
if err != nil {
return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err)
}
writeDB.SetMaxOpenConns(1)
write = pool{db: writeDB, d: d}
if err := writeDB.Ping(); err != nil {
writeDB.Close()
return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err)
}
if err := runMigrations(write, migrationsFS, migrationsDir); err != nil {
writeDB.Close()
return pool{}, pool{}, err
}
// Der Lesepool wird erst danach geöffnet — dann steht das Schema bereits.
readDB, err := sql.Open("sqlite", dsn)
if err != nil {
writeDB.Close()
return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err)
}
readDB.SetMaxOpenConns(maxReadConns)
read = pool{db: readDB, d: d}
if err := readDB.Ping(); err != nil {
writeDB.Close()
readDB.Close()
return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht lesbar: %w", err)
}
return read, write, nil
}
// maxReadConns 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 maxReadConns = 8
// PoolStats liefert die Zahlen beider Pools (siehe Verbindungen).
func (s *Store) PoolStats() PoolStats {
return PoolStats{Read: s.read.db.Stats(), Write: s.write.db.Stats()}
}
// Close schließt beide Verbindungspools.
func (s *Store) Close() error {
readErr := s.read.Close()
if err := s.write.Close(); err != nil {
return err
}
return readErr
}
// runMigrations wendet alle noch nicht ausgeführten Migrationen aus
// fsys/dir/<Unterordner des Dialekts> über die gegebene Schreibverbindung an.
//
// # Wie das funktioniert
//
// Die Dateien 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 Schemata
// dieser Größe ist der Mechanismus leicht zu überblicken, und wer ihn
// versteht, versteht auch, was beim Start mit seiner Datenbank passiert.
//
// Jede Datenbankdatei (jede Workspace-Datei ebenso wie die Kontroll-Datenbank)
// führt ihre eigene schema_migrations-Tabelle — sie kennt nur die Migrationen
// aus dem für sie zuständigen Verzeichnis. Unter PostgreSQL gilt dasselbe je
// Schema.
//
// In schema_migrations steht nur der Dateiname ("001_init.sql"), nicht der
// Ordner. Deshalb hat das Aufteilen in sqlite/ und postgres/ bestehende
// Datenbanken nicht berührt.
func runMigrations(write pool, fsys embed.FS, dir string) error {
ctx := context.Background()
dir = dir + "/" + write.d.migrationsSubdir()
_, err := write.ExecContext(ctx, `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)
}
applied, err := appliedMigrations(write)
if err != nil {
return err
}
files, err := migrationFiles(fsys, dir)
if err != nil {
return err
}
for _, name := range files {
if applied[name] {
continue
}
content, err := fsys.ReadFile(dir + "/" + name)
if err != nil {
return fmt.Errorf("Migration %s konnte nicht gelesen werden: %w", name, err)
}
if err := applyMigration(write, name, string(content)); err != nil {
return err
}
}
return nil
}
// appliedMigrations liest die Namen der bereits ausgeführten Migrationen.
func appliedMigrations(write pool) (map[string]bool, error) {
rows, err := write.QueryContext(context.Background(), `SELECT name FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
applied := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
applied[name] = true
}
return applied, rows.Err()
}
// migrationFiles listet die Migrationsdateien in aufsteigender Reihenfolge.
func migrationFiles(fsys embed.FS, dir string) ([]string, error) {
entries, err := fsys.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("Migrationen konnten nicht gelesen werden: %w", err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
// Die Nummerierung am Anfang macht die alphabetische Sortierung zur
// richtigen Reihenfolge.
sort.Strings(names)
return names, nil
}
// applyMigration führt eine einzelne Migration in einer Transaktion aus.
//
// Der Text der Migration geht ausnahmsweise am rebind vorbei (direkt über
// *sql.Tx): Er enthält keine Platzhalter, aber in den Kommentaren stehen
// Fragezeichen, und die gehören dort hin. Ohne Parameter schickt auch der
// PostgreSQL-Treiber den Text unverändert und erlaubt dabei mehrere
// Anweisungen hintereinander.
func applyMigration(write pool, name, sqlText string) error {
tx, err := write.db.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(
write.d.rebind(`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(*transaction) error) error {
tx, err := s.write.beginTx(ctx)
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()
}