Skip to content

Commit ee0fdd7

Browse files
Batch dashboard DB queries to eliminate N+1
build_watchlist_dashboard previously issued about five DB queries per ticker (list_runs_for_ticker, latest score for latest run, latest score for previous run, latest source timestamp for latest run, latest source timestamp for previous run). A 50-ticker watchlist fired ~250 round-trips to render once. Add three batched Database methods that collapse those into three queries total regardless of watchlist size: - recent_runs_for_tickers: window-function query returning up to N most-recent runs per ticker - latest_scores_for_runs: MAX(iteration_number)-per-run join - latest_source_timestamps_for_runs: grouped MAX timestamp Rewire build_watchlist_dashboard to pre-fetch everything up front and look it up from hash maps. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent 3d48663 commit ee0fdd7

2 files changed

Lines changed: 164 additions & 46 deletions

File tree

src/db.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1727,6 +1727,56 @@ impl Database {
17271727
}))
17281728
}
17291729

1730+
/// Batch variant of [`Database::get_latest_iteration_evaluation_score`].
1731+
/// Returns a map from run_id to score for the latest iteration with an
1732+
/// `evaluation_json` payload. Missing runs / runs with no evaluated
1733+
/// iteration are simply absent from the map. Used by the dashboard and
1734+
/// alerts path to avoid N+1 score lookups.
1735+
pub async fn latest_scores_for_runs(
1736+
&self,
1737+
run_ids: &[String],
1738+
) -> Result<std::collections::HashMap<String, f64>> {
1739+
use std::collections::HashMap;
1740+
if run_ids.is_empty() {
1741+
return Ok(HashMap::new());
1742+
}
1743+
let conn = self.open_connection()?;
1744+
let placeholders: String = (0..run_ids.len())
1745+
.map(|i| format!("?{}", i + 1))
1746+
.collect::<Vec<_>>()
1747+
.join(",");
1748+
// Pick the iteration with the highest iteration_number per run_id.
1749+
let sql = format!(
1750+
"SELECT i.run_id, i.evaluation_json
1751+
FROM iterations i
1752+
INNER JOIN (
1753+
SELECT run_id, MAX(iteration_number) AS max_n
1754+
FROM iterations
1755+
WHERE run_id IN ({placeholders}) AND evaluation_json IS NOT NULL
1756+
GROUP BY run_id
1757+
) latest ON latest.run_id = i.run_id AND latest.max_n = i.iteration_number"
1758+
);
1759+
let params: Vec<&dyn rusqlite::ToSql> =
1760+
run_ids.iter().map(|r| r as &dyn rusqlite::ToSql).collect();
1761+
let mut statement = conn.prepare(&sql)?;
1762+
let rows = statement.query_map(params.as_slice(), |row| {
1763+
Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
1764+
})?;
1765+
let mut map = HashMap::new();
1766+
for row in rows {
1767+
let (run_id, raw) = row?;
1768+
if let Some(raw) = raw {
1769+
if let Some(score) = serde_json::from_str::<serde_json::Value>(&raw)
1770+
.ok()
1771+
.and_then(|value| value.get("score").and_then(|s| s.as_f64()))
1772+
{
1773+
map.insert(run_id, score);
1774+
}
1775+
}
1776+
}
1777+
Ok(map)
1778+
}
1779+
17301780
pub async fn get_latest_source_timestamp_for_run(
17311781
&self,
17321782
run_id: &str,
@@ -1750,6 +1800,87 @@ impl Database {
17501800
}
17511801
}
17521802

