use std::sync::Arc;
use std::time::Duration;
use sift_query_core::{QueryPlan, SearchEntity, SearchOptions};
use sqlx::pool::PoolConnection;
use sqlx::postgres::{PgArguments, PgConnection, PgRow};
use sqlx::{AssertSqlSafe, Connection, Decode, FromRow, Postgres, Row, Type};
use tokio::time::{Instant, timeout_at};
use crate::postgres::bind::build_arguments;
use crate::postgres::decode::{decode_map, decode_summaries};
use crate::{PgSearchError, RenderedPgQuery, RowMap, SummaryValue, TimeoutStage};
pub(crate) struct PreparedPgQuery {
pub(crate) sql: Arc<str>,
pub(crate) arguments: PgArguments,
pub(crate) shape_id: crate::QueryShapeId,
}
impl PreparedPgQuery {
pub(crate) fn sql(&self) -> AssertSqlSafe<Arc<str>> {
AssertSqlSafe(Arc::clone(&self.sql))
}
}
pub(crate) fn prepare(rendered: RenderedPgQuery) -> Result<PreparedPgQuery, PgSearchError> {
let arguments = build_arguments(&rendered.binds)?;
Ok(PreparedPgQuery {
sql: rendered.sql,
arguments,
shape_id: rendered.shape_id,
})
}
pub(crate) async fn fetch_typed<E>(
connection: PoolConnection<Postgres>,
prepared: PreparedPgQuery,
deadline: Instant,
stage: TimeoutStage,
) -> Result<(PoolConnection<Postgres>, Vec<E>), PgSearchError>
where
E: Send + Unpin + for<'row> FromRow<'row, PgRow>,
{
let mut connection = configure_statement_timeout(connection, deadline, stage).await?;
tracing::debug!(
query_shape_id = prepared.shape_id.0,
"executing SiftQuery list"
);
let started = Instant::now();
let result = timeout_at(
deadline,
sqlx::query_as_with::<Postgres, E, _>(prepared.sql(), prepared.arguments)
.fetch_all(&mut *connection),
)
.await;
let Ok(result) = result else {
close_hard(connection).await;
return Err(PgSearchError::Timeout(stage));
};
let (connection, rows) = finish_statement(connection, result, stage).await?;
tracing::debug!(
list_duration_us = elapsed_micros(started),
rows_returned = rows.len(),
"completed SiftQuery list"
);
Ok((connection, rows))
}
pub(crate) async fn fetch_maps<E: SearchEntity>(
connection: PoolConnection<Postgres>,
prepared: PreparedPgQuery,
plan: &QueryPlan<E>,
deadline: Instant,
) -> Result<(PoolConnection<Postgres>, Vec<RowMap>), PgSearchError> {
let mut connection =
configure_statement_timeout(connection, deadline, TimeoutStage::ListQuery).await?;
tracing::debug!(
query_shape_id = prepared.shape_id.0,
"executing SiftQuery map list"
);
let started = Instant::now();
let result = timeout_at(
deadline,
sqlx::query_with(prepared.sql(), prepared.arguments).fetch_all(&mut *connection),
)
.await;
let Ok(result) = result else {
close_hard(connection).await;
return Err(PgSearchError::Timeout(TimeoutStage::ListQuery));
};
let (connection, rows) = finish_statement(connection, result, TimeoutStage::ListQuery).await?;
let list_duration = elapsed_micros(started);
let decode_started = Instant::now();
let decoded = rows
.iter()
.map(|row| decode_map(row, plan))
.collect::<Result<Vec<_>, _>>()
.map_err(PgSearchError::Decode)?;
tracing::debug!(
list_duration_us = list_duration,
decode_duration_us = elapsed_micros(decode_started),
rows_returned = decoded.len(),
"completed SiftQuery map list"
);
Ok((connection, decoded))
}
pub(crate) async fn fetch_cluster<E: SearchEntity>(
connection: PoolConnection<Postgres>,
prepared: PreparedPgQuery,
options: Option<&SearchOptions<E>>,
meta: &'static sift_query_core::EntityMeta,
deadline: Instant,
) -> Result<(PoolConnection<Postgres>, (i64, Vec<SummaryValue>)), PgSearchError> {
let mut connection =
configure_statement_timeout(connection, deadline, TimeoutStage::ClusterQuery).await?;
tracing::debug!(
query_shape_id = prepared.shape_id.0,
"executing SiftQuery cluster"
);
let started = Instant::now();
let result = timeout_at(
deadline,
sqlx::query_with(prepared.sql(), prepared.arguments).fetch_one(&mut *connection),
)
.await;
let Ok(result) = result else {
close_hard(connection).await;
return Err(PgSearchError::Timeout(TimeoutStage::ClusterQuery));
};
let (connection, row) =
finish_statement(connection, result, TimeoutStage::ClusterQuery).await?;
let cluster_duration = elapsed_micros(started);
let decode_started = Instant::now();
let total_count = row.try_get::<i64, _>("sq_count")?;
let summaries = decode_summaries(&row, options, meta)?;
tracing::debug!(
cluster_duration_us = cluster_duration,
decode_duration_us = elapsed_micros(decode_started),
"completed SiftQuery cluster"
);
Ok((connection, (total_count, summaries)))
}
pub(crate) async fn fetch_sum<T>(
connection: PoolConnection<Postgres>,
prepared: PreparedPgQuery,
deadline: Instant,
) -> Result<(PoolConnection<Postgres>, Option<T>), PgSearchError>
where
T: Send + Unpin + for<'row> Decode<'row, Postgres> + Type<Postgres>,
{
let mut connection =
configure_statement_timeout(connection, deadline, TimeoutStage::ClusterQuery).await?;
let started = Instant::now();
let result = timeout_at(
deadline,
sqlx::query_scalar_with::<Postgres, Option<T>, _>(prepared.sql(), prepared.arguments)
.fetch_one(&mut *connection),
)
.await;
let Ok(result) = result else {
close_hard(connection).await;
return Err(PgSearchError::Timeout(TimeoutStage::ClusterQuery));
};
let result = finish_statement(connection, result, TimeoutStage::ClusterQuery).await;
tracing::debug!(
cluster_duration_us = elapsed_micros(started),
"completed SiftQuery sum"
);
result
}
pub(crate) async fn fetch_typed_external<E>(
connection: &mut PgConnection,
prepared: PreparedPgQuery,
deadline: Instant,
stage: TimeoutStage,
) -> Result<Vec<E>, PgSearchError>
where
E: Send + Unpin + for<'row> FromRow<'row, PgRow>,
{
let result = timeout_at(
deadline,
sqlx::query_as_with::<Postgres, E, _>(prepared.sql(), prepared.arguments)
.fetch_all(&mut *connection),
)
.await;
match result {
Ok(Ok(rows)) => Ok(rows),
Ok(Err(error)) => Err(external_sql_error(error, stage)),
Err(_) => Err(PgSearchError::Timeout(stage)),
}
}
pub(crate) async fn fetch_maps_external<E: SearchEntity>(
connection: &mut PgConnection,
prepared: PreparedPgQuery,
plan: &QueryPlan<E>,
deadline: Instant,
) -> Result<Vec<RowMap>, PgSearchError> {
let result = timeout_at(
deadline,
sqlx::query_with(prepared.sql(), prepared.arguments).fetch_all(&mut *connection),
)
.await;
let rows = match result {
Ok(Ok(rows)) => rows,
Ok(Err(error)) => return Err(external_sql_error(error, TimeoutStage::ListQuery)),
Err(_) => return Err(PgSearchError::Timeout(TimeoutStage::ListQuery)),
};
rows.iter()
.map(|row| decode_map(row, plan))
.collect::<Result<Vec<_>, _>>()
.map_err(PgSearchError::Decode)
}
pub(crate) async fn fetch_cluster_external<E: SearchEntity>(
connection: &mut PgConnection,
prepared: PreparedPgQuery,
options: Option<&SearchOptions<E>>,
meta: &'static sift_query_core::EntityMeta,
deadline: Instant,
) -> Result<(i64, Vec<SummaryValue>), PgSearchError> {
let result = timeout_at(
deadline,
sqlx::query_with(prepared.sql(), prepared.arguments).fetch_one(&mut *connection),
)
.await;
let row = match result {
Ok(Ok(row)) => row,
Ok(Err(error)) => return Err(external_sql_error(error, TimeoutStage::ClusterQuery)),
Err(_) => return Err(PgSearchError::Timeout(TimeoutStage::ClusterQuery)),
};
let total_count = row.try_get::<i64, _>("sq_count")?;
let summaries = decode_summaries(&row, options, meta)?;
Ok((total_count, summaries))
}
pub(crate) async fn configure_statement_timeout(
mut connection: PoolConnection<Postgres>,
deadline: Instant,
stage: TimeoutStage,
) -> Result<PoolConnection<Postgres>, PgSearchError> {
let remaining = deadline.saturating_duration_since(Instant::now());
let milliseconds = remaining.as_millis().saturating_sub(10).max(1);
let milliseconds = u64::try_from(milliseconds).unwrap_or(u64::MAX);
let result = timeout_at(
deadline,
sqlx::query_scalar::<_, String>("SELECT set_config('statement_timeout', $1, false)")
.bind(milliseconds.to_string())
.fetch_one(&mut *connection),
)
.await;
let Ok(result) = result else {
close_hard(connection).await;
return Err(PgSearchError::Timeout(stage));
};
result.map_err(PgSearchError::Sql)?;
Ok(connection)
}
pub(crate) async fn finish_statement<T>(
mut connection: PoolConnection<Postgres>,
result: Result<T, sqlx::Error>,
stage: TimeoutStage,
) -> Result<(PoolConnection<Postgres>, T), PgSearchError> {
let reset = tokio::time::timeout(
Duration::from_secs(1),
sqlx::query("SELECT set_config('statement_timeout', '0', false)").execute(&mut *connection),
)
.await;
let reset_error = match reset {
Ok(Ok(_)) => None,
Ok(Err(error)) => Some(PgSearchError::Sql(error)),
Err(_) => Some(PgSearchError::Timeout(stage)),
};
if let Some(error) = reset_error {
close_hard(connection).await;
return Err(error);
}
match result {
Ok(value) => Ok((connection, value)),
Err(error) if is_statement_timeout(&error) => Err(PgSearchError::Timeout(stage)),
Err(error) => Err(PgSearchError::Sql(error)),
}
}
fn is_statement_timeout(error: &sqlx::Error) -> bool {
error
.as_database_error()
.and_then(sqlx::error::DatabaseError::code)
.is_some_and(|code| code == "57014")
}
fn external_sql_error(error: sqlx::Error, stage: TimeoutStage) -> PgSearchError {
if is_statement_timeout(&error) {
PgSearchError::Timeout(stage)
} else {
PgSearchError::Sql(error)
}
}
pub(crate) async fn close_hard(connection: PoolConnection<Postgres>) {
if connection.detach().close_hard().await.is_err() {
tracing::warn!(
error_category = "connection_close",
"failed to close timed-out PostgreSQL connection"
);
}
}
fn elapsed_micros(started: Instant) -> u64 {
u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests;