fix: N+1 query pattern in event trigger loop (routine_engine) (#1163)

* fix: N+1 query pattern in event trigger loop (routine_engine)

* fix: linter
This commit is contained in:
Nick Pismenkov
2026-03-14 13:06:59 -07:00
committed by GitHub
parent ffe384b66e
commit 994a0b194f
7 changed files with 1211 additions and 5 deletions
+61 -3
View File
@@ -139,6 +139,32 @@ impl RoutineEngine {
let cache = self.event_cache.read().await;
let mut fired = 0;
// Collect routine IDs for batch query
let routine_ids: Vec<Uuid> = cache
.iter()
.filter_map(|matcher| match matcher {
EventMatcher::Message { routine, .. } => Some(routine.id),
EventMatcher::System { .. } => None,
})
.collect();
if routine_ids.is_empty() {
return 0;
}
// Single batch query instead of N queries
let concurrent_counts = match self
.store
.count_running_routine_runs_batch(&routine_ids)
.await
{
Ok(counts) => counts,
Err(e) => {
tracing::error!("Failed to batch-load concurrent counts: {}", e);
return 0;
}
};
for matcher in cache.iter() {
let (routine, re) = match matcher {
EventMatcher::Message { routine, regex } => (routine, regex),
@@ -164,8 +190,9 @@ impl RoutineEngine {
continue;
}
// Concurrent run check
if !self.check_concurrent(routine).await {
// Concurrent run check (using batch-loaded counts)
let running_count = concurrent_counts.get(&routine.id).copied().unwrap_or(0);
if running_count >= routine.guardrails.max_concurrent as i64 {
tracing::trace!(routine = %routine.name, "Skipped: max concurrent reached");
continue;
}
@@ -197,6 +224,35 @@ impl RoutineEngine {
let cache = self.event_cache.read().await;
let mut fired = 0;
// Collect routine IDs for batch query
let routine_ids: Vec<Uuid> = cache
.iter()
.filter_map(|matcher| match matcher {
EventMatcher::System { routine } => Some(routine.id),
EventMatcher::Message { .. } => None,
})
.collect();
if routine_ids.is_empty() {
return 0;
}
// Single batch query instead of N queries
let concurrent_counts = match self
.store
.count_running_routine_runs_batch(&routine_ids)
.await
{
Ok(counts) => counts,
Err(e) => {
tracing::error!(
"Failed to batch-load concurrent counts for system events: {}",
e
);
return 0;
}
};
for matcher in cache.iter() {
let routine = match matcher {
EventMatcher::System { routine } => routine,
@@ -248,7 +304,9 @@ impl RoutineEngine {
continue;
}
if !self.check_concurrent(routine).await {
// Concurrent run check (using batch-loaded counts)
let running_count = concurrent_counts.get(&routine.id).copied().unwrap_or(0);
if running_count >= routine.guardrails.max_concurrent as i64 {
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
continue;
}
+55 -2
View File
@@ -1,13 +1,15 @@
//! Routine-related RoutineStore implementation for LibSqlBackend.
use std::collections::{HashMap, HashSet};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use libsql::params;
use uuid::Uuid;
use super::{
LibSqlBackend, ROUTINE_COLUMNS, ROUTINE_RUN_COLUMNS, fmt_opt_ts, fmt_ts, get_i64, opt_text,
opt_text_owned, row_to_routine_libsql, row_to_routine_run_libsql,
LibSqlBackend, ROUTINE_COLUMNS, ROUTINE_RUN_COLUMNS, fmt_opt_ts, fmt_ts, get_i64, get_text,
opt_text, opt_text_owned, row_to_routine_libsql, row_to_routine_run_libsql,
};
use crate::agent::routine::{Routine, RoutineRun, RunStatus};
use crate::db::RoutineStore;
@@ -409,6 +411,57 @@ impl RoutineStore for LibSqlBackend {
}
}
async fn count_running_routine_runs_batch(
&self,
routine_ids: &[Uuid],
) -> Result<HashMap<Uuid, i64>, DatabaseError> {
if routine_ids.is_empty() {
return Ok(HashMap::new());
}
let mut counts = HashMap::new();
let conn = self.connect().await?;
// Query all running routines and filter in memory
// This is simpler for libSQL than building dynamic parameter lists
let mut rows = conn
.query(
"SELECT routine_id, COUNT(*) as cnt FROM routine_runs
WHERE status = 'running'
GROUP BY routine_id",
params![],
)
.await
.map_err(|e| {
DatabaseError::Query(format!("Failed to batch count running routines: {}", e))
})?;
let routine_id_set: HashSet<Uuid> = routine_ids.iter().copied().collect();
while let Some(row) = rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
let id_str: String = get_text(&row, 0);
let id = Uuid::parse_str(&id_str)
.map_err(|e| DatabaseError::Query(format!("Invalid routine UUID: {}", e)))?;
// Only include if this routine ID was requested
if routine_id_set.contains(&id) {
let cnt: i64 = get_i64(&row, 1);
counts.insert(id, cnt);
}
}
// Ensure all requested IDs are in the map (defaults to 0 for no running runs)
for id in routine_ids {
counts.entry(*id).or_insert(0);
}
Ok(counts)
}
async fn link_routine_run_to_job(
&self,
run_id: Uuid,
+4
View File
@@ -387,6 +387,10 @@ pub trait RoutineStore: Send + Sync {
limit: i64,
) -> Result<Vec<RoutineRun>, DatabaseError>;
async fn count_running_routine_runs(&self, routine_id: Uuid) -> Result<i64, DatabaseError>;
async fn count_running_routine_runs_batch(
&self,
routine_ids: &[Uuid],
) -> Result<HashMap<Uuid, i64>, DatabaseError>;
async fn link_routine_run_to_job(
&self,
run_id: Uuid,
+9
View File
@@ -487,6 +487,15 @@ impl RoutineStore for PgBackend {
self.store.count_running_routine_runs(routine_id).await
}
async fn count_running_routine_runs_batch(
&self,
routine_ids: &[Uuid],
) -> Result<std::collections::HashMap<Uuid, i64>, DatabaseError> {
self.store
.count_running_routine_runs_batch(routine_ids)
.await
}
async fn link_routine_run_to_job(
&self,
run_id: Uuid,
+39
View File
@@ -1,5 +1,8 @@
//! PostgreSQL store for persisting agent data.
#[cfg(feature = "postgres")]
use std::collections::HashMap;
use chrono::{DateTime, Utc};
#[cfg(feature = "postgres")]
use deadpool_postgres::{Config, Pool};
@@ -1294,6 +1297,42 @@ impl Store {
Ok(row.get("cnt"))
}
/// Batch-load concurrent run counts for multiple routines in a single query.
/// Returns a map where missing routine IDs default to 0.
#[cfg(feature = "postgres")]
pub async fn count_running_routine_runs_batch(
&self,
routine_ids: &[Uuid],
) -> Result<HashMap<Uuid, i64>, DatabaseError> {
if routine_ids.is_empty() {
return Ok(HashMap::new());
}
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT routine_id, COUNT(*) as cnt FROM routine_runs
WHERE routine_id = ANY($1) AND status = 'running'
GROUP BY routine_id",
&[&routine_ids],
)
.await?;
let mut counts = HashMap::new();
for row in rows {
let id: Uuid = row.get("routine_id");
let cnt: i64 = row.get("cnt");
counts.insert(id, cnt);
}
// Ensure all requested IDs are in the map (defaults to 0 for no running runs)
for id in routine_ids {
counts.entry(*id).or_insert(0);
}
Ok(counts)
}
/// Link a routine run to a dispatched job.
pub async fn link_routine_run_to_job(
&self,