1803+
/// Batch variant of [`Database::get_latest_source_timestamp_for_run`].
1804+
/// Returns a map of run_id to the most recent source timestamp. Missing
1805+
/// runs / runs without sources are absent from the map.
1806+
pub async fn latest_source_timestamps_for_runs(
1807+
&self,
1808+
run_ids: &[String],
1809+
) -> Result<std::collections::HashMap<String, DateTime<Utc>>> {
1810+
use std::collections::HashMap;
1811+
if run_ids.is_empty() {
1812+
return Ok(HashMap::new());
1813+
}
1814+
let conn = self.open_connection()?;
1815+
let placeholders: String = (0..run_ids.len())
1816+
.map(|i| format!("?{}", i + 1))
1817+
.collect::<Vec<_>>()
1818+
.join(",");
1819+
let sql = format!(
1820+
"SELECT run_id, MAX(COALESCE(published_at, created_at)) AS latest
1821+
FROM sources
1822+
WHERE run_id IN ({placeholders})
1823+
GROUP BY run_id"
1824+
);
1825+
let params: Vec<&dyn rusqlite::ToSql> =
1826+
run_ids.iter().map(|r| r as &dyn rusqlite::ToSql).collect();
1827+
let mut statement = conn.prepare(&sql)?;
1828+
let rows = statement.query_map(params.as_slice(), |row| {
1829+
Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
1830+
})?;
1831+
let mut map = HashMap::new();
1832+
for row in rows {
1833+
let (run_id, raw) = row?;
1834+
if let Some(raw) = raw {
1835+
if let Some(ts) = parse_time_opt(&raw) {
1836+
map.insert(run_id, ts);
1837+
}
1838+
}
1839+
}
1840+
Ok(map)
1841+
}
1842+
1843+
/// Fetch up to `limit` most-recent runs for each ticker in one query.
1844+
/// Used by the dashboard which needs the latest two runs per ticker.
1845+
pub async fn recent_runs_for_tickers(
1846+
&self,
1847+
tickers: &[String],
1848+
limit: i64,
1849+
) -> Result<std::collections::HashMap<String, Vec<Run>>> {
1850+
use std::collections::HashMap;
1851+
if tickers.is_empty() || limit <= 0 {
1852+
return Ok(HashMap::new());
1853+
}
1854+
let conn = self.open_connection()?;
1855+
let placeholders: String = (0..tickers.len())
1856+
.map(|i| format!("?{}", i + 1))
1857+
.collect::<Vec<_>>()
1858+
.join(",");
1859+
let limit_placeholder = tickers.len() + 1;
1860+
let sql = format!(
1861+
"SELECT id, ticker, question, status, created_at, updated_at,
1862+
final_iteration_number, final_memo_markdown, final_memo_html, summary
1863+
FROM (
1864+
SELECT r.*,
1865+
ROW_NUMBER() OVER (PARTITION BY ticker ORDER BY created_at DESC) AS rn
1866+
FROM runs r
1867+
WHERE ticker IN ({placeholders})
1868+
) WHERE rn <= ?{limit_placeholder}
1869+
ORDER BY ticker, created_at DESC"
1870+
);
1871+
let mut params: Vec<&dyn rusqlite::ToSql> =
1872+
tickers.iter().map(|t| t as &dyn rusqlite::ToSql).collect();
1873+
params.push(&limit as &dyn rusqlite::ToSql);
1874+
let mut statement = conn.prepare(&sql)?;
1875+
let rows = statement.query_map(params.as_slice(), map_run)?;
1876+
let mut map: HashMap<String, Vec<Run>> = HashMap::new();
1877+
for row in rows {
1878+
let run = row?;
1879+
map.entry(run.ticker.clone()).or_default().push(run);
1880+
}
1881+
Ok(map)
1882+
}
1883+
17531884
// Scanner database methods
17541885

