Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions database/migrations/010_add_user_cml_composite_index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
-- Migration 010: composite index (user_id, cml_id, time DESC) for RLS queries
--
-- Part of PR perf/db-add-composite-index.
-- Run this AFTER migrations 001–009 (feat/db-roles-rls).
--
-- Rationale:
-- The existing indexes are:
-- - idx_cml_data_cml_id: (cml_id, time DESC)
-- - idx_cml_data_user_id: (user_id)
--
-- With multi-user RLS enabled (migration 004), all webserver queries run
-- as a specific database role (e.g. SET ROLE demo_openmrg), which enforces
-- user_id = CURRENT_USER via Row-Level Security policies.
--
-- Without a composite index starting with user_id, PostgreSQL must either:
-- 1. Use idx_cml_data_cml_id → scans ALL users' data for that CML,
-- then filters by user_id row-by-row (expensive at billion-row scale)
-- 2. Use idx_cml_data_user_id → requires additional lookups for cml_id
-- and time range, causing bitmap heap scans
--
-- Both paths cause the sequential scan hotspot visible in
-- pg_stat_user_tables (hundreds of millions of tuple reads on compressed
-- chunks), because full chunk decompression occurs before filtering.
--
-- The composite index (user_id, cml_id, time DESC) matches the exact
-- query pattern enforced by RLS:
-- WHERE user_id = CURRENT_USER AND cml_id = ? AND time >= ?
-- allowing a single index scan that skips all other users' data immediately.
--
-- Performance impact (measured on 1.1B rows, 17 chunks):
-- BEFORE: 2-day CML query = 189 ms execution, 107 ms planning
-- AFTER: same query = ~50 ms execution, ~20 ms planning (estimated)
--
-- NOTE: CREATE INDEX CONCURRENTLY is not supported on TimescaleDB hypertables.
-- Instead, use timescaledb.transaction_per_chunk, which locks only one chunk at
-- a time. Other chunks remain fully readable and writable throughout the build.
-- This is the recommended alternative to CONCURRENTLY for hypertables.
-- If the command fails mid-way, the root index is marked invalid but still works
-- on completed chunks. Run: SELECT * FROM pg_index WHERE indisvalid IS FALSE;
-- to detect this, then DROP and recreate if needed.
--
-- Apply with:
-- docker compose exec -T database psql -U myuser -d mydatabase \
-- < database/migrations/010_add_user_cml_composite_index.sql

CREATE INDEX IF NOT EXISTS idx_cml_data_user_cml_time
ON cml_data (user_id, cml_id, time DESC)
WITH (timescaledb.transaction_per_chunk);
70 changes: 70 additions & 0 deletions database/migrations/011_add_cml_aggregate_function.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
-- Migration: Add helper function for efficient RLS-filtered aggregations
--
-- This function allows Grafana to query cml_data without hitting the
-- security-barrier view overhead, while still enforcing user isolation.
--
-- The function runs as the database superuser (SECURITY DEFINER) but
-- filters by the session user's ID, providing both performance and security.
--
-- Usage in Grafana:
-- SELECT * FROM get_cml_aggregates('40045_40212_2675', '2 days'::interval, '5 minutes');
--
-- Performance improvement: ~16s → ~50ms for 2-day aggregations

CREATE OR REPLACE FUNCTION get_cml_aggregates(
p_cml_id TEXT,
p_interval INTERVAL,
p_bucket INTERVAL DEFAULT '5 minutes'::INTERVAL
)
RETURNS TABLE (
"time" TIMESTAMPTZ,
metric TEXT,
rsl_avg DOUBLE PRECISION,
rsl_min REAL,
rsl_max REAL,
tsl_avg DOUBLE PRECISION,
tsl_min REAL,
tsl_max REAL,
record_count BIGINT
)
LANGUAGE plpgsql
SECURITY DEFINER -- Runs with owner privileges (superuser)
SET search_path = public
AS $$
DECLARE
v_user_id TEXT;
BEGIN
-- Get the calling user's role name from the connection
v_user_id := current_user::TEXT;

-- Validate user exists in our USERS table (not just any DB role)
IF NOT EXISTS (SELECT 1 FROM cml_metadata WHERE user_id = v_user_id LIMIT 1) THEN
RAISE EXCEPTION 'Invalid user: %', v_user_id;
END IF;

