From 6d46a4775ad37ea6f8c145d1343ed7fb13b7be80 Mon Sep 17 00:00:00 2001 From: Rodrigo Matos Date: Wed, 26 Aug 2026 16:34:13 -0300 Subject: [PATCH] fix(platform): eliminate N+1 query in getOrgChangeDetections (#180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detecting metric changes across an org's repos issued one `metrics` query per repo, sequentially — a round-trip count that scales linearly with repo count and can stall the ChangeAlertPanel's stream on large orgs. Extends the repo_metric_summaries view (023) with the previous run's values for the metrics detectChanges compares, so the DB does the per-repo "latest vs previous" aggregation once instead of the app looping N times. getOrgChangeDetections now reads that plus the repo list — 2 bulk queries total regardless of repo count — mirroring the same fix getOrgReposSummary already applies against this view (022). Needs the migration applied to the actual Supabase project (`supabase db push` or via the dashboard) before this takes effect — not run from here. Co-Authored-By: claude-code_2-1-238_agent --- platform/lib/queries/temporal.ts | 64 ++++++++++++++----- .../023_repo_metric_summaries_prev_values.sql | 53 +++++++++++++++ 2 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 platform/supabase/migrations/023_repo_metric_summaries_prev_values.sql diff --git a/platform/lib/queries/temporal.ts b/platform/lib/queries/temporal.ts index 2a4fec3..d4352c5 100644 --- a/platform/lib/queries/temporal.ts +++ b/platform/lib/queries/temporal.ts @@ -378,36 +378,68 @@ export function detectChanges( return changes; } -/** Detect changes across all repos in an org. */ +/** + * Detect changes across all repos in an org. + * + * Uses 2 bulk queries instead of an N+1 (one `metrics` query per repo, + * serialized): the `repo_metric_summaries` view already carries the latest + * and previous run's values for every repo in one pre-aggregated row (see + * 023_repo_metric_summaries_prev_values.sql), so this reads one row per + * repo instead of looping — mirroring the same fix getOrgReposSummary + * already applies against this view. + */ export async function getOrgChangeDetections( supabase: SupabaseClient, organizationId: string, windowDays: number = DEFAULT_WINDOW_DAYS, ): Promise { + // Query 1: all repos (for display names — the view only has repository_id). const { data: repos } = await supabase .from("repositories") .select("id, name") .eq("organization_id", organizationId); - if (!repos) return []; + if (!repos || repos.length === 0) return []; + + // Query 2: pre-aggregated latest + previous values, one row per repo. + const { data: summaries } = await supabase + .from("repo_metric_summaries") + .select( + "repository_id, runs_count, last_run_at, stabilization_ratio, prev_stabilization_ratio, revert_rate, prev_revert_rate, churn_events, prev_churn_events, ai_detection_coverage_pct, prev_ai_detection_coverage_pct, prev_created_at", + ) + .eq("organization_id", organizationId) + .eq("window_days", windowDays); + + const summaryByRepo = new Map< + string, + NonNullable[number] + >(); + for (const row of summaries ?? []) summaryByRepo.set(row.repository_id, row); const allChanges: ChangeDetection[] = []; for (const repo of repos) { - const { data: runs } = await supabase - .from("metrics") - .select( - "created_at, stabilization_ratio, revert_rate, churn_events, commits_total, ai_detection_coverage_pct, pr_merged_count, pr_single_pass_rate, fix_latency_median_hours, cascade_rate", - ) - .eq("repository_id", repo.id) - .eq("window_days", windowDays) - .order("created_at", { ascending: false }) - .limit(2); - - if (!runs || runs.length < 2) continue; - - const current: TimeSeriesPoint = { date: runs[0].created_at, ...runs[0] }; - const previous: TimeSeriesPoint = { date: runs[1].created_at, ...runs[1] }; + const s = summaryByRepo.get(repo.id); + if (!s || s.runs_count < 2) continue; + + // commits_total isn't compared by detectChanges — left null rather than + // guessed at, since the view doesn't track a "previous" value for it. + const current: TimeSeriesPoint = { + date: s.last_run_at, + stabilization_ratio: s.stabilization_ratio, + revert_rate: s.revert_rate, + churn_events: s.churn_events, + commits_total: null, + ai_detection_coverage_pct: s.ai_detection_coverage_pct, + }; + const previous: TimeSeriesPoint = { + date: s.prev_created_at, + stabilization_ratio: s.prev_stabilization_ratio, + revert_rate: s.prev_revert_rate, + churn_events: s.prev_churn_events, + commits_total: null, + ai_detection_coverage_pct: s.prev_ai_detection_coverage_pct, + }; allChanges.push(...detectChanges(repo.name, repo.id, current, previous)); } diff --git a/platform/supabase/migrations/023_repo_metric_summaries_prev_values.sql b/platform/supabase/migrations/023_repo_metric_summaries_prev_values.sql new file mode 100644 index 0000000..77dd516 --- /dev/null +++ b/platform/supabase/migrations/023_repo_metric_summaries_prev_values.sql @@ -0,0 +1,53 @@ +-- Extends repo_metric_summaries with the previous run's values for the +-- metrics getOrgChangeDetections compares (revert_rate, churn_events, +-- ai_detection_coverage_pct) plus the previous run's timestamp. +-- +-- getOrgChangeDetections used to fetch the org's repos, then loop over them +-- issuing one `metrics` query per repo (limit 2, newest-first) to compare +-- the latest run against the one before it — an N+1 that scales linearly +-- with repo count and serializes one round-trip per repo. +-- +-- The view already computes prev_stabilization_ratio ([2] = second-newest) +-- for the sparkline delta arrow; extending it with the same [2] slot for +-- the other compared metrics lets getOrgChangeDetections read one +-- pre-aggregated row per repo instead of looping, mirroring the same +-- "2 bulk queries instead of N+1" fix getOrgReposSummary already uses +-- against this view (see 022_repo_metric_summaries.sql). + +CREATE OR REPLACE VIEW repo_metric_summaries AS +SELECT + repository_id, + organization_id, + window_days, + + count(*) AS runs_count, + max(created_at) AS last_run_at, + + -- Latest run's indexed values ([1] = newest by created_at). + (array_agg(stabilization_ratio ORDER BY created_at DESC))[1] AS stabilization_ratio, + (array_agg(revert_rate ORDER BY created_at DESC))[1] AS revert_rate, + (array_agg(churn_events ORDER BY created_at DESC))[1] AS churn_events, + (array_agg(commits_total ORDER BY created_at DESC))[1] AS commits_total, + (array_agg(ai_detection_coverage_pct ORDER BY created_at DESC))[1] AS ai_detection_coverage_pct, + (array_agg(pr_merged_count ORDER BY created_at DESC))[1] AS pr_merged_count, + (array_agg(pr_single_pass_rate ORDER BY created_at DESC))[1] AS pr_single_pass_rate, + (array_agg(fix_latency_median_hours ORDER BY created_at DESC))[1] AS fix_latency_median_hours, + (array_agg(cascade_rate ORDER BY created_at DESC))[1] AS cascade_rate, + (array_agg(merge_strategy ORDER BY created_at DESC))[1] AS merge_strategy, + (array_agg(commit_metrics_reliable ORDER BY created_at DESC))[1] AS commit_metrics_reliable, + + -- Previous run's values ([2] = second newest) for the change-detection + -- current-vs-previous comparison in getOrgChangeDetections. + (array_agg(created_at ORDER BY created_at DESC))[2] AS prev_created_at, + (array_agg(stabilization_ratio ORDER BY created_at DESC))[2] AS prev_stabilization_ratio, + (array_agg(revert_rate ORDER BY created_at DESC))[2] AS prev_revert_rate, + (array_agg(churn_events ORDER BY created_at DESC))[2] AS prev_churn_events, + (array_agg(ai_detection_coverage_pct ORDER BY created_at DESC))[2] AS prev_ai_detection_coverage_pct, + + -- Newest-first stabilization values; the caller slices SPARKLINE_POINTS, + -- reverses to chronological, and drops nulls. 50 is more than any sparkline + -- needs while keeping the array small. + (array_agg(stabilization_ratio ORDER BY created_at DESC))[1:50] AS recent_stabilization + +FROM metrics +GROUP BY repository_id, organization_id, window_days;