Files
HealthLog/prisma/migrations/0210_v125_measurement_useractivity_index/migration.sql
T
Marc-André Bombeck 84683f4ce9 perf: bound heavy-tenant memory on feature, consolidation, backup and auth paths
Cap and page the unbounded reads that let a multi-year tenant pull its
whole measurement history into memory on hot paths.

- extractFeatures: read newest-first with a 6000-row cap (mirrors the
  Coach snapshot) and reverse to ascending, so a 365-400 day window no
  longer materialises hundreds of thousands of PULSE / glucose rows per
  Coach turn and nightly briefing. Full-history extremes still come from
  readAllTimeExtremes, so the cap never relabels an extreme.
- Add a (userId, measuredAt) index on measurements (migration 0210) for
  the type-less range scans that extractFeatures and the consolidation
  passes drive; the existing (userId, type, measuredAt) cannot serve them
  as a prefix.
- runConsolidation: keyset-paginate the per-(user, type) source scan on
  (measuredAt, id) instead of one unbounded findMany, removing the giant
  single result-set allocation that drove the boot storm. Output is
  identical; covered by a paging unit test.
- Stagger the boot-time backfill enqueues with increasing startAfter
  offsets so the heavy full-history loads no longer all drain in parallel
  onto one tenant at startup. Non-boot callers keep their immediate send.
- Backups: replace load-all-then-stringify with paged narrow-select reads
  for the weekly and off-host writers, bounding transient heap. Off-host
  still includes soft-deleted rows. Covered by a serialize/restore
  round-trip test.
- Drop the per-request auth.bearer.success audit insert; the wide event
  already records auth_method and user_id, so the polling client no longer
  drives a write + pool checkout on every request. Failure-path audit
  stays.
- Coach conversation detail: cap the message include to the newest 200 so
  a long thread no longer decrypts every message on each open; response
  shape unchanged.
- Admin audit-log: floor the scan at the retention horizon so the listing
  and its count stop sweeping open-ended over the substring filters.
2026-06-28 03:07:15 +02:00

31 lines
1.7 KiB
SQL

-- v1.25 — type-less `(user_id, measured_at)` supporting index on `measurements`.
--
-- The bulk feature read (`extractFeatures`, src/lib/insights/features.ts) and
-- the consolidation scans walk a user's measurements ordered by `measured_at`
-- with no `type` predicate. The existing composite indexes cover
-- `(user_id, type, measured_at)`, `(user_id, source, measured_at)`, and
-- `(user_id, updated_at, id)` — none serves a type-less `(user_id, measured_at)`
-- range scan as a prefix. Without this index Postgres filters by `user_id` then
-- sorts the user's entire measurement set in memory on each of those reads,
-- which on a multi-year HealthKit tenant is hundreds of thousands of rows.
--
-- The composite `(user_id, measured_at)` index lets those reads stream straight
-- off the index in time order, which the newest-first row caps then bound.
--
-- Additive + order-safe: a pure `CREATE INDEX IF NOT EXISTS`, no column add, no
-- backfill, no rewrite of `measurements`.
--
-- Lock note: this is a plain, non-`CONCURRENTLY` index build, so it runs inside
-- Prisma's migration transaction and holds an ACCESS EXCLUSIVE lock on
-- `measurements` for the build's duration. The index covers every row (no
-- partial predicate), so on the largest multi-year HealthKit tenants the build
-- is slow; run the deploy at a low-traffic window on such deployments. A
-- `CONCURRENTLY` build cannot run inside the migration transaction; a plain
-- build is acceptable at this table size in the maintenance window.
--
-- Reversibility: down migration is
-- DROP INDEX IF EXISTS "measurements_user_id_measured_at_idx";
CREATE INDEX IF NOT EXISTS "measurements_user_id_measured_at_idx"
ON "measurements" ("user_id", "measured_at");