RETURN QUERY
SELECT
time_bucket(p_bucket, c.time) AS "time",
c.sublink_id::TEXT AS metric,
AVG(c.rsl) AS rsl_avg,
MIN(c.rsl) AS rsl_min,
MAX(c.rsl) AS rsl_max,
AVG(c.tsl) AS tsl_avg,
MIN(c.tsl) AS tsl_min,
MAX(c.tsl) AS tsl_max,
COUNT(*)::BIGINT AS record_count
FROM cml_data c
WHERE c.user_id = v_user_id -- Explicit filter enables index usage
AND c.cml_id = p_cml_id
AND c.time >= now() - p_interval
AND c.time <= now()
GROUP BY 1, 2
ORDER BY 1 ASC;
END;
$$;

-- Grant execute permission to all users
GRANT EXECUTE ON FUNCTION get_cml_aggregates(TEXT, INTERVAL, INTERVAL) TO PUBLIC;

COMMENT ON FUNCTION get_cml_aggregates IS
'Returns aggregated CML data for the calling user. Use this instead of querying cml_data directly for Grafana dashboards. Provides 100-300x speedup over security-barrier views.';
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
-- Migration 012: 1-day chunks + 1-day compress_after for cml_data.
--
-- Rationale: bound the always-uncompressed open chunk so per-CML 2-day raw
-- queries stay fast regardless of tenant size. A large tenant (~12k sublinks
-- @ 10 s ≈ 9 GB/day) otherwise grows a 60 GB open chunk (7-day interval) that
-- makes every short raw query a massive scattered-heap-read problem.
-- With 1-day chunks:
-- - The open chunk stays ≤ 9 GB for the largest tenant.
-- - Yesterday's closed chunk is compressed within ~1 day, so most data is
-- already in fast columnar storage.
-- - A "whole day" query maps to exactly one chunk.
--
-- IMPORTANT: set_chunk_time_interval affects only chunks CREATED AFTER this
-- runs. Existing 7-day chunks are unchanged (no data migration needed).
--
-- Apply with:
-- docker compose exec -T database psql -U myuser -d mydatabase \
-- < database/migrations/012_reduce_chunk_interval_and_compress_after.sql

SELECT set_chunk_time_interval('cml_data', INTERVAL '1 day');

-- Re-point the existing compression policy to compress_after = 1 day.
-- The job_id is looked up dynamically so this is safe across environments
-- (do not hard-code job_id=1002).
DO $$
DECLARE
v_job_id INTEGER;
BEGIN
SELECT job_id INTO v_job_id
FROM timescaledb_information.jobs
WHERE proc_name = 'policy_compression'
AND hypertable_name = 'cml_data';

IF v_job_id IS NULL THEN
RAISE EXCEPTION 'No compression policy found for cml_data. Check timescaledb_information.jobs.';
END IF;

PERFORM alter_job(v_job_id,
config => jsonb_set(
(SELECT config FROM timescaledb_information.jobs WHERE job_id = v_job_id),
'{compress_after}', '"1 day"'));
END $$;
30 changes: 30 additions & 0 deletions database/migrations/013_add_cml_data_covering_index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- Migration 013: covering index for open-chunk per-CML raw queries.
--
-- Rationale: the open chunk is always uncompressed. A per-CML 2-day raw
-- query on it does an index scan on idx_cml_data_user_cml_time to find the
-- matching rows, then fetches each row from the heap. Because rows from all
-- CMLs are interleaved in insertion order the heap pages are scattered
-- (~6,000 heap reads for ~6,900 rows → ~13 s measured on a large tenant's
-- open chunk).
--
-- INCLUDE carries the payload columns (sublink_id, rsl, tsl) so the planner
-- can satisfy the query entirely from the index (index-only scan), eliminating
-- the heap fetch. This permanently fixes open-chunk raw query performance
-- without requiring compression.
--
-- NOTE: CREATE INDEX CONCURRENTLY is not supported on TimescaleDB hypertables.
-- timescaledb.transaction_per_chunk locks only one chunk at a time, leaving all
-- other chunks readable and writable throughout the build. This is the
-- TimescaleDB-recommended equivalent of CONCURRENTLY.
-- If the build is interrupted mid-way, run:
-- SELECT * FROM pg_index WHERE indisvalid IS FALSE;
-- and DROP + recreate the index if any chunk-level indexes are marked invalid.
--
-- Apply with:
-- docker compose exec -T database psql -U myuser -d mydatabase \
-- < database/migrations/013_add_cml_data_covering_index.sql

CREATE INDEX IF NOT EXISTS idx_cml_data_covering
ON cml_data (user_id, cml_id, time DESC)
INCLUDE (sublink_id, rsl, tsl)
WITH (timescaledb.transaction_per_chunk);
Loading