Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b08f6a49fc |
@@ -0,0 +1,211 @@
|
|||||||
|
// Package migrate implementiert Core TEN-07: das automatisierte, pro Tenant
|
||||||
|
// fehlerisolierte Ausrollen von SQL-Migrationen ueber alle registrierten
|
||||||
|
// Mandanten-Datenbanken (Modell C — jede Migration muss N-mal statt einmal
|
||||||
|
// laufen, siehe TEN-01).
|
||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
const upSuffix = ".up.sql"
|
||||||
|
|
||||||
|
type Migration struct {
|
||||||
|
Version string
|
||||||
|
SQL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadMigrations liest alle *.up.sql-Dateien aus dir und sortiert sie nach
|
||||||
|
// Dateiname (die bestehende Namenskonvention 0001_..., 0002_... aus TEN-01
|
||||||
|
// sorgt fuer eine stabile Reihenfolge).
|
||||||
|
func LoadMigrations(dir string) ([]Migration, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("migrationsverzeichnis lesen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), upSuffix) {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
migrations := make([]Migration, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
content, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("migration %q lesen: %w", name, err)
|
||||||
|
}
|
||||||
|
version := strings.TrimSuffix(name, upSuffix)
|
||||||
|
migrations = append(migrations, Migration{Version: version, SQL: string(content)})
|
||||||
|
}
|
||||||
|
return migrations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrationStatus ist der pro Tenant und Version nachvollziehbare Stand
|
||||||
|
// (Akzeptanzkriterium 3): welche Version, wann zuletzt versucht, Erfolg oder
|
||||||
|
// Fehler.
|
||||||
|
type MigrationStatus struct {
|
||||||
|
Version string
|
||||||
|
AppliedAt time.Time
|
||||||
|
Success bool
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantResult fasst das Ergebnis eines Rollout-Versuchs fuer EINEN Tenant
|
||||||
|
// zusammen — wird von Orchestrator.RolloutAll pro Tenant gesammelt, damit ein
|
||||||
|
// Fehlschlag bei einem Mandanten die anderen nicht blockiert (Akzeptanzkriterium 2).
|
||||||
|
type TenantResult struct {
|
||||||
|
TenantSlug string
|
||||||
|
Applied []string // erfolgreich in diesem Lauf angewendete Versionen
|
||||||
|
FailedAt string // Version, bei der abgebrochen wurde; leer wenn kein Fehlschlag
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orchestrator rollt Migrationen ueber alle in der Registry gefuehrten
|
||||||
|
// Mandanten aus. Baut bewusst NICHT auf TEN-06 (Router) auf — Migrations-
|
||||||
|
// Rollouts sind seltene Batch-Vorgaenge, kein Hot-Path, ein kurzlebiger Pool
|
||||||
|
// pro Tenant und Lauf ist hier einfacher und unabhaengig von TEN-06 testbar.
|
||||||
|
type Orchestrator struct {
|
||||||
|
registry *tenant.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrchestrator(registry *tenant.Registry) *Orchestrator {
|
||||||
|
return &Orchestrator{registry: registry}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureTableSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
success BOOLEAN NOT NULL,
|
||||||
|
error TEXT
|
||||||
|
);`
|
||||||
|
|
||||||
|
// RolloutAll wendet migrations auf JEDE registrierte Tenant-Datenbank an.
|
||||||
|
// Jeder Tenant laeuft unabhaengig — ein Fehlschlag bei einem Mandanten wird
|
||||||
|
// im jeweiligen TenantResult festgehalten und blockiert die uebrigen nicht
|
||||||
|
// (Akzeptanzkriterium 1 + 2).
|
||||||
|
func (o *Orchestrator) RolloutAll(ctx context.Context, migrations []Migration) ([]TenantResult, error) {
|
||||||
|
tenants, err := o.registry.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenants fuer rollout auflisten: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]TenantResult, 0, len(tenants))
|
||||||
|
for _, t := range tenants {
|
||||||
|
results = append(results, o.rolloutForTenant(ctx, t, migrations))
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) rolloutForTenant(ctx context.Context, t tenant.Tenant, migrations []Migration) TenantResult {
|
||||||
|
result := TenantResult{TenantSlug: t.Slug}
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, t.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
result.Err = fmt.Errorf("verbindung zu tenant %q: %w", t.Slug, err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, ensureTableSQL); err != nil {
|
||||||
|
result.Err = fmt.Errorf("schema_migrations anlegen fuer tenant %q: %w", t.Slug, err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
alreadyApplied, err := isAlreadySuccessful(ctx, pool, m.Version)
|
||||||
|
if err != nil {
|
||||||
|
result.Err = fmt.Errorf("migrationsstand lesen fuer tenant %q, version %q: %w", t.Slug, m.Version, err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if alreadyApplied {
|
||||||
|
continue // Akzeptanzkriterium 3 / Pruefung 3: keine erneute Anwendung.
|
||||||
|
}
|
||||||
|
|
||||||
|
_, execErr := pool.Exec(ctx, m.SQL)
|
||||||
|
if execErr != nil {
|
||||||
|
recordAttempt(ctx, pool, m.Version, false, execErr.Error())
|
||||||
|
result.FailedAt = m.Version
|
||||||
|
result.Err = fmt.Errorf("migration %q fuer tenant %q fehlgeschlagen: %w", m.Version, t.Slug, execErr)
|
||||||
|
return result // spaetere Migrationen bauen typischerweise auf dieser auf — Abbruch NUR fuer diesen Tenant.
|
||||||
|
}
|
||||||
|
|
||||||
|
recordAttempt(ctx, pool, m.Version, true, "")
|
||||||
|
result.Applied = append(result.Applied, m.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAlreadySuccessful(ctx context.Context, pool *pgxpool.Pool, version string) (bool, error) {
|
||||||
|
var success bool
|
||||||
|
err := pool.QueryRow(ctx, `SELECT success FROM schema_migrations WHERE version = $1`, version).Scan(&success)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return success, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordAttempt(ctx context.Context, pool *pgxpool.Pool, version string, success bool, errMsg string) {
|
||||||
|
var errVal *string
|
||||||
|
if errMsg != "" {
|
||||||
|
errVal = &errMsg
|
||||||
|
}
|
||||||
|
_, _ = pool.Exec(ctx, `
|
||||||
|
INSERT INTO schema_migrations (version, applied_at, success, error)
|
||||||
|
VALUES ($1, now(), $2, $3)
|
||||||
|
ON CONFLICT (version) DO UPDATE SET applied_at = now(), success = $2, error = $3
|
||||||
|
`, version, success, errVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status liefert den Migrationsstand eines einzelnen Tenants (Akzeptanzkriterium 3).
|
||||||
|
func (o *Orchestrator) Status(ctx context.Context, tenantSlug string) ([]MigrationStatus, error) {
|
||||||
|
t, err := o.registry.GetBySlug(ctx, tenantSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenant %q nicht gefunden: %w", tenantSlug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, t.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("verbindung zu tenant %q: %w", tenantSlug, err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
SELECT version, applied_at, success, COALESCE(error, '')
|
||||||
|
FROM schema_migrations ORDER BY version
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("migrationsstand abfragen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []MigrationStatus
|
||||||
|
for rows.Next() {
|
||||||
|
var s MigrationStatus
|
||||||
|
if err := rows.Scan(&s.Version, &s.AppliedAt, &s.Success, &s.Error); err != nil {
|
||||||
|
return nil, fmt.Errorf("migrationsstand lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadMigrations_SortsByFilename(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFile(t, dir, "0002_second.up.sql", "SELECT 2;")
|
||||||
|
writeFile(t, dir, "0001_first.up.sql", "SELECT 1;")
|
||||||
|
writeFile(t, dir, "0001_first.down.sql", "SELECT 'ignored';") // muss ignoriert werden
|
||||||
|
|
||||||
|
migrations, err := LoadMigrations(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if len(migrations) != 2 {
|
||||||
|
t.Fatalf("erwartet 2 migrationen, habe %d", len(migrations))
|
||||||
|
}
|
||||||
|
if migrations[0].Version != "0001_first" || migrations[1].Version != "0002_second" {
|
||||||
|
t.Fatalf("unerwartete reihenfolge: %+v", migrations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, dir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(dir+"/"+name, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tenant.Tenant, *pgxpool.Pool, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
adminPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin pool: %v", err)
|
||||||
|
}
|
||||||
|
registryPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registry pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := registryPool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
db_name TEXT NOT NULL UNIQUE,
|
||||||
|
db_dsn TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("registry-schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := tenant.NewRegistry(registryPool)
|
||||||
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||||
|
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
||||||
|
|
||||||
|
var tenants []tenant.Tenant
|
||||||
|
var slugs []string
|
||||||
|
for i := 0; i < tenantCount; i++ {
|
||||||
|
slug := fmt.Sprintf("mig_t%d", i)
|
||||||
|
slugs = append(slugs, slug)
|
||||||
|
tn, err := provisioner.Provision(ctx, slug, slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision %s: %v", slug, err)
|
||||||
|
}
|
||||||
|
tenants = append(tenants, tn)
|
||||||
|
}
|
||||||
|
|
||||||
|
orchestrator := NewOrchestrator(registry)
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
for _, slug := range slugs {
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug))
|
||||||
|
}
|
||||||
|
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
|
||||||
|
registryPool.Close()
|
||||||
|
adminPool.Close()
|
||||||
|
}
|
||||||
|
return orchestrator, tenants, adminPool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + 2 + Pruefung 1: Rollout gegen mehrere Tenants, einer
|
||||||
|
// davon absichtlich inkompatibel — die anderen laufen trotzdem durch.
|
||||||
|
func TestRolloutAll_IsolatesFailurePerTenant(t *testing.T) {
|
||||||
|
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 3)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []Migration{
|
||||||
|
{Version: "0001_demo_a", SQL: "CREATE TABLE demo_a (id INT);"},
|
||||||
|
{Version: "0002_demo_b", SQL: "CREATE TABLE demo_b (id INT);"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenants[1] absichtlich inkompatibel machen: demo_b existiert schon,
|
||||||
|
// migration 0002 schlaegt dort mit "already exists" fehl.
|
||||||
|
badTenant := tenants[1]
|
||||||
|
badPool, err := pgxpool.New(ctx, badTenant.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect bad tenant: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := badPool.Exec(ctx, "CREATE TABLE demo_b (id INT);"); err != nil {
|
||||||
|
t.Fatalf("inkompatiblen zustand vorbereiten: %v", err)
|
||||||
|
}
|
||||||
|
badPool.Close()
|
||||||
|
|
||||||
|
results, err := orchestrator.RolloutAll(ctx, migrations)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rollout: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 3 {
|
||||||
|
t.Fatalf("erwartet 3 ergebnisse, habe %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
byslug := map[string]TenantResult{}
|
||||||
|
for _, r := range results {
|
||||||
|
byslug[r.TenantSlug] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
good0 := byslug[tenants[0].Slug]
|
||||||
|
if good0.Err != nil || len(good0.Applied) != 2 {
|
||||||
|
t.Fatalf("tenant[0] sollte beide migrationen erhalten, habe %+v", good0)
|
||||||
|
}
|
||||||
|
good2 := byslug[tenants[2].Slug]
|
||||||
|
if good2.Err != nil || len(good2.Applied) != 2 {
|
||||||
|
t.Fatalf("tenant[2] sollte beide migrationen erhalten, habe %+v", good2)
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := byslug[badTenant.Slug]
|
||||||
|
if bad.Err == nil {
|
||||||
|
t.Fatal("erwartet fehler fuer den inkompatiblen tenant")
|
||||||
|
}
|
||||||
|
if bad.FailedAt != "0002_demo_b" {
|
||||||
|
t.Fatalf("failedAt = %q, want 0002_demo_b", bad.FailedAt)
|
||||||
|
}
|
||||||
|
if len(bad.Applied) != 1 || bad.Applied[0] != "0001_demo_a" {
|
||||||
|
t.Fatalf("erwartet dass 0001_demo_a trotzdem erfolgreich war, habe %+v", bad.Applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 2: Migrationsstand-Abfrage liefert fuer
|
||||||
|
// jeden Tenant den korrekten, unabhaengigen Stand.
|
||||||
|
func TestStatus_ReflectsPerTenantState(t *testing.T) {
|
||||||
|
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []Migration{
|
||||||
|
{Version: "0001_ok", SQL: "CREATE TABLE ok_table (id INT);"},
|
||||||
|
{Version: "0002_fail", SQL: "SELECT this_column_does_not_exist FROM ok_table;"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := orchestrator.RolloutAll(ctx, migrations); err != nil {
|
||||||
|
t.Fatalf("rollout: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := orchestrator.Status(ctx, tenants[0].Slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("status: %v", err)
|
||||||
|
}
|
||||||
|
if len(status) != 2 {
|
||||||
|
t.Fatalf("erwartet 2 status-eintraege, habe %d", len(status))
|
||||||
|
}
|
||||||
|
if !status[0].Success || status[0].Version != "0001_ok" {
|
||||||
|
t.Fatalf("status[0] unerwartet: %+v", status[0])
|
||||||
|
}
|
||||||
|
if status[1].Success || status[1].Version != "0002_fail" || status[1].Error == "" {
|
||||||
|
t.Fatalf("status[1] sollte fehlgeschlagen sein mit fehlertext: %+v", status[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 3: wiederholter Rollout-Versuch wendet bereits erfolgreiche
|
||||||
|
// Migrationen NICHT erneut an und kann die zuvor fehlgeschlagene nachholen,
|
||||||
|
// sobald die Ursache behoben ist.
|
||||||
|
func TestRolloutAll_RetryDoesNotReapplySuccessful(t *testing.T) {
|
||||||
|
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 0002 schlaegt beim ersten Versuch fehl, weil demo_conflict schon
|
||||||
|
// existiert (wir legen sie vorher an, um den Fehlschlag zu erzwingen).
|
||||||
|
pool, err := pgxpool.New(ctx, tenants[0].DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, "CREATE TABLE demo_conflict (id INT);"); err != nil {
|
||||||
|
t.Fatalf("vorbedingung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations := []Migration{
|
||||||
|
{Version: "0001_ok", SQL: "CREATE TABLE demo_first (id INT);"}, // OHNE IF NOT EXISTS,
|
||||||
|
// damit ein erneutes Anwenden nachweislich fehlschlagen wuerde.
|
||||||
|
{Version: "0002_conflict", SQL: "CREATE TABLE demo_conflict (id INT);"},
|
||||||
|
}
|
||||||
|
|
||||||
|
firstRun, err := orchestrator.RolloutAll(ctx, migrations)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rollout 1: %v", err)
|
||||||
|
}
|
||||||
|
if firstRun[0].FailedAt != "0002_conflict" {
|
||||||
|
t.Fatalf("erwartet fehlschlag bei 0002_conflict im ersten lauf, habe %+v", firstRun[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ursache beheben.
|
||||||
|
if _, err := pool.Exec(ctx, "DROP TABLE demo_conflict;"); err != nil {
|
||||||
|
t.Fatalf("ursache beheben: %v", err)
|
||||||
|
}
|
||||||
|
pool.Close()
|
||||||
|
|
||||||
|
secondRun, err := orchestrator.RolloutAll(ctx, migrations)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rollout 2: %v", err)
|
||||||
|
}
|
||||||
|
// Waere 0001_ok erneut angewendet worden ("CREATE TABLE demo_first" ohne
|
||||||
|
// IF NOT EXISTS), haette das einen Fehler erzeugt statt eines sauberen
|
||||||
|
// Applied-Eintrags fuer 0002_conflict.
|
||||||
|
if secondRun[0].Err != nil {
|
||||||
|
t.Fatalf("zweiter lauf sollte fehlerfrei sein, habe %+v", secondRun[0])
|
||||||
|
}
|
||||||
|
if len(secondRun[0].Applied) != 1 || secondRun[0].Applied[0] != "0002_conflict" {
|
||||||
|
t.Fatalf("erwartet nur 0002_conflict im zweiten lauf angewendet, habe %+v", secondRun[0].Applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"container/list"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrMissingTenantContext wird geliefert, wenn keine Tenant-Kennung
|
|
||||||
// uebergeben wurde — es gibt bewusst keinen impliziten Default-Tenant
|
|
||||||
// (TEN-06 Akzeptanzkriterium 3).
|
|
||||||
var ErrMissingTenantContext = errors.New("tenant: kein tenant-kontext angegeben")
|
|
||||||
|
|
||||||
// ErrUnknownTenant wird geliefert, wenn die Tenant-Kennung in der Registry
|
|
||||||
// nicht existiert.
|
|
||||||
var ErrUnknownTenant = errors.New("tenant: unbekannter tenant")
|
|
||||||
|
|
||||||
// Router loest den Tenant-Kontext (Slug, aus dem JWT-Claim von API-05) in
|
|
||||||
// eine wiederverwendbare Verbindung zur richtigen Tenant-Datenbank auf.
|
|
||||||
// Ein LRU-verwalteter Cache begrenzt die Zahl gleichzeitig offener
|
|
||||||
// pgxpool.Pool-Instanzen, damit die Zahl offener Postgres-Verbindungen NICHT
|
|
||||||
// linear mit der Mandantenzahl waechst (Akzeptanzkriterium 2).
|
|
||||||
type Router struct {
|
|
||||||
registry *Registry
|
|
||||||
maxOpen int
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
order *list.List // vorne = zuletzt benutzt
|
|
||||||
items map[string]*list.Element // slug -> element mit *routerEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
type routerEntry struct {
|
|
||||||
slug string
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRouter(registry *Registry, maxOpen int) *Router {
|
|
||||||
if maxOpen < 1 {
|
|
||||||
maxOpen = 1
|
|
||||||
}
|
|
||||||
return &Router{
|
|
||||||
registry: registry,
|
|
||||||
maxOpen: maxOpen,
|
|
||||||
order: list.New(),
|
|
||||||
items: make(map[string]*list.Element),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve liefert einen wiederverwendeten Pool fuer den angegebenen Tenant.
|
|
||||||
// Ist der Tenant bereits im Cache, wird KEINE neue Verbindung aufgebaut
|
|
||||||
// (Akzeptanzkriterium 2 / Pruefung 3).
|
|
||||||
func (r *Router) Resolve(ctx context.Context, tenantSlug string) (*pgxpool.Pool, error) {
|
|
||||||
if tenantSlug == "" {
|
|
||||||
return nil, ErrMissingTenantContext
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
if el, ok := r.items[tenantSlug]; ok {
|
|
||||||
r.order.MoveToFront(el)
|
|
||||||
pool := el.Value.(*routerEntry).pool
|
|
||||||
r.mu.Unlock()
|
|
||||||
return pool, nil
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
|
||||||
|
|
||||||
// Registry-Lookup und Verbindungsaufbau bewusst ausserhalb des Locks,
|
|
||||||
// damit ein langsamer Verbindungsaufbau nicht alle anderen Tenants blockiert.
|
|
||||||
t, err := r.registry.GetBySlug(ctx, tenantSlug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnknownTenant, tenantSlug)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, t.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("verbindung zu tenant %q aufbauen: %w", tenantSlug, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
// Zwischen Unlock oben und hier koennte ein paralleler Aufruf denselben
|
|
||||||
// Tenant bereits eingefuegt haben — dann die eigene, ueberzaehlige
|
|
||||||
// Verbindung wieder schliessen und die vorhandene verwenden.
|
|
||||||
if el, ok := r.items[tenantSlug]; ok {
|
|
||||||
r.order.MoveToFront(el)
|
|
||||||
existing := el.Value.(*routerEntry).pool
|
|
||||||
pool.Close()
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
el := r.order.PushFront(&routerEntry{slug: tenantSlug, pool: pool})
|
|
||||||
r.items[tenantSlug] = el
|
|
||||||
|
|
||||||
if r.order.Len() > r.maxOpen {
|
|
||||||
r.evictOldest()
|
|
||||||
}
|
|
||||||
|
|
||||||
return pool, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictOldest schliesst den am laengsten nicht genutzten Pool. Muss mit
|
|
||||||
// gehaltenem r.mu aufgerufen werden.
|
|
||||||
func (r *Router) evictOldest() {
|
|
||||||
oldest := r.order.Back()
|
|
||||||
if oldest == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entry := oldest.Value.(*routerEntry)
|
|
||||||
r.order.Remove(oldest)
|
|
||||||
delete(r.items, entry.slug)
|
|
||||||
entry.pool.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenCount liefert die aktuelle Zahl offen gehaltener Tenant-Pools —
|
|
||||||
// dient Tests/Monitoring, um AC2 nachzuweisen.
|
|
||||||
func (r *Router) OpenCount() int {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
return r.order.Len()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close schliesst alle offen gehaltenen Tenant-Pools, z.B. beim
|
|
||||||
// Herunterfahren des Core-Prozesses.
|
|
||||||
func (r *Router) Close() {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
for el := r.order.Front(); el != nil; el = el.Next() {
|
|
||||||
el.Value.(*routerEntry).pool.Close()
|
|
||||||
}
|
|
||||||
r.order.Init()
|
|
||||||
r.items = make(map[string]*list.Element)
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestRouterSetup(t *testing.T, tenantCount int) (*Router, []Tenant, func()) {
|
|
||||||
t.Helper()
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
if adminDSN == "" {
|
|
||||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
adminPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("admin pool: %v", err)
|
|
||||||
}
|
|
||||||
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("registry pool: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := registryPool.Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
slug TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
db_name TEXT NOT NULL UNIQUE,
|
|
||||||
db_dsn TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("registry-schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
registry := NewRegistry(registryPool)
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
|
|
||||||
var tenants []Tenant
|
|
||||||
var slugs []string
|
|
||||||
for i := 0; i < tenantCount; i++ {
|
|
||||||
slug := fmt.Sprintf("router_t%d", i)
|
|
||||||
slugs = append(slugs, slug)
|
|
||||||
tn, err := provisioner.Provision(ctx, slug, slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision %s: %v", slug, err)
|
|
||||||
}
|
|
||||||
tenants = append(tenants, tn)
|
|
||||||
}
|
|
||||||
|
|
||||||
router := NewRouter(registry, 2) // klein gewaehlt, um Eviction im Test zu erzwingen
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
router.Close()
|
|
||||||
for _, slug := range slugs {
|
|
||||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
|
|
||||||
}
|
|
||||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
|
|
||||||
registryPool.Close()
|
|
||||||
adminPool.Close()
|
|
||||||
}
|
|
||||||
return router, tenants, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1: Verbindung wird zuverlaessig anhand des Tenant-Kontexts aufgeloest.
|
|
||||||
func TestRouter_ResolvesCorrectTenantDatabase(t *testing.T) {
|
|
||||||
router, tenants, cleanup := newTestRouterSetup(t, 2)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
pool, err := router.Resolve(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve: %v", err)
|
|
||||||
}
|
|
||||||
var dbName string
|
|
||||||
if err := pool.QueryRow(ctx, `SELECT current_database()`).Scan(&dbName); err != nil {
|
|
||||||
t.Fatalf("current_database: %v", err)
|
|
||||||
}
|
|
||||||
if dbName != tenants[0].DBName {
|
|
||||||
t.Fatalf("current_database() = %q, want %q", dbName, tenants[0].DBName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 2: fehlender/unbekannter Tenant-Kontext
|
|
||||||
// wird explizit abgewiesen statt irgendeine Verbindung zu liefern.
|
|
||||||
func TestRouter_RejectsMissingOrUnknownTenant(t *testing.T) {
|
|
||||||
router, _, cleanup := newTestRouterSetup(t, 1)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, err := router.Resolve(ctx, ""); !errors.Is(err, ErrMissingTenantContext) {
|
|
||||||
t.Fatalf("erwartet ErrMissingTenantContext, habe %v", err)
|
|
||||||
}
|
|
||||||
if _, err := router.Resolve(ctx, "nie-registrierter-slug"); !errors.Is(err, ErrUnknownTenant) {
|
|
||||||
t.Fatalf("erwartet ErrUnknownTenant, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 3: Verbindungswiederverwendung nachweislich
|
|
||||||
// gemessen — zweiter Resolve-Aufruf liefert exakt denselben Pool, kein
|
|
||||||
// erneuter Verbindungsaufbau.
|
|
||||||
func TestRouter_ReusesConnectionForSameTenant(t *testing.T) {
|
|
||||||
router, tenants, cleanup := newTestRouterSetup(t, 1)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
first, err := router.Resolve(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve 1: %v", err)
|
|
||||||
}
|
|
||||||
second, err := router.Resolve(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve 2: %v", err)
|
|
||||||
}
|
|
||||||
if first != second {
|
|
||||||
t.Fatal("erwartet identische pool-instanz bei wiederholtem resolve, habe unterschiedliche")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 1: Lasttest mit mehr simulierten Mandanten
|
|
||||||
// als maxOpen — die Zahl gleichzeitig offener Tenant-Pools bleibt begrenzt
|
|
||||||
// (LRU-Eviction), waechst also NICHT linear mit der Mandantenzahl.
|
|
||||||
func TestRouter_BoundsOpenConnectionsUnderLoad(t *testing.T) {
|
|
||||||
const tenantCount = 6
|
|
||||||
router, tenants, cleanup := newTestRouterSetup(t, tenantCount)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
for _, tn := range tenants {
|
|
||||||
if _, err := router.Resolve(ctx, tn.Slug); err != nil {
|
|
||||||
t.Fatalf("resolve %s: %v", tn.Slug, err)
|
|
||||||
}
|
|
||||||
if router.OpenCount() > 2 {
|
|
||||||
t.Fatalf("OpenCount() = %d, erwartet <= maxOpen (2) nach jedem Resolve", router.OpenCount())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if router.OpenCount() != 2 {
|
|
||||||
t.Fatalf("erwartet genau maxOpen=2 offene pools nach %d tenants, habe %d", tenantCount, router.OpenCount())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evictete Tenants sind wieder ganz normal ueber die Registry aufloesbar
|
|
||||||
// (Cache-Miss fuehrt zu neuem, funktionierendem Pool, kein Fehlerzustand).
|
|
||||||
pool, err := router.Resolve(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve nach eviction: %v", err)
|
|
||||||
}
|
|
||||||
var one int
|
|
||||||
if err := pool.QueryRow(ctx, `SELECT 1`).Scan(&one); err != nil {
|
|
||||||
t.Fatalf("query nach re-resolve: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user