342 lines
12 KiB
Go
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 dialekt (dialekt.go); welche
|
|
// Datenbank benutzt wird, entscheidet der Speicher (speicher.go). Alle
|
|
// Abfragen laufen über pool (verbindung.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
|
|
// speicher_postgres.go).
|
|
type Store struct {
|
|
lesen pool // mehrere Verbindungen — nur lesende Abfragen
|
|
schreiben 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 (speicher.go); Tests und Werkzeuge, die einfach eine Datei
|
|
// brauchen, rufen Open direkt auf.
|
|
func Open(path string) (*Store, error) {
|
|
lesen, schreiben, err := openSQLite(path, migrationsFS, "migrations")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Store{lesen: lesen, schreiben: schreiben}, 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 PostgresSpeicher.oeffnePools.
|
|
func openSQLite(path string, migrationsFS embed.FS, migrationsDir string) (lesen, schreiben pool, err error) {
|
|
d := sqliteDialekt{}
|
|
|
|
// 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).
|
|
schreibDB, err := sql.Open("sqlite", dsn+"&_txlock=immediate")
|
|
if err != nil {
|
|
return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err)
|
|
}
|
|
schreibDB.SetMaxOpenConns(1)
|
|
schreiben = pool{db: schreibDB, d: d}
|
|
|
|
if err := schreibDB.Ping(); err != nil {
|
|
schreibDB.Close()
|
|
return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err)
|
|
}
|
|
|
|
if err := runMigrations(schreiben, migrationsFS, migrationsDir); err != nil {
|
|
schreibDB.Close()
|
|
return pool{}, pool{}, err
|
|
}
|
|
|
|
// Der Lesepool wird erst danach geöffnet — dann steht das Schema bereits.
|
|
leseDB, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
schreibDB.Close()
|
|
return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err)
|
|
}
|
|
leseDB.SetMaxOpenConns(maxLeseverbindungen)
|
|
lesen = pool{db: leseDB, d: d}
|
|
|
|
if err := leseDB.Ping(); err != nil {
|
|
schreibDB.Close()
|
|
leseDB.Close()
|
|
return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht lesbar: %w", err)
|
|
}
|
|
|
|
return lesen, schreiben, 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
|
|
|
|
// Verbindungen liefert die Zahlen beider Pools (siehe Verbindungen).
|
|
func (s *Store) Verbindungen() Verbindungen {
|
|
return Verbindungen{Lesen: s.lesen.db.Stats(), Schreiben: s.schreiben.db.Stats()}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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(schreiben pool, fsys embed.FS, dir string) error {
|
|
ctx := context.Background()
|
|
dir = dir + "/" + schreiben.d.migrationsUnterordner()
|
|
|
|
_, err := schreiben.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)
|
|
}
|
|
|
|
angewandt, err := appliedMigrations(schreiben)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dateien, err := migrationFiles(fsys, dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, name := range dateien {
|
|
if angewandt[name] {
|
|
continue
|
|
}
|
|
|
|
inhalt, err := fsys.ReadFile(dir + "/" + name)
|
|
if err != nil {
|
|
return fmt.Errorf("Migration %s konnte nicht gelesen werden: %w", name, err)
|
|
}
|
|
|
|
if err := applyMigration(schreiben, name, string(inhalt)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// appliedMigrations liest die Namen der bereits ausgeführten Migrationen.
|
|
func appliedMigrations(schreiben pool) (map[string]bool, error) {
|
|
rows, err := schreiben.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()
|
|
|
|
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(fsys embed.FS, dir string) ([]string, error) {
|
|
eintraege, err := fsys.ReadDir(dir)
|
|
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.
|
|
//
|
|
// 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(schreiben pool, name, sqlText string) error {
|
|
tx, err := schreiben.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(
|
|
schreiben.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(*transaktion) error) error {
|
|
tx, err := s.schreiben.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()
|
|
}
|