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-Loadapps/web/app/utils/data/.server/appData.ts— Read-Side (Tenant-Lookups, Member-Listings)apps/web/app/modules/sites/*undapps/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 PgBouncer02-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 etabliert03-scaling-infrastructure/tenant-snapshot.md— Snapshot-Reads können von Replica kommen, Restore-Writes Primary04-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.
-
packages/db/src/client.tszentral 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; }, }, }); -
Konvention:
prisma.write.user.findUnique(...)für strikte Konsistenz,prisma.read.user.findUnique(...)für eventually-consistent. -
Migration der Hot-Read-Pfade (siehe Liste oben) explizit pro Funktion auf
prisma.readumstellen. Kein Default-Routing — wir wollen bewusste Entscheidung pro Call. -
Telemetry: Prisma-Middleware zählt pro Client (write vs read), exposed via OpenTelemetry-Counter
prisma.queries.total{client="read|write"}. Grafana-Dashboard zeigt Verteilung. -
Fallback-Logik: Wenn
DATABASE_READ_URLnicht gesetzt ist → Read-Client = Write-Client, keine Replication-Nutzung. So bleibt Code-Konvention auch in Dev ohne Replica funktional. -
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
$extendsist 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
createdirektfindUniqueauf 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
-
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()undprisma.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
- Files:
-
Env-Var-Schema erweitern
- Files:
packages/config/src/env-schema.ts(oder analog),.env.example,.env.production.example DATABASE_READ_URLals 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
- Files:
-
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 mitmodel,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
- Files:
-
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 bleibenprisma.write(oder default-prismaweil 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
- Files: alle List-Query-Funktionen in
-
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
-
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
-
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
-
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
getUserAndTenantsaufgerufen wird, hat Replica evtl. den frisch erstellten User noch nicht (Replication-Lag). Für Auth-Pfade weiterhinprisma.writenutzen, dokumentieren - Commit:
03-scaling/rw-split: route appdata read side to replica with auth path exception
- Files:
-
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
-
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.mdergänzen um „wir beobachten read/write-split via Grafana" - Commit:
03-scaling/rw-split: add grafana dashboard for read/write split observability
- Files:
-
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.readfür: Listings, Dashboards, Public-Pages, Reports, Search-Result-Display - Use
prisma.writefür: Mutations, Auth-Pfade (Login/Session), Stripe-Webhook-Reads-die-Writes-vorangehen, Outbox-Reads im Dispatcher
- Use
- 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_replicationauf 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)
- Setup: wie Postgres-Streaming-Replication aufgesetzt wird (
- Commit:
03-scaling/rw-split: add read replicas documentation with usage guidelines
- Files:
-
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
- Files:
Test Requirements
- Unit-Tests:
client.tsFallback-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.tsexportiertprismamit.writeund.readAccessors via$extends- Fallback funktioniert: ohne
DATABASE_READ_URListprisma.read === prisma.write - Env-Schema kennt
DATABASE_READ_URLals optional, dokumentiert - Telemetry-Middleware aktiv auf beiden Clients, OpenTelemetry-Metrics live
- Alle 6 genannten Hot-Path-Module nutzen
prisma.readfür reine Read-Queries - Auth-Pfade in
appData.tsbleiben bewusst aufprisma.write(read-after-write Garantie) - Grafana-Dashboard
prisma-rw-split.jsonexists und visualisiert die Verteilung - Staging-Verifikation: Read-Replica-Container hochgezogen, > 60 % Dashboard-Reads landen auf Replica
docs/database/read-replicas.mdenthä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 testgrü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_URLauf 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 wennprisma.readversehentlich für Write genutzt wird → Postgres rejected - Connection-String-Handling:
DATABASE_READ_URLdarf 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 mitpackages/db/src/client.tsals Input + Prisma-$extends-Docs — soll Type-Safety prüfen - Research:
Explore-Subagent für „Liste alle Prisma-Aufrufe inapps/web/app/modules/, kategorisiere als Read-Only vs Write" — Output ist Migration-Backlog für Steps 4-9 - Implementation:
general-purposefür Step 1-3 (Foundation: Client + Env + Telemetry)- Ein eigener
general-purposepro Migration-Step (4-9), weil pro Modul Tradeoffs überlegt werden müssen — der Subagent muss pro Funktion Read/Write begründen general-purposefür Step 11 (Doku, umfangreich)
- Tests: Separater
general-purposefür Integration-Test mit testcontainers + Streaming-Replication (Step 12) - Verification:
gsd-verifierfür Acceptance-Criteria; Grafana-Panel-Check als manueller Final-Verify - Code-Review:
gsd-code-reviewerfür alle Migration-Commits — Read/Write-Choices reviewen, Auth-Pfad-Ausnahmen prüfen
Staging-Deployment-Steps
- Branch
agent/03-rw-splitpushen, schrittweise Commits 1-12 - Coolify-Setup für Postgres-Replica:
- Neuen Coolify-Service
postgres-replicamit Imagepostgres: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 '...';
- Neuen Coolify-Service
- App-Env-Vars in Coolify setzen:
DATABASE_READ_URL=postgresql://read_user:pass@postgres-replica:5432/toolctrl?connection_limit=10 - Deploy via Coolify
- 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_activityauf Replica → erwarte aktive Queries - Write-Path-Test: Tenant-Settings ändern, parallel
pg_stat_activityauf Primary → erwarte aktive Write-Queries (NICHT auf Replica) - Grafana-Verifikation: Dashboard
prisma-rw-splitzeigt Verteilung; Read-Counter steigt schneller als Write-Counter nach 5 min Browsing
- Replication-Test:
- 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
- Replication-Lag-Test:
pg_basebackupparallel zu Last-Test → check ob Lag unter 5s bleibt- Bei höherem Lag:
wal_buffersundsynchronous_committunen
- 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.mdstep-by-step
- Manuell auf Replica
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/metricsprom-client wiring and a future OTelMeterboth work without touchingpackages/db. - 2026-05-17 —
appData.tswas 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 todbRead. 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.tsfiles (the module lives inservices/). The single directdb.user.findMany({})inCrmService.loadFromUserswas migrated; the rest of CRM goes through~/utils/db/entities/*.db.serverwhich is shared infrastructure and was left untouched. - 2026-05-17 — Replica provisioning (
postgres-replicaCoolify service,wal_level=replica,pg_basebackupinit) is operator-owned; the task ships code-complete and staysin_progressuntil staging deploys the replica and Grafana verifies> 60 %dashboard reads land on it. - 2026-05-27 — Status reconciliation against
PROGRESS.mdandOPERATOR-CHECKLIST.md: read-replica provisioning is intentionally deferred, not an active A-bucket operator item. WithDATABASE_READ_URLunset,dbRead === dbkeeps 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
$usemiddleware;$extends({ query: { $allModels: { $allOperations } } })is the new path and works cleanly for query-counter telemetry without losing model/operation metadata. - The
$extendsreturn type forgetExtendedDbClient()is not assignable to plainPrismaClient, so keepinggetDbClient()returning the un-extended write client preserves backwards compatibility with the dozens of existing call-sites that rely onDbClienttyping (api-keys, queue-failed-jobs, idempotency, etc.). - Single-DB fallback (
DATABASE_READ_URLunset →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.