Files
HiveGrid-Wireframes/hivegrid-cloud/docs/architecture/rw-split.md
T

202 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Read / Write Split (Prisma `$extends`)
> Phase 03 task #41. Status: code-complete; replica provisioning is operator-owned.
## TL;DR
```ts
import { db, dbRead } from "~/utils/db.server";
// Default — primary (write target). Use for all writes, auth, read-after-write.
await db.user.update({ where: { id }, data: { lastSeenAt: new Date() } });
// Explicit primary — same client, communicates intent.
import { getWriteDbClient } from "@toolctrl/db/client.server";
// Replica — eventually consistent. Use for listings, dashboards, public reads.
const users = await dbRead.user.findMany({ where: { tenantId } });
```
`dbRead` is wired to `DATABASE_READ_URL` when set. When unset (single-DB dev,
self-hosted setups) it returns the same client as `db`, so the code path is
identical and lag = 0.
## Convention: when to use which
| Use `dbRead` (replica) | Use `db` / `getWriteDbClient()` (primary) |
| ---------------------- | ----------------------------------------- |
| List endpoints / paginated grids | All mutations (`create`, `update`, `delete`, `upsert`) |
| Public marketing pages (sites, portals) | Auth-resolution paths (session → user → tenant) |
| Knowledge-base public reads + search | Stripe webhook handlers reading state they just wrote |
| Analytics dashboards & aggregations | Outbox dispatcher reading `PENDING` events (see #42) |
| Admin tenant / role enumerations (post-auth) | Anything inside a `db.$transaction(...)` callback |
| Dashboard counts (`count()` / `groupBy()`) | Idempotency-key lookups (request-scoped consistency) |
| Search-result rendering | Read-after-write inside the same request |
When in doubt: leave on `db` (= primary). The replica is an optimisation,
not a correctness requirement. **Auto-routing is intentionally not
implemented** — the spec rejects it because it makes read-after-write
hazards invisible.
## Read-after-write hazard
Streaming replication is **eventually consistent**:
- Typical lag: < 1 s
- Burst writes (bulk imports, k6 load tests): up to 5–10 s
- A query issued on the replica milliseconds after the write hits the
primary will read the **pre-write** state.
**Mitigation:**
1. Always use `db` inside transactions and for writes.
2. Never re-read on `dbRead` what you just wrote on `db` in the same
request. If you must, route the read through `db` too.
3. Auth-critical paths (`getUserAndTenants`, session resolution) stay on
`db` even though they're "reads" — they gate the rest of the
request, and a stale read can mis-route a tenant boundary check.
## Fallback semantics
`getReadDbClient()`:
```ts
export function getReadDbClient(): DbClient {
const readUrl = process.env.DATABASE_READ_URL;
if (!readUrl || readUrl.length === 0) {
return getDbClient(); // single instance, no double connection
}
// memoised replica singleton
if (!global.__dbRead) {
global.__dbRead = createDbClient({ connectionString: readUrl });
}
return global.__dbRead;
}
```
| State of `DATABASE_READ_URL` | `dbRead` returns |
| ---------------------------- | --------------------------------- |
| unset / empty | the write client (`db === dbRead`) |
| valid `postgres://...` | a distinct read-only client |
This means CI, local dev, and any deploy without a replica continue to
work without code changes. All migrations are zero-risk to roll out.
## Telemetry
`@toolctrl/db/telemetry.server` exposes `attachPrismaTelemetry()` — a
`$extends({ query })` wrapper that records every operation with
`{ client: 'read' | 'write', model, operation, durationMs, ok }`. Wire it
in the app bootstrap by adapting the recorder to your metrics sink
(prom-client `Histogram` / `Counter` or an OpenTelemetry `Meter`). The
canonical counter shape is `prisma.queries.total{client,model,operation}`.
## Coolify / staging setup (operator-owned)
Replica provisioning is **out of scope** for the application repo — these
steps live with the platform operator.
1. **Primary Postgres** (Coolify service `postgres`). `postgresql.conf`:
```
wal_level = replica
max_wal_senders = 3
wal_keep_size = 256MB
```
Then:
```sql
CREATE USER replicator WITH REPLICATION ENCRYPTED PASSWORD '...';
CREATE USER read_user WITH ENCRYPTED PASSWORD '...';
GRANT CONNECT ON DATABASE toolctrl TO read_user;
GRANT USAGE ON SCHEMA public TO read_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO read_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO read_user;
```
2. **Replica Postgres** (Coolify service `postgres-replica`,
`postgres:16-alpine`):
```bash
# Init from primary
until pg_basebackup -h postgres -D /var/lib/postgresql/data -U replicator -P -R; do
sleep 5
done
```
`postgresql.replica.conf`:
```
hot_standby = on
primary_conninfo = 'host=postgres port=5432 user=replicator password=...'
```
3. **App env**:
```sh
DATABASE_URL=postgres://app_user:...@pgbouncer:6432/toolctrl?pgbouncer=true&connection_limit=10
DATABASE_READ_URL=postgres://read_user:...@postgres-replica:5432/toolctrl?connection_limit=10
```
Optionally front the replica with its own PgBouncer for connection pooling.
4. **Verification:**
```sql
-- On primary
SELECT * FROM pg_stat_replication;
-- On replica
SELECT pg_last_xact_replay_timestamp(), now() - pg_last_xact_replay_timestamp() AS lag;
```
Grafana panel `prisma.queries.total{client="read"}` should rise on
dashboard / KB / portal traffic.
## Rollback
- **Per-call-site:** `git revert <migration-commit>` flips that module
back to `db` (primary). No data change.
- **Global:** unset `DATABASE_READ_URL` and restart the app. `dbRead === db`,
all traffic hits the primary.
- **Replica down:** Prisma's read client will throw connection errors.
Either unset `DATABASE_READ_URL` (degraded performance, no errors) or
add a circuit-breaker (future work, out of scope here).
## Migration recipe (for future modules)
1. **Find candidate read-only functions** — pure `findMany`, `findUnique`,
`findFirst`, `count`, `groupBy`, `aggregate` with no surrounding write.
2. **Check the caller graph** — is this function called immediately
after a write that updates the same rows? If yes, leave on `db`.
3. **Replace `db.<model>.<op>` with `dbRead.<model>.<op>`** and add a
one-line comment explaining the choice.
4. **Add `dbRead` to the file's imports** alongside `db` (keep `db` for
the mutations still in the file).
5. **Run** `pnpm lint --max-warnings=0` and `pnpm typecheck`.
6. **Tests** stay green because of the fallback semantics — single-DB
CI doesn't see a difference.
7. **Commit per-module** (`03-scaling/rw-split: migrate <module> ...`)
so a single revert can roll back one module.
## What was migrated in #41
- `apps/web/app/modules/crm/services/CrmService.ts` — user enumeration
for sync display.
- `apps/web/app/modules/knowledgeBase/db/*.db.server.ts` — public KB
reads, listings, search, featured-article queries, dashboard counts.
- `apps/web/app/modules/analytics/db/analyticsUniqueVisitors.db.server.ts`
— `groupBy` aggregations.
- `apps/web/app/utils/db/tenants.db.server.ts` — admin tenant enumerations
(post-auth).
- `apps/web/app/modules/sites/db/sites.db.server.ts` — read-only site
loaders (auth-gating `getSiteByTypeAndSiteId` left on primary).
- `apps/web/app/modules/portals/db/portals.db.server.ts` and
`portalPages.db.server.ts` — listings, page lookups, dashboard counts.
`appData.ts` was **not** migrated wholesale; only the secondary tenant
listings it consumes were moved. The auth-resolution path stays on
primary by design.