// 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/ ü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() }