use std::time::Duration;
use systemprompt_extension::{Migration, cost};
use crate::services::DatabaseProvider;
pub(crate) const DEFAULT_STATEMENT_TIMEOUT: Duration = Duration::from_secs(300);
pub(crate) const MEASURED_SAFETY_FACTOR: u32 = 10;
pub(crate) const MIN_DERIVED_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const MAX_DERIVED_TIMEOUT: Duration = Duration::from_mins(30);
pub(crate) const LOCK_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const TIMEOUT_ENV: &str = "SYSTEMPROMPT_MIGRATION_STATEMENT_TIMEOUT_SECS";
#[must_use]
pub(crate) fn statement_timeout(migration: &Migration, live_rows: u64) -> Option<Duration> {
match env_timeout() {
EnvTimeout::Unset => {},
EnvTimeout::Disabled => return None,
EnvTimeout::Bounded(timeout) => return Some(timeout),
}
if let Ok(Some(declared)) = cost::parse(migration.sql) {
return Some(
declared
.projected(live_rows)
.saturating_mul(MEASURED_SAFETY_FACTOR)
.clamp(MIN_DERIVED_TIMEOUT, MAX_DERIVED_TIMEOUT),
);
}
Some(DEFAULT_STATEMENT_TIMEOUT)
}
pub(crate) async fn live_rows(db: &dyn DatabaseProvider, migration: &Migration) -> u64 {
if !matches!(cost::parse(migration.sql), Ok(Some(_))) {
return 0;
}
let rows = db
.fetch_all(
&"SELECT c.relname::text AS name, c.reltuples::bigint AS estimate FROM pg_class c \
JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE c.relkind IN ('r', 'p') AND n.nspname = current_schema() AND c.reltuples > 0",
&[],
)
.await
.inspect_err(|error| {
tracing::warn!(%error, "Table size estimates unavailable; migration bound not projected");
})
.unwrap_or_default();
rows.iter()
.filter_map(|row| {
let name = row.get("name")?.as_str()?;
let estimate = row.get("estimate")?.as_i64()?;
if !names_table(migration.sql, name) {
return None;
}
u64::try_from(estimate).ok()
})
.max()
.unwrap_or(0)
}
fn names_table(sql: &str, table: &str) -> bool {
let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
sql.match_indices(table).any(|(at, _)| {
let before = sql[..at].chars().next_back();
let after = sql[at + table.len()..].chars().next();
!before.is_some_and(is_ident) && !after.is_some_and(is_ident)
})
}
enum EnvTimeout {
Unset,
Disabled,
Bounded(Duration),
}
fn env_timeout() -> EnvTimeout {
let Ok(raw) = std::env::var(TIMEOUT_ENV) else {
return EnvTimeout::Unset;
};
match raw.trim().parse::<u64>() {
Ok(0) => EnvTimeout::Disabled,
Ok(secs) => EnvTimeout::Bounded(Duration::from_secs(secs)),
Err(_) => EnvTimeout::Bounded(DEFAULT_STATEMENT_TIMEOUT),
}
}
#[must_use]
pub(crate) fn timeout_statements(timeout: Option<Duration>, local: bool) -> Vec<String> {
let scope = if local { "LOCAL " } else { "" };
let statement = timeout.map_or_else(|| "0".to_owned(), |d| format!("{}", d.as_millis()));
vec![
format!("SET {scope}statement_timeout = {statement}"),
format!("SET {scope}lock_timeout = {}", LOCK_TIMEOUT.as_millis()),
]
}