use std::collections::{HashMap, HashSet};
use crate::correlate::Trace;
use crate::event::EventType;
use super::n_plus_one::parse_timestamp_ms;
use super::{Confidence, Finding, FindingType, Pattern, Severity};
type PoolKey<'a> = (&'a str, Option<(&'a str, &'a str)>);
#[must_use]
pub fn detect_pool_saturation(trace: &Trace, threshold: u32) -> Vec<Finding> {
saturated_services(trace, threshold)
.into_iter()
.map(|(service, indices, peak)| build_saturation_finding(trace, service, &indices, peak))
.collect()
}
#[must_use]
pub(crate) fn detect_pool_saturation_with_spans(
trace: &Trace,
threshold: u32,
) -> Vec<(Finding, Vec<&str>)> {
saturated_services(trace, threshold)
.into_iter()
.map(|(service, indices, peak)| {
(
build_saturation_finding(trace, service, &indices, peak),
peak_span_ids(trace, &indices),
)
})
.collect()
}
fn saturated_services(trace: &Trace, threshold: u32) -> Vec<(&str, Vec<usize>, u32)> {
let threshold = threshold as usize;
group_sql_indices_by_service(trace)
.into_iter()
.filter_map(|((service, _grouping), indices)| {
if indices.len() < threshold {
return None;
}
let peak = compute_peak_concurrency(trace, &indices);
((peak as usize) >= threshold).then_some((service, indices, peak))
})
.collect()
}
fn group_sql_indices_by_service(trace: &Trace) -> HashMap<PoolKey<'_>, Vec<usize>> {
let mut sql_by_service: HashMap<PoolKey<'_>, Vec<usize>> =
HashMap::with_capacity(trace.spans.len().min(16));
for (i, span) in trace.spans.iter().enumerate() {
if span.event.event_type == EventType::Sql {
sql_by_service
.entry((span.event.service.as_ref(), span.event.grouping_identity()))
.or_default()
.push(i);
}
}
sql_by_service
}
fn compute_peak_concurrency(trace: &Trace, indices: &[usize]) -> u32 {
let mut sweep: Vec<(u64, bool)> = Vec::with_capacity(indices.len() * 2);
for &idx in indices {
let span = &trace.spans[idx];
if let Some(start_ms) = parse_timestamp_ms(&span.event.timestamp) {
let start_us = start_ms.saturating_mul(1000);
let end_us = start_us.saturating_add(span.event.duration_us);
sweep.push((start_us, true)); sweep.push((end_us, false)); }
}
sweep.sort_unstable();
let mut current: u32 = 0;
let mut peak: u32 = 0;
for &(_, is_start) in &sweep {
if is_start {
current += 1;
} else {
current = current.saturating_sub(1);
}
if current > peak {
peak = current;
}
}
peak
}
fn peak_span_ids<'a>(trace: &'a Trace, indices: &[usize]) -> Vec<&'a str> {
let mut sweep: Vec<(u64, bool, usize)> = Vec::with_capacity(indices.len() * 2);
for &idx in indices {
let span = &trace.spans[idx];
if span.event.duration_us == 0 {
continue;
}
if let Some(start_ms) = parse_timestamp_ms(&span.event.timestamp) {
let start_us = start_ms.saturating_mul(1000);
sweep.push((start_us, true, idx));
sweep.push((start_us.saturating_add(span.event.duration_us), false, idx));
}
}
sweep.sort_unstable();
let mut active = HashSet::with_capacity(indices.len());
let mut peak = Vec::new();
for (_, is_start, idx) in sweep {
if is_start {
active.insert(idx);
if active.len() > peak.len() {
peak = active.iter().copied().collect();
peak.sort_unstable();
}
} else {
active.remove(&idx);
}
}
peak.into_iter()
.map(|idx| trace.spans[idx].event.span_id.as_str())
.collect()
}
fn build_saturation_finding(trace: &Trace, service: &str, indices: &[usize], peak: u32) -> Finding {
let total_sql = indices.len();
let first = &trace.spans[indices[0]];
let (window_ms, first_ts, last_ts) = super::n_plus_one::compute_window_and_bounds_iter(
indices
.iter()
.map(|&i| trace.spans[i].event.timestamp.as_str()),
);
Finding {
finding_type: FindingType::PoolSaturation,
severity: Severity::Warning,
trace_id: trace.trace_id.clone(),
service: service.to_string(),
grouping: first.event.grouping.clone(),
source_endpoint: first.event.source.endpoint.clone(),
pattern: Pattern {
template: service.to_string(),
occurrences: peak as usize, window_ms,
distinct_params: total_sql,
..Default::default()
},
suggestion: format!(
"Potential connection pool saturation: service {service} has {peak} concurrent \
SQL spans within {window_ms}ms window. Consider increasing the connection \
pool size, optimizing long-running queries or using connection pool metrics \
(db.client.connection.pool.*) for precise monitoring"
),
first_timestamp: first_ts.to_string(),
last_timestamp: last_ts.to_string(),
green_impact: None,
confidence: Confidence::default(),
classification_method: None,
signature: String::new(),
code_location: None,
instrumentation_scopes: Vec::new(),
suggested_fix: None,
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::test_helpers::{
make_http_event_with_duration, make_sql_event_with_duration, make_trace,
};
fn make_concurrent_sql(
trace_id: &str,
service: &str,
count: usize,
duration_us: u64,
) -> Vec<crate::event::SpanEvent> {
(0..count)
.map(|i| {
let mut ev = make_sql_event_with_duration(
trace_id,
&format!("span-{i}"),
&format!("SELECT * FROM t{i} WHERE id = {i}"),
"2025-07-10T14:32:01.000Z",
duration_us,
);
ev.service = Arc::from(service);
ev
})
.collect()
}
#[test]
fn detects_concurrent_sql_spans() {
let events = make_concurrent_sql("trace-1", "order-svc", 12, 200_000);
let trace = make_trace(events);
let findings = detect_pool_saturation(&trace, 10);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].finding_type, FindingType::PoolSaturation);
assert_eq!(findings[0].severity, Severity::Warning);
assert_eq!(findings[0].pattern.occurrences, 12); assert_eq!(findings[0].pattern.distinct_params, 12); }
#[test]
fn no_finding_below_threshold() {
let events = make_concurrent_sql("trace-1", "order-svc", 5, 200_000);
let trace = make_trace(events);
let findings = detect_pool_saturation(&trace, 10);
assert!(findings.is_empty());
}
#[test]
fn sequential_spans_peak_one() {
let events: Vec<_> = (0..10)
.map(|i| {
make_sql_event_with_duration(
"trace-1",
&format!("span-{i}"),
&format!("SELECT * FROM t WHERE id = {i}"),
&format!("2025-07-10T14:32:01.{:03}Z", i * 100),
100_000, )
})
.collect();
let trace = make_trace(events);
let findings = detect_pool_saturation(&trace, 2);
assert!(findings.is_empty(), "sequential spans should have peak=1");
}
#[test]
fn partial_overlap() {
let events = vec![
make_sql_event_with_duration(
"trace-1",
"s0",
"SELECT 1",
"2025-07-10T14:32:01.000Z",
100_000,
),
make_sql_event_with_duration(
"trace-1",
"s1",
"SELECT 2",
"2025-07-10T14:32:01.050Z",
100_000,
),
make_sql_event_with_duration(
"trace-1",
"s2",
"SELECT 3",
"2025-07-10T14:32:01.120Z",
100_000,
),
make_sql_event_with_duration(
"trace-1",
"s3",
"SELECT 4",
"2025-07-10T14:32:01.200Z",
100_000,
),
];
let trace = make_trace(events);
let findings = detect_pool_saturation(&trace, 2);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].pattern.occurrences, 2);
let findings = detect_pool_saturation(&trace, 3);
assert!(findings.is_empty());
}
#[test]
fn evidence_names_only_the_first_peak_concurrency_set() {
let events = vec![
make_sql_event_with_duration(
"trace-1",
"s0",
"SELECT 1",
"2025-07-10T14:32:01.000Z",
100_000,
),
make_sql_event_with_duration(
"trace-1",
"s1",
"SELECT 2",
"2025-07-10T14:32:01.050Z",
100_000,
),
make_sql_event_with_duration(
"trace-1",
"s2",
"SELECT 3",
"2025-07-10T14:32:01.120Z",
100_000,
),
];
let trace = make_trace(events);
let found = detect_pool_saturation_with_spans(&trace, 2);
assert_eq!(found.len(), 1);
assert_eq!(found[0].1, vec!["s0", "s1"]);
}
#[test]
fn different_services_counted_separately() {
let mut events = make_concurrent_sql("trace-1", "svc-a", 12, 200_000);
let mut svc_b = make_concurrent_sql("trace-1", "svc-b", 12, 200_000);
for (i, ev) in svc_b.iter_mut().enumerate() {
ev.span_id = format!("span-b-{i}");
}
events.extend(svc_b);
let trace = make_trace(events);
let findings = detect_pool_saturation(&trace, 10);
assert_eq!(findings.len(), 2);
}
#[test]
fn equal_grouping_values_from_different_keys_do_not_share_one_pool() {
let mut events = make_concurrent_sql("trace-1", "svc", 6, 200_000);
for (i, event) in events.iter_mut().enumerate() {
let key = if i < 3 {
"tenant.id"
} else {
"k8s.namespace.name"
};
event.grouping = crate::test_helpers::grouping(key, "prod");
}
let trace = make_trace(events);
let findings = detect_pool_saturation(&trace, 4);
assert!(
findings.is_empty(),
"each grouping peaks at three connections: {findings:#?}"
);
}
#[test]
fn both_sweeps_agree_on_the_peak() {
let starts = [
("2025-07-10T14:32:01.000Z", 5_000_u64),
("2025-07-10T14:32:01.000Z", 1_000),
("2025-07-10T14:32:01.001Z", 3_000),
("2025-07-10T14:32:01.002Z", 500),
("2025-07-10T14:32:01.006Z", 2_000),
("2025-07-10T14:32:01.001Z", 0),
("2025-07-10T14:32:01.003Z", 0),
];
let events: Vec<_> = starts
.iter()
.enumerate()
.map(|(i, (ts, dur))| {
make_sql_event_with_duration("t1", &format!("s{i}"), "SELECT 1", ts, *dur)
})
.collect();
let trace = make_trace(events);
let indices: Vec<usize> = (0..trace.spans.len()).collect();
assert_eq!(
peak_span_ids(&trace, &indices).len(),
compute_peak_concurrency(&trace, &indices) as usize
);
}
#[test]
fn http_events_ignored() {
let events: Vec<_> = (0..15)
.map(|i| {
make_http_event_with_duration(
"trace-1",
&format!("span-{i}"),
&format!("http://svc/api/{i}"),
"2025-07-10T14:32:01.000Z",
200_000,
)
})
.collect();
let trace = make_trace(events);
let findings = detect_pool_saturation(&trace, 10);
assert!(findings.is_empty());
}
}