Fix vulnerability statistics inflation from non-default tracked branches
Summary
When vulnerabilities_across_contexts is enabled, non-default branch vulnerabilities inflate vulnerability_statistics and vulnerability_historical_statistics. This fixes all three statistics update paths to only count default-branch vulnerabilities.
Changes
1. Full recount (Statistics::AdjustmentService)
Add a LEFT JOIN to security_project_tracked_contexts in STATS_SQL with filter WHERE tracked_context_id IS NULL OR is_default = TRUE.
2. Pipeline ingestion (IngestVulnerabilityStatistics)
Switch execute and insert_values to use default_branch_severity_counts instead of severity_counts.
3. Individual state changes (StatisticsUpdateService)
Add non_default_branch_vulnerability? guard that skips statistics updates for non-default branch vulnerabilities. Legacy data (NULL tracked context) continues to update normally.
Design decisions
- No feature flag gate — the filter is a no-op when only default branch data exists, so it's safe unconditionally.
- Option A from historical stats analysis — scope to default branch now, implement per-branch statistics (Option B) when multi-branch tracking is fully rolled out.
Query plan
The modified STATS_SQL adds a LEFT JOIN on security_project_tracked_contexts (joined on PK id). The table has at most 2 rows per project.
Raw SQL (subquery)
SELECT
vulnerability_reads.project_id AS project_id,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE severity = 0) as info,
COUNT(*) FILTER (WHERE severity = 1) as unknown,
COUNT(*) FILTER (WHERE severity = 2) as low,
COUNT(*) FILTER (WHERE severity = 3) as medium,
COUNT(*) FILTER (WHERE severity = 4) as high,
COUNT(*) FILTER (WHERE severity = 5) as critical
FROM vulnerability_reads
LEFT JOIN security_project_tracked_contexts
ON vulnerability_reads.security_project_tracked_context_id = security_project_tracked_contexts.id
WHERE
vulnerability_reads.project_id IN (1, 2, 3) AND
vulnerability_reads.state IN (1, 4) AND
(vulnerability_reads.security_project_tracked_context_id IS NULL
OR security_project_tracked_contexts.is_default = TRUE)
GROUP BY vulnerability_reads.project_id;Query plan
GroupAggregate (cost=0.30..4.39 rows=1 width=64)
Group Key: vulnerability_reads.project_id
-> Nested Loop Left Join (cost=0.30..4.34 rows=1 width=10)
Filter: ((vulnerability_reads.security_project_tracked_context_id IS NULL) OR security_project_tracked_contexts.is_default)
-> Index Scan using index_vulnerability_reads_common_finder_query on vulnerability_reads (cost=0.15..2.17 rows=1 width=18)
Index Cond: ((project_id = ANY ('{1,2,3}'::bigint[])) AND (state = ANY ('{1,4}'::integer[])))
-> Index Scan using security_project_tracked_contexts_pkey on security_project_tracked_contexts (cost=0.15..2.17 rows=1 width=9)
Index Cond: (id = vulnerability_reads.security_project_tracked_context_id)
Planning Time: 17.029 ms
Execution Time: 0.050 msAll index scans — no sequential scans. The join uses index_vulnerability_reads_common_finder_query for the (project_id, state) filter, and security_project_tracked_contexts_pkey for the LEFT JOIN.
References
- Closes #596736
- Related to #578047
- Related to #555991 (closed)