fix(sqlserver): handle query timeout and multi-result-set pagination

* Fix SQL Server query timeout and result pagination

* Address SQL Server timeout review feedback

* Cover SQL Server lock wait timeout

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipgandzipg authored and GitHub committed 2026-07-10 10:36:11 +08:00
1 parent 3a9bdc1cf6
commit f73633f622
5 files changed
+216 -25

No files matched your search

+118 -14
View File
@@ -852,7 +852,12 @@ fn is_os_connection_error(lower: &str) -> bool {
}
pub fn timeout_error() -> String {
format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs())
timeout_error_for(QUERY_TIMEOUT)
}
fn timeout_error_for(timeout_duration: Duration) -> String {
let seconds = timeout_duration.as_secs().max(1);
format!("Query timed out after {seconds} seconds")
}
pub fn canceled_error() -> String {
@@ -882,15 +887,26 @@ pub async fn wait_for_query_with_timeout<F>(
) -> Result<db::QueryResult, String>
where
F: Future<Output = Result<db::QueryResult, String>>,
{
wait_for_result_with_timeout(cancel_token, timeout_duration, future).await
}
async fn wait_for_result_with_timeout<T, F>(
cancel_token: Option<CancellationToken>,
timeout_duration: Duration,
future: F,
) -> Result<T, String>
where
F: Future<Output = Result<T, String>>,
{
if let Some(token) = cancel_token {
tokio::select! {
biased;
_ = token.cancelled() => Err(canceled_error()),
result = timeout(timeout_duration, future) => result.map_err(|_| timeout_error())?,
result = timeout(timeout_duration, future) => result.map_err(|_| timeout_error_for(timeout_duration))?,
}
} else {
timeout(timeout_duration, future).await.map_err(|_| timeout_error())?
timeout(timeout_duration, future).await.map_err(|_| timeout_error_for(timeout_duration))?
}
}
@@ -903,9 +919,20 @@ pub async fn wait_for_query_opt<F>(
) -> Result<db::QueryResult, String>
where
F: Future<Output = Result<db::QueryResult, String>>,
{
wait_for_result_opt(cancel_token, timeout_duration, future).await
}
async fn wait_for_result_opt<T, F>(
cancel_token: Option<CancellationToken>,
timeout_duration: Option<Duration>,
future: F,
) -> Result<T, String>
where
F: Future<Output = Result<T, String>>,
{
match timeout_duration {
Some(d) => wait_for_query_with_timeout(cancel_token, d, future).await,
Some(d) => wait_for_result_with_timeout(cancel_token, d, future).await,
None => match cancel_token {
Some(token) => {
tokio::select! {
@@ -919,6 +946,48 @@ where
}
}
async fn wait_for_value_opt<T, F>(
cancel_token: Option<CancellationToken>,
timeout_duration: Option<Duration>,
future: F,
) -> Result<T, String>
where
F: Future<Output = T>,
{
match timeout_duration {
Some(timeout_duration) => {
if let Some(token) = cancel_token {
tokio::select! {
biased;
_ = token.cancelled() => Err(canceled_error()),
result = timeout(timeout_duration, future) => result.map_err(|_| timeout_error_for(timeout_duration)),
}
} else {
timeout(timeout_duration, future).await.map_err(|_| timeout_error_for(timeout_duration))
}
}
None => match cancel_token {
Some(token) => {
tokio::select! {
biased;
_ = token.cancelled() => Err(canceled_error()),
result = future => Ok(result),
}
}
None => Ok(future.await),
},
}
}
async fn sqlserver_pool_is_current(
state: &AppState,
pool_key: &str,
client: &Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>,
) -> bool {
let connections = state.connections.read().await;
matches!(connections.get(pool_key), Some(PoolKind::SqlServer(current)) if Arc::ptr_eq(current, client))
}
fn resolve_query_timeout(timeout_secs: Option<u64>) -> Option<Duration> {
match timeout_secs {
Some(0) => None,
@@ -1867,6 +1936,7 @@ async fn execute_multi_sqlserver(
check_read_only_for_connection_multi(state, pool_key, &batches).await?;
let mut all_results = Vec::new();
let max_rows = options.max_rows;
let query_timeout = resolve_query_timeout(options.timeout_secs);
for batch in &batches {
if is_canceled(&cancel_token) {
@@ -1892,17 +1962,28 @@ async fn execute_multi_sqlserver(
};
drop(connections);
let mut client = match cancel_token.as_ref() {
Some(token) => tokio::select! {
biased;
_ = token.cancelled() => return Err(canceled_error()),
guard = client.lock() => guard,
},
None => client.lock().await,
let mut client_guard = match wait_for_value_opt(cancel_token.clone(), query_timeout, client.lock()).await {
Ok(guard) => guard,
Err(err) => {
all_results.push(error_query_result(err));
break;
}
};
let result = db::sqlserver::execute_batch_with_max_rows(&mut client, batch, max_rows).await;
drop(client);
if !sqlserver_pool_is_current(state, pool_key, &client).await {
all_results.push(error_query_result(
"SQL Server connection was reset while waiting for the query lock; please retry.".to_string(),
));
break;
}
let result = wait_for_result_opt(
cancel_token.clone(),
query_timeout,
db::sqlserver::execute_batch_with_max_rows(&mut client_guard, batch, max_rows),
)
.await;
drop(client_guard);
match result {
Ok(results) => all_results.extend(results),
@@ -2887,6 +2968,7 @@ pub async fn rollback_manual_transaction(state: &AppState, txn_session_id: &str)
mod tests {
use super::*;
use crate::models::connection::{default_redis_key_separator, ConnectionConfig, DatabaseType};
#[cfg(feature = "duckdb-bundled")]
use crate::storage::Storage;
fn test_connection_config(db_type: DatabaseType) -> ConnectionConfig {
@@ -3025,7 +3107,29 @@ mod tests {
})
.await;
assert_eq!(result.unwrap_err(), timeout_error());
assert_eq!(result.unwrap_err(), timeout_error_for(Duration::from_millis(10)));
}
#[tokio::test]
async fn wait_for_value_opt_times_out_while_waiting_for_lock() {
let lock = tokio::sync::Mutex::new(());
let _guard = lock.lock().await;
let result = wait_for_value_opt(None, Some(Duration::from_millis(10)), lock.lock()).await;
assert_eq!(result.unwrap_err(), timeout_error_for(Duration::from_millis(10)));
}
#[tokio::test]
async fn wait_for_value_opt_can_cancel_while_waiting_for_lock() {
let lock = tokio::sync::Mutex::new(());
let _guard = lock.lock().await;
let token = CancellationToken::new();
token.cancel();
let result = wait_for_value_opt(Some(token), Some(Duration::from_secs(30)), lock.lock()).await;
assert_eq!(result.unwrap_err(), QUERY_CANCELED);
}
#[cfg(feature = "duckdb-bundled")]