feat(web): observable read-replica and slot replication health - #4887
Conversation
pg_stat_replication only lists connected walsenders, so a read replica whose walreceiver has died is invisible there. Add a shared replication-health module that probes each replica directly for its own replay delay, reads pg_replication_slots for logical (Snowflake) slot health, and still reports connected walsenders. - Rewrite /api/internal/db/replication-lag to return the full health report (replicas, walSenders, slots, errors, healthy). - Add /api/cron/db-replication-health (every 5m) that emits per-replica and per-slot metrics to Axiom and alerts Sentry on lag/unreachable replicas or at-risk slots.
Code Review SummaryStatus: 1 Issue Found | Recommendation: Merge after optional cleanup Executive SummaryIncremental review of the three follow-up commits: the previously flagged throw-out-of- Overview
Issue Details (click to expand)SUGGESTION
Previously reported, now resolved
Files Reviewed (2 files)
Notes and assumptions
Fix these issues in Kilo Cloud Previous Review Summary (commit aa04078)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit aa04078)Status: 4 Issues Found | Recommendation: Address before merge Executive SummaryThe new replication monitor has blind spots that can report Overview
Issue Details (click to expand)WARNING
Files Reviewed (7 files)
Notes and assumptions
Reviewed by claude-opus-5 · Input: 52 · Output: 14K · Cached: 1.5M Review guidance: REVIEW.md from base branch |
createDrizzleClient runs new URL(connectionString) via getDatabaseClientConfig, which throws synchronously on a malformed POSTGRES_REPLICA_* value. It ran outside probeReplica's try, and collectReplicationHealth wrapped the probes in a bare Promise.all, so one bad connection string threw past the 'never throws' contract and 500'd the endpoint / blanked walsender and slot data. Move createDrizzleClient inside the try (guarding pool.end with client?.), and isolate each probe with a .catch that maps failures to status: 'unreachable'. Addresses review comment on apps/web/src/lib/replication-health.ts (probe throw isolation).
pg.Pool emits 'error' when an idle/checked-out client's connection drops unexpectedly. With no listener, Node treats it as an unhandled 'error' event and terminates the process - a real risk here because these pools exist to probe replicas that may already be unhealthy. The long-lived pools in lib/drizzle.ts attach a listener for exactly this reason; do the same for the probe pool. Addresses review comment on apps/web/src/lib/replication-health.ts (probe pool error listener).
getEnvVariable returns '' for missing vars, so if the POSTGRES_REPLICA_* values are unset or renamed, targets is [] and replicas.every(...) on an empty array is true - the report and cron both claim healthy: true while checking nothing. For a monitor built to catch invisible failures, that is itself an invisible failure. Push an error (forcing healthy: false and a cron alert) when the configured target count is below EXPECTED_REPLICA_COUNT in production. Gated on VERCEL_ENV === 'production' so preview/dev, which legitimately run without replica URLs, do not false-alarm. Addresses review comment on apps/web/src/lib/replication-health.ts (zero-target silent healthy).
Why
We had a US-west read replica silently stuck ~8 days behind (its walreceiver crash-looped after the primary recycled WAL it still needed). The existing `/api/internal/db/replication-lag` endpoint queried `pg_stat_replication`, which only lists currently-connected walsenders — so a broken replica has no row and is invisible, exactly the failure we most want to catch. Logical (Snowflake) slots disappear from that view the same way once their consumer disconnects.
What
New shared module `apps/web/src/lib/replication-health.ts` combining three signals:
`collectReplicationHealth()` isolates primary-query failures into `errors[]` and returns a single `healthy` boolean.
Endpoint A — `/api/internal/db/replication-lag`
Returns the full report (`healthy`, `replicas`, `walSenders`, `slots`, `errors`, `timestamp`). Same `X-Internal-Secret` auth. Response shape changed (no other consumers).
Cron B — `/api/cron/db-replication-health` (new, `*/5 * * * *`)
Reuses the module, emits per-replica/per-slot JSON to the Vercel→Axiom log drain, and `captureException`s to Sentry on lagging/unreachable replicas or at-risk slots.
Design note: I deliberately did not extend `db-pool-metrics`. That cron is a single-purpose Supabase Prometheus scraper, and the `physical_replication_lag_*` metric has a documented history of returning no data (and can share the same connected-only blind spot). A dedicated cron on the reliable replica-side SQL probe is cleaner and more trustworthy.
Testing
Follow-ups (non-blocking)