← back to NationalPaperHangers

db/migrations/003_perf.sql

75 lines

-- 003 · Performance migrations from database-optimizer review (2026-05-05)
--
-- All CREATE INDEX statements use CONCURRENTLY where possible. Note: PG won't
-- run CREATE INDEX CONCURRENTLY inside a transaction block, so this file is
-- intentionally not wrapped in BEGIN/COMMIT. Apply with:
--
--   psql -d national_paper_hangers -f db/migrations/003_perf.sql
--
-- Idempotent via IF NOT EXISTS / ON CONFLICT guards.

-- 1) Slot-calc hot-path index — partial composite covering only the statuses
-- that count toward conflict checks. Replaces the broader idx_bookings_scheduled.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_bookings_slot_hot
  ON bookings (installer_id, scheduled_start, scheduled_end)
  WHERE status IN ('pending', 'confirmed');

DROP INDEX CONCURRENTLY IF EXISTS idx_bookings_scheduled;

-- 2) Lead-list filter — unclaimed studios with a scrapeable website.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_installers_unclaimed_with_site
  ON installers (state, city)
  WHERE claim_status = 'unclaimed' AND website IS NOT NULL;

-- 3) Time-off range scans — BRIN is tiny vs BTree for naturally time-ordered data.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_time_off_start_brin
  ON installer_time_off USING BRIN (start_at, end_at)
  WITH (pages_per_range = 32);

-- 4) Denormalize avg_rating + review_count onto installers (avoids GROUP BY
-- join on every directory listing render at scale).
ALTER TABLE installers
  ADD COLUMN IF NOT EXISTS avg_rating NUMERIC(3,2),
  ADD COLUMN IF NOT EXISTS review_count INTEGER DEFAULT 0;

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_installers_rating
  ON installers (avg_rating DESC NULLS LAST)
  WHERE status = 'active';

-- One-shot backfill for any existing reviews. Safe to re-run.
UPDATE installers i
   SET avg_rating   = sub.avg,
       review_count = sub.cnt
  FROM (
    SELECT installer_id,
           ROUND(AVG(rating)::numeric, 2) AS avg,
           COUNT(*)::int                  AS cnt
      FROM installer_reviews
     WHERE published = true
     GROUP BY installer_id
  ) sub
 WHERE sub.installer_id = i.id;

-- 5) GIN on ad_signals JSONB — supports `@>` containment + key-exists as the
-- signals schema grows beyond paid_ads_count.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_installers_ad_signals_gin
  ON installers USING GIN (ad_signals)
  WHERE ad_signals IS NOT NULL;

-- 6) Subscription-events orphan cleanup index. installer_id can become NULL
-- via ON DELETE SET NULL — make those rows easy to find for retention sweeps.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sub_events_no_installer
  ON subscription_events (created_at)
  WHERE installer_id IS NULL;

-- 7) Autovacuum tuning for high-churn tables.
ALTER TABLE bookings SET (
  autovacuum_vacuum_scale_factor = 0.01,
  autovacuum_analyze_scale_factor = 0.005
);

ALTER TABLE scrape_log SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_vacuum_insert_scale_factor = 0.01
);