Files
HiveGrid-Wireframes/hivegrid-cloud/docs/improvement-plan/03-scaling-infrastructure/read-write-split.md
T

21 KiB

Read-Write-Split via Prisma $extends

Status: in_progress (code-complete; read-replica deferred by operator; single-DB fallback live) Task-ID: #41 Phase: 03 Estimated Effort: 1 Woche Owner: unassigned

Problem

Sämtliche Prisma-Queries (auch reine Read-Loaders für Dashboards, KB-Public-Seiten, Analytics, Listings) gehen heute gegen den Primary-Postgres. Das Primary ist gleichzeitig Write-Target — bei einem Bulk-Write (Import von 10k CRM-Records) verlangsamen sich parallele Reads spürbar. Mit dem PgBouncer-Setup aus #40 ist das Connection-Limit zwar entschärft, aber Disk-I/O und CPU-Last bleiben auf einer Maschine.

Postgres unterstützt Streaming-Replication out of the box. Eine Read-Replica entlastet den Primary für alle Read-Only-Workloads. Voraussetzung: die Application muss bewusst entscheiden, ob eine Query an Replica (eventually consistent, Lag bis ~1s) oder Primary (strikt aktuell) geht.

Aktuelle Hot-Read-Pfade (Stichproben):

  • apps/web/app/modules/crm/db/*.db.server.ts — List-Queries (Contacts, Companies, Deals)
  • apps/web/app/modules/knowledgeBase/db/*.db.server.ts — Public KB-Routes (hohe Lese-Last, kein Konsistenz-Bedarf)
  • apps/web/app/modules/analytics/* — Dashboard-Loaders (aggregierte Reports, oft minutenlang gecached)
  • apps/web/app/routes/app.$tenant/dashboard.tsx — Loader pro Page-Load
  • apps/web/app/utils/data/.server/appData.ts — Read-Side (Tenant-Lookups, Member-Listings)
  • apps/web/app/modules/sites/* und apps/web/app/modules/portals/* — Public-Facing Loaders (Marketing-Sites, Customer-Portals)

Klassische Write-Pfade müssen explizit Primary nutzen: Mutations, Session-Writes, Stripe-Webhook-Updates, Outbox-Writes (siehe #42).

Depends on

  • 03-scaling-infrastructure/pgbouncer.md — DIRECT_URL-Konvention und Pool-Setup ist Voraussetzung; Read-Replica läuft optional auch über eigenen PgBouncer
  • 02-observability/metrics-grafana.md — Read/Write-Verteilung wird über Prisma-Middleware-Counter erfasst und in Grafana visualisiert

Blocks

  • 03-scaling-infrastructure/outbox-pattern.md — Outbox-Writes müssen explizit Primary nutzen, Konvention wird hier etabliert
  • 03-scaling-infrastructure/tenant-snapshot.md — Snapshot-Reads können von Replica kommen, Restore-Writes Primary
  • 04-search-rag/embedding-pipeline.md — Indexer liest große Mengen — gut für Replica
  • alle nachfolgenden Read-Heavy-Features

Approach

Zwei Prisma-Clients hinter sauberem Interface.

  1. packages/db/src/client.ts zentral umbauen:

    const writeUrl = env.DATABASE_URL;
    const readUrl = env.DATABASE_READ_URL ?? writeUrl; // Fallback
    
    const prismaWrite = new PrismaClient({ datasources: { db: { url: writeUrl } } });
    const prismaRead = readUrl === writeUrl
      ? prismaWrite // Same instance, no double connection
      : new PrismaClient({ datasources: { db: { url: readUrl } } });
    
    export const prisma = prismaWrite.$extends({
      name: 'rw-split',
      client: {
        get write() { return prismaWrite; },
        get read() { return prismaRead; },
      },
    });
    
  2. Konvention: prisma.write.user.findUnique(...) für strikte Konsistenz, prisma.read.user.findUnique(...) für eventually-consistent.

  3. Migration der Hot-Read-Pfade (siehe Liste oben) explizit pro Funktion auf prisma.read umstellen. Kein Default-Routing — wir wollen bewusste Entscheidung pro Call.

  4. Telemetry: Prisma-Middleware zählt pro Client (write vs read), exposed via OpenTelemetry-Counter prisma.queries.total{client="read|write"}. Grafana-Dashboard zeigt Verteilung.

  5. Fallback-Logik: Wenn DATABASE_READ_URL nicht gesetzt ist → Read-Client = Write-Client, keine Replication-Nutzung. So bleibt Code-Konvention auch in Dev ohne Replica funktional.

  6. Test-Setup:

    • In CI gegen Single-DB (read = write Fallback) → kein Replica-Mock nötig, alle Tests grün bleiben
    • Optional Integration-Test gegen Replica via testcontainers (Postgres-Primary + Postgres-Standby mit Streaming-Replication)

Warum dieses Pattern:

  • Prisma $extends ist offizieller Mechanismus, type-safe, keine Magic
  • Explizite Auswahl pro Call statt Auto-Routing (z.B. nach Operation-Type) — Auto-Routing macht Konsistenz-Bugs schwer auffindbar
  • Fallback-Konstruktion: Code bleibt funktional auch ohne Replica (Dev/Self-Hosted-Setups ohne Replica-Setup)

Warum nicht Alternativen:

  • ❌ Prisma-Middleware mit Auto-Routing nach Operation-Type: Bricht „read-after-write" in einer Request-Lifecycle (z.B. nach create direkt findUnique auf Replica → Race)
  • ❌ Separate Module pro DB-Access (read-models package): zu viel Doppelung, schwer zu maintainen
  • ❌ Externe Library wie prisma-replicas: Maintenance unsicher, wir haben nur einen Use-Case

Implementation Steps

  1. Two-Client-Setup in packages/db

    • Files: packages/db/src/client.ts, packages/db/src/client.test.ts
    • Implementierung wie oben skizziert, mit Fallback wenn READ_URL nicht gesetzt
    • Type-Test: prisma.read.user.findFirst() und prisma.write.user.findFirst() beide typed
    • Tests: Fallback-Verhalten, Singleton-Pattern, Beide-Clients-Different bei gesetzter READ_URL
    • Commit: 03-scaling/rw-split: add prisma write/read clients with fallback
  2. Env-Var-Schema erweitern

    • Files: packages/config/src/env-schema.ts (oder analog), .env.example, .env.production.example
    • DATABASE_READ_URL als optional, Zod-Schema
    • Comment: „Optional Postgres Read-Replica URL. Wenn leer, fallen alle Reads auf Primary zurück (DATABASE_URL)."
    • Validation: wenn gesetzt, muss valides postgres:// URL sein
    • Commit: 03-scaling/rw-split: add DATABASE_READ_URL to env catalogue
  3. Telemetry-Middleware

    • Files: packages/db/src/telemetry.ts, telemetry.test.ts
    • Prisma-Middleware ($use) registriert pro Client-Instance, zählt Queries (counter), Duration (histogram), labels mit model, operation, client
    • Integration mit OpenTelemetry-Metrics aus Phase 02
    • Tests: Counter incrementiert korrekt, Labels gesetzt
    • Commit: 03-scaling/rw-split: add prisma telemetry middleware for read/write split observability
  4. Migration: apps/web/app/modules/crm/db/*.db.server.ts

    • Files: alle List-Query-Funktionen in apps/web/app/modules/crm/db/
    • Pro Funktion: wenn Read-Only (z.B. getContacts, getCompanies, getDealsForBoard) → prisma.read. Mutations bleiben prisma.write (oder default-prisma weil das = prisma.write)
    • Pro Funktion bewusster Choice — Subagent dokumentiert Entscheidung pro Funktion in Commit-Message
    • Tests: bestehende Tests laufen weiter; Single-DB-Fallback macht keinen Unterschied
    • Commit: 03-scaling/rw-split: route crm list queries to read replica
  5. Migration: apps/web/app/modules/knowledgeBase/db/*.db.server.ts

    • Files: KB-DB-Files
    • Public-KB-Routes haben höchste Read-Last, beste Migration-Candidate
    • Mutations (KB-Editor) bleiben Write
    • Commit: 03-scaling/rw-split: route knowledge base public reads to replica
  6. Migration: apps/web/app/modules/analytics/*

    • Files: alle Analytics-Loader
    • Aggregations + Reports sind Read-Only, oft langsame Queries → ideal für Replica
    • Commit: 03-scaling/rw-split: route analytics dashboard queries to read replica
  7. Migration: apps/web/app/routes/app.$tenant/dashboard.tsx

    • Files: Dashboard-Route + verlinkter Loader-Code
    • Dashboard-Loader pure Read → prisma.read
    • Commit: 03-scaling/rw-split: route tenant dashboard loader to read replica
  8. Migration: apps/web/app/utils/data/.server/appData.ts

    • Files: appData.ts (Read-Side)
    • Tenant/Member-Lookups sind Read-Only — Subscription-Cache (#38) cached oben drauf
    • Vorsicht: wenn nach Login direkt getUserAndTenants aufgerufen wird, hat Replica evtl. den frisch erstellten User noch nicht (Replication-Lag). Für Auth-Pfade weiterhin prisma.write nutzen, dokumentieren
    • Commit: 03-scaling/rw-split: route appdata read side to replica with auth path exception
  9. Migration: apps/web/app/modules/sites/ + portals/**

    • Files: alle Site/Portal-Loaders
    • Public-Facing, hohe Last, Read-Only → prisma.read
    • Commit: 03-scaling/rw-split: route public sites and portals to read replica
  10. Telemetry-Verifikation + Grafana-Dashboard

    • Files: infra/monitoring/grafana-dashboards/prisma-rw-split.json
    • Panels: Total Queries pro Client, Latency-P95 pro Client, Queries pro Model+Operation
    • Doku-Update: docs/observability/redis-stores.md ergänzen um „wir beobachten read/write-split via Grafana"
    • Commit: 03-scaling/rw-split: add grafana dashboard for read/write split observability
  11. Doku

    • Files: docs/database/read-replicas.md
    • Sections:
      • Setup: wie Postgres-Streaming-Replication aufgesetzt wird (primary_conninfo, hot_standby = on)
      • Coolify/Hetzner-Setup: zwei Postgres-Container, eines als Standby, pg_basebackup-Init
      • Fly.io: managed Postgres-Replicas
      • Konvention: „Use read for X, write for Y" — Tabelle:
        • Use prisma.read für: Listings, Dashboards, Public-Pages, Reports, Search-Result-Display
        • Use prisma.write für: Mutations, Auth-Pfade (Login/Session), Stripe-Webhook-Reads-die-Writes-vorangehen, Outbox-Reads im Dispatcher
      • Replication-Lag-Considerations: typisch < 1s, aber Burst-Writes können Lag auf 5-10s pushen. Read-after-write nicht garantiert auf Replica
      • Monitoring: pg_stat_replication auf Primary; pg_last_xact_replay_timestamp() auf Replica
      • Failover: Manuell (Coolify-Restart von Standby zu Primary); automatisch via Patroni/repmgr (out of scope für jetzt)
    • Commit: 03-scaling/rw-split: add read replicas documentation with usage guidelines
  12. Integration-Test (optional, gated)

    • Files: packages/db/src/__integration__/rw-split.test.ts
    • testcontainers mit Postgres-Primary + Postgres-Standby (Streaming-Replication)
    • Tests: Write auf Primary → Read auf Replica nach 100ms (mit kleinem Sleep, lebensnah)
    • Gated über TEST_REPLICA=1-Env-Var (nicht in default-CI weil Replica-Setup teuer)
    • Commit: 03-scaling/rw-split: add optional integration test with replicated postgres

Test Requirements

  • Unit-Tests: client.ts Fallback-Logik (READ_URL gesetzt vs nicht); Telemetry-Middleware Counter
  • Integration-Tests: Bestehende CI-Suite läuft weiter (Single-DB-Fallback macht keinen Unterschied); optionaler Replica-Test gated über Env-Var
  • E2E-Tests: keine neuen, bestehende E2E müssen grün bleiben
  • Manual-Test: auf Staging mit Replica: read-Query auf Replica, write-Query auf Primary, verify via pg_stat_activity
  • Coverage-Target: keine Regression; client.ts + telemetry.ts ≥ 90 %
  • Test-Files: packages/db/src/client.test.ts, packages/db/src/telemetry.test.ts, packages/db/src/__integration__/rw-split.test.ts

NICHT als completed markieren bevor:

  • Bestehende Tests alle grün
  • Migration-Subagent hat pro Funktion in Commit-Message dokumentiert WARUM read oder write gewählt wurde
  • Staging-Test (mit Replica) zeigt: > 60 % der Dashboard-Reads gehen an Replica (Grafana-Panel)

Acceptance Criteria

  • packages/db/src/client.ts exportiert prisma mit .write und .read Accessors via $extends
  • Fallback funktioniert: ohne DATABASE_READ_URL ist prisma.read === prisma.write
  • Env-Schema kennt DATABASE_READ_URL als optional, dokumentiert
  • Telemetry-Middleware aktiv auf beiden Clients, OpenTelemetry-Metrics live
  • Alle 6 genannten Hot-Path-Module nutzen prisma.read für reine Read-Queries
  • Auth-Pfade in appData.ts bleiben bewusst auf prisma.write (read-after-write Garantie)
  • Grafana-Dashboard prisma-rw-split.json exists und visualisiert die Verteilung
  • Staging-Verifikation: Read-Replica-Container hochgezogen, > 60 % Dashboard-Reads landen auf Replica
  • docs/database/read-replicas.md enthält Setup, Konvention, Tabelle „Use read for X, write for Y", Lag-Considerations
  • Bestehende Test-Suite grün (Single-DB-Fallback in CI)
  • Optional: Integration-Test mit replizierten Postgres-Containern (gated, läuft in nightly-Job)
  • pnpm lint, pnpm typecheck, pnpm test grün
  • Detail-MD Progress Log aktualisiert
  • PROGRESS.md Status auf completed

Rollback Strategy

Pro Modul-Migration separat rollback-bar: git revert <migration-commit> → Modul fällt zurück auf prisma.write (= Primary). Kein Datenverlust, kein DB-Schema-Change.

Global-Disable: DATABASE_READ_URL unsetzen + App-Restart → Read-Client = Write-Client. Replica wird ignoriert, alles geht an Primary. Keine Code-Änderung nötig.

Replica-down-Szenario: Wenn Replica ausfällt:

  • Prisma-Read-Client wirft Connection-Error
  • Sentry-Alert
  • Manuell DATABASE_READ_URL auf Primary umstellen + Restart → läuft weiter (degraded Performance)
  • Future: Auto-Fallback in client.ts (mit Circuit-Breaker) — out of scope für jetzt

DB-Schema: keine Migrations in diesem Task.

Security Notes

  • Read-Replica-Credentials: separater User mit READ-ONLY-Rechten auf Replica (GRANT SELECT ON ALL TABLES IN SCHEMA public TO read_user). Selbst wenn prisma.read versehentlich für Write genutzt wird → Postgres rejected
  • Connection-String-Handling: DATABASE_READ_URL darf nie geloggt werden (Passwort)
  • Replication-Slot-Security: Streaming-Replication-User braucht REPLICATION-Recht, das ist sensibel. Separater replicator-User, nur von Replica-IP erlaubt
  • Cross-Tenant-Data-Leak via Replica-Lag: wenn Tenant-Switch direkt nach Write erfolgt und Loader auf Replica liest, könnte alte Tenant-ID-Daten gelesen werden. Mitigation: Auth-Pfade strikt prisma.write. Code-Review check
  • PII-Considerations: Replica hat full data copy. Backup-/Snapshot-Policy für Replica gleich strikt wie Primary
  • DSGVO: wenn EU-Customer-Data: Replica im selben Land hosten. Hetzner-Falkenstein für Primary, Hetzner-Helsinki für Replica wäre OK (beide EU)

Subagent Recommendations

  • Planning: Plan-Subagent mit packages/db/src/client.ts als Input + Prisma-$extends-Docs — soll Type-Safety prüfen
  • Research: Explore-Subagent für „Liste alle Prisma-Aufrufe in apps/web/app/modules/, kategorisiere als Read-Only vs Write" — Output ist Migration-Backlog für Steps 4-9
  • Implementation:
    • general-purpose für Step 1-3 (Foundation: Client + Env + Telemetry)
    • Ein eigener general-purpose pro Migration-Step (4-9), weil pro Modul Tradeoffs überlegt werden müssen — der Subagent muss pro Funktion Read/Write begründen
    • general-purpose für Step 11 (Doku, umfangreich)
  • Tests: Separater general-purpose für Integration-Test mit testcontainers + Streaming-Replication (Step 12)
  • Verification: gsd-verifier für Acceptance-Criteria; Grafana-Panel-Check als manueller Final-Verify
  • Code-Review: gsd-code-reviewer für alle Migration-Commits — Read/Write-Choices reviewen, Auth-Pfad-Ausnahmen prüfen

Staging-Deployment-Steps

  1. Branch agent/03-rw-split pushen, schrittweise Commits 1-12
  2. Coolify-Setup für Postgres-Replica:
    • Neuen Coolify-Service postgres-replica mit Image postgres:16-alpine
    • Custom-Init-Script in infra/postgres/init-replica.sh:
      # On replica container start
      until pg_basebackup -h postgres -D /var/lib/postgresql/data -U replicator -P -R; do
        sleep 5
      done
      
    • Replica-Postgres-Conf in infra/postgres/postgresql.replica.conf:
      hot_standby = on
      primary_conninfo = 'host=postgres port=5432 user=replicator password=...'
      
    • Primary-Postgres-Conf in infra/postgres/postgresql.conf:
      wal_level = replica
      max_wal_senders = 3
      wal_keep_size = 256MB
      
    • User auf Primary anlegen: CREATE USER replicator WITH REPLICATION ENCRYPTED PASSWORD '...';
  3. App-Env-Vars in Coolify setzen: DATABASE_READ_URL=postgresql://read_user:pass@postgres-replica:5432/toolctrl?connection_limit=10
  4. Deploy via Coolify
  5. Smoke-Tests:
    • Replication-Test: psql -h postgres-replica -U read_user -c "SELECT pg_last_xact_replay_timestamp()" → erwarte aktuelle Timestamp
    • Read-Routing-Test: Login als Test-User, öffne Dashboard, parallel pg_stat_activity auf Replica → erwarte aktive Queries
    • Write-Path-Test: Tenant-Settings ändern, parallel pg_stat_activity auf Primary → erwarte aktive Write-Queries (NICHT auf Replica)
    • Grafana-Verifikation: Dashboard prisma-rw-split zeigt Verteilung; Read-Counter steigt schneller als Write-Counter nach 5 min Browsing
  6. Last-Test (k6):
    k6 run --vus 100 --duration 10m scripts/k6/dashboard-load.js
    
    • Während Test: Primary-CPU vs Replica-CPU vergleichen
    • Acceptance: Replica-CPU > 30 %, Primary-CPU spürbar entlastet
  7. Replication-Lag-Test:
    • pg_basebackup parallel zu Last-Test → check ob Lag unter 5s bleibt
    • Bei höherem Lag: wal_buffers und synchronous_commit tunen
  8. Failover-Drill (optional):
    • Manuell auf Replica pg_promote() → wird zu neuem Primary
    • Coolify-Env umstellen, App neu deployen
    • Doku-Procedure in docs/database/read-replicas.md step-by-step

Deviations from Plan

  • 2026-05-17 — Telemetry middleware uses $extends({ query }) (Prisma 7) instead of the deprecated $use(...) API the spec sketched. The injected recorder is metrics-SDK-agnostic so the existing @toolctrl/metrics prom-client wiring and a future OTel Meter both work without touching packages/db.
  • 2026-05-17 — appData.ts was not migrated wholesale. The spec already calls this out as risky (auth-path read-after-write). Only the secondary admin tenant-enumeration helpers it depends on (adminGetAllActiveTenantSimple, adminGetAllTenantsIdsAndNames) moved to dbRead. The user / current-tenant / role resolution remains on the write client by design.
  • 2026-05-17 — CRM has no apps/web/app/modules/crm/db/*.db.server.ts files (the module lives in services/). The single direct db.user.findMany({}) in CrmService.loadFromUsers was migrated; the rest of CRM goes through ~/utils/db/entities/*.db.server which is shared infrastructure and was left untouched.
  • 2026-05-17 — Replica provisioning (postgres-replica Coolify service, wal_level=replica, pg_basebackup init) is operator-owned; the task ships code-complete and stays in_progress until staging deploys the replica and Grafana verifies > 60 % dashboard reads land on it.
  • 2026-05-27 — Status reconciliation against PROGRESS.md and OPERATOR-CHECKLIST.md: read-replica provisioning is intentionally deferred, not an active A-bucket operator item. With DATABASE_READ_URL unset, dbRead === db keeps the single-primary path live and correct. Do not document #41 as completed until a real read replica is provisioned and the Grafana > 60 % dashboard-read verification has evidence.

Progress Log

  • 2026-05-17 — a3eb0722 — refactor prisma client to expose getReadDbClient / getWriteDbClient / getExtendedDbClient with DATABASE_READ_URL fallback (10 tests pass).
  • 2026-05-17 — dc2b57dc — add @toolctrl/db/telemetry.server (attachPrismaTelemetry + 5 unit tests) wrapping clients via $extends({ query }) with an injectable recorder.
  • 2026-05-17 — 50f47fe1 — env catalog + .env.example + .env.production.example entries for DATABASE_READ_URL with fallback notes.
  • 2026-05-17 — e74567cc — apps/web/app/utils/db.server.ts exports dbRead; CrmService.loadFromUsers migrated.
  • 2026-05-17 — 4da6faf4 — knowledgeBase: 5 db.server files migrated (kbArticles, kbCategories, kbCategorySections, kbAnalytics counts, knowledgeBase) — public reads on replica, mutations + read-then-write analytics helpers on primary.
  • 2026-05-17 — a83c30f8 — analytics: groupUniqueVisitorsBy moved to dbRead.
  • 2026-05-17 — fc6ed8ae — apps/web/app/utils/db/tenants.db.server.ts: admin tenant-listings on dbRead, auth-path reads stay on db.
  • 2026-05-17 — aad650b9 — sites + portals: read-only listings on dbRead, getSiteByTypeAndSiteId stays on db (API auth), portal mutations stay on db.
  • 2026-05-17 — f676511f — docs/architecture/rw-split.md (conventions, lag hazard, rollback, replica setup, migration recipe).

Lessons Learned

  • Prisma 7 dropped $use middleware; $extends({ query: { $allModels: { $allOperations } } }) is the new path and works cleanly for query-counter telemetry without losing model/operation metadata.
  • The $extends return type for getExtendedDbClient() is not assignable to plain PrismaClient, so keeping getDbClient() returning the un-extended write client preserves backwards compatibility with the dozens of existing call-sites that rely on DbClient typing (api-keys, queue-failed-jobs, idempotency, etc.).
  • Single-DB fallback (DATABASE_READ_URL unset → dbRead === db) made every migration commit a zero-risk roll-out: CI single-DB tests stay green, replica is purely an optimisation that lights up when the env var flips.