17551886
pub async fn upsert_ticker_universe(

src/services/dashboard.rs

Lines changed: 33 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -19,69 +19,56 @@ pub async fn build_watchlist_dashboard(
1919
let watchlist_tickers = state.db.list_watchlist_tickers(watchlist_id).await?;
2020
let alert_rules = state.db.list_or_create_alert_rules(watchlist_id).await?;
2121

22+
// --- Single batched pre-fetch replaces the per-ticker N+1. ---
23+
let ticker_ids: Vec<String> = watchlist_tickers
24+
.iter()
25+
.map(|wt| wt.ticker.clone())
26+
.collect();
27+
// Up to the two most-recent runs per ticker (latest + previous).
28+
let runs_by_ticker = state.db.recent_runs_for_tickers(&ticker_ids, 2).await?;
29+
// Collect run_ids so we can batch-fetch their scores / source freshness.
30+
let run_ids: Vec<String> = runs_by_ticker
31+
.values()
32+
.flat_map(|runs| runs.iter().map(|r| r.id.clone()))
33+
.collect();
34+
let scores_by_run = state.db.latest_scores_for_runs(&run_ids).await?;
35+
let source_ts_by_run = state.db.latest_source_timestamps_for_runs(&run_ids).await?;
36+
2237
let mut rows = Vec::new();
2338
for watchlist_ticker in watchlist_tickers {
24-
let recent_runs = state
25-
.db
26-
.list_runs_for_ticker(&watchlist_ticker.ticker, 2)
27-
.await?;
28-
let latest_run = recent_runs.first().cloned();
29-
let previous_run = recent_runs.get(1).cloned();
39+
let recent_runs: &[crate::models::Run] = runs_by_ticker
40+
.get(&watchlist_ticker.ticker)
41+
.map(Vec::as_slice)
42+
.unwrap_or(&[]);
43+
let latest_run = recent_runs.first();
44+
let previous_run = recent_runs.get(1);
3045

31-
let latest_score = if let Some(run) = latest_run.as_ref() {
32-
state
33-
.db
34-
.get_latest_iteration_evaluation_score(&run.id)
35-
.await?
36-
} else {
37-
None
38-
};
39-
let previous_score = if let Some(run) = previous_run.as_ref() {
40-
state
41-
.db
42-
.get_latest_iteration_evaluation_score(&run.id)
43-
.await?
44-
} else {
45-
None
46-
};
46+
let latest_score = latest_run.and_then(|run| scores_by_run.get(&run.id).copied());
47+
let previous_score = previous_run.and_then(|run| scores_by_run.get(&run.id).copied());
4748
let score_delta = match (latest_score, previous_score) {
4849
(Some(latest), Some(previous)) => Some(latest - previous),
4950
_ => None,
5051
};
5152
let trend = classify_trend(score_delta).to_string();
5253

53-
let latest_source_timestamp = if let Some(run) = latest_run.as_ref() {
54-
state
55-
.db
56-
.get_latest_source_timestamp_for_run(&run.id)
57-
.await?
58-
} else {
59-
None
60-
};
54+
let latest_source_timestamp =
55+
latest_run.and_then(|run| source_ts_by_run.get(&run.id).copied());
6156
let evidence_freshness = classify_freshness(latest_source_timestamp);
62-
let previous_evidence_freshness = if let Some(run) = previous_run.as_ref() {
63-
classify_freshness(
64-
state
65-
.db
66-
.get_latest_source_timestamp_for_run(&run.id)
67-
.await?,
68-
)
69-
} else {
70-
"no_evidence".to_string()
71-
};
57+
let previous_evidence_freshness = previous_run
58+
.map(|run| classify_freshness(source_ts_by_run.get(&run.id).copied()))
59+
.unwrap_or_else(|| "no_evidence".to_string());
7260

7361
let latest_status = latest_run
74-
.as_ref()
7562
.map(|run| run.status.clone())
7663
.unwrap_or_else(|| "no_data".to_string());
7764
let decision_state = classify_decision(&latest_status, latest_score, &evidence_freshness);
78-
let previous_decision_state = previous_run.as_ref().map(|run| {
65+
let previous_decision_state = previous_run.map(|run| {
7966
classify_decision(&run.status, previous_score, &previous_evidence_freshness)
8067
});
8168

8269
let snapshot = TickerAlertSnapshot {
8370
ticker: watchlist_ticker.ticker.clone(),
84-
latest_run_id: latest_run.as_ref().map(|run| run.id.clone()),
71+
latest_run_id: latest_run.map(|run| run.id.clone()),
8572
latest_status: latest_status.clone(),
8673
latest_score,
8774
previous_score,
@@ -93,17 +80,17 @@ pub async fn build_watchlist_dashboard(
9380

9481
rows.push(DashboardTickerRow {
9582
ticker: watchlist_ticker.ticker,
96-
latest_run_id: latest_run.as_ref().map(|run| run.id.clone()),
83+
latest_run_id: latest_run.map(|run| run.id.clone()),
9784
latest_status,
9885
latest_score,
9986
previous_score,
10087
score_delta,
10188
trend,
102-
summary: latest_run.as_ref().and_then(|run| run.summary.clone()),
89+
summary: latest_run.and_then(|run| run.summary.clone()),
10390
evidence_freshness,
10491
decision_state,
10592
active_alert_count: 0,
106-
last_run_updated_at: latest_run.as_ref().map(|run| run.updated_at),
93+
last_run_updated_at: latest_run.map(|run| run.updated_at),
10794
});
10895
}
10996

0 commit comments

Comments
 (0)