fix(jdbc): support manual transactions and auto-commit control (#6997)
* fix(jdbc): support manual transactions * fix(jdbc): preserve paged query transaction state --------- Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
1 parent
c8dbeeaf7d
commit
c021fe009b
6 files changed
+662
-55
No files matched your search
@@ -190,6 +190,14 @@ pub enum TxnConnection {
|
||||
database: Option<String>,
|
||||
cleanup_guard: ClientSessionPoolCleanupGuard,
|
||||
},
|
||||
/// Dedicated external-driver process whose shared JDBC connection owns the TX.
|
||||
ExternalDriver {
|
||||
session: Arc<PluginDriverSession>,
|
||||
config: Arc<ConnectionConfig>,
|
||||
client_session_id: String,
|
||||
database: Option<String>,
|
||||
cleanup_guard: ClientSessionPoolCleanupGuard,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct TransactionSession {
|
||||
|
||||
@@ -4348,6 +4348,7 @@ async fn begin_transaction_session(
|
||||
Postgres(deadpool_postgres::Pool),
|
||||
Mysql(db::mysql::MySqlPool),
|
||||
Agent,
|
||||
ExternalDriver,
|
||||
}
|
||||
let pool_handle = {
|
||||
let connections = state.connections.read().await;
|
||||
@@ -4355,6 +4356,7 @@ async fn begin_transaction_session(
|
||||
PoolKind::Postgres(pg) => TxnPoolHandle::Postgres(pg.clone()),
|
||||
PoolKind::Mysql(mp, _) => TxnPoolHandle::Mysql(mp.clone()),
|
||||
PoolKind::Agent(_) if !consistent_snapshot => TxnPoolHandle::Agent,
|
||||
PoolKind::ExternalDriver { .. } if !consistent_snapshot => TxnPoolHandle::ExternalDriver,
|
||||
_ => return Err("Manual transaction is not supported for this database type".to_string()),
|
||||
}
|
||||
}; // connections lock released here
|
||||
@@ -4438,6 +4440,53 @@ async fn begin_transaction_session(
|
||||
agent_pool_key,
|
||||
)
|
||||
}
|
||||
TxnPoolHandle::ExternalDriver => {
|
||||
let client_session_id = format!("manual-txn-{}", uuid::Uuid::new_v4());
|
||||
let external_pool_key =
|
||||
state.get_or_create_pool_for_session(connection_id, pool_database, Some(&client_session_id)).await?;
|
||||
let (config, session) = {
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(&external_pool_key) {
|
||||
Some(PoolKind::ExternalDriver { config, session, .. }) => (config.clone(), session.clone()),
|
||||
_ => {
|
||||
let _ = state.close_client_session_pool(connection_id, pool_database, &client_session_id).await;
|
||||
return Err("External driver connection not found for manual transaction".to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
let begin_params = serde_json::json!({
|
||||
"connection": config.as_ref(),
|
||||
"database": database,
|
||||
"schema": schema,
|
||||
});
|
||||
if let Err(error) = session
|
||||
.invoke_with_timeout::<serde_json::Value>(
|
||||
"beginManualTransaction",
|
||||
begin_params,
|
||||
query_timeout_duration(Some(config.effective_query_timeout_secs())),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = state.close_client_session_pool(connection_id, pool_database, &client_session_id).await;
|
||||
return Err(format!("BEGIN manual transaction failed: {error}"));
|
||||
}
|
||||
let Some(cleanup_guard) =
|
||||
state.workload_session_pool_cleanup_guard(connection_id, pool_database, &client_session_id).await
|
||||
else {
|
||||
let _ = state.close_client_session_pool(connection_id, pool_database, &client_session_id).await;
|
||||
return Err("Manual transaction requires a dedicated external driver session".to_string());
|
||||
};
|
||||
(
|
||||
TxnConnection::ExternalDriver {
|
||||
session,
|
||||
config,
|
||||
client_session_id,
|
||||
database: pool_database.map(str::to_string),
|
||||
cleanup_guard,
|
||||
},
|
||||
external_pool_key,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let txn_session_id = uuid::Uuid::new_v4().to_string();
|
||||
@@ -4589,7 +4638,7 @@ pub async fn execute_in_manual_transaction_with_options(
|
||||
if let Some(session) = connection {
|
||||
let mut conn = session.connection.lock().await;
|
||||
let _ = rollback_manual_txn_connection(&mut conn).await;
|
||||
release_manual_txn_agent_pool(state, &session.connection_id, &mut conn).await;
|
||||
release_manual_txn_session_pool(state, &session.connection_id, &mut conn).await;
|
||||
return Err(MANUAL_TRANSACTION_IDLE_ROLLBACK_ERROR.to_string());
|
||||
}
|
||||
|
||||
@@ -4622,6 +4671,10 @@ pub async fn execute_in_manual_transaction_with_options(
|
||||
)
|
||||
.await
|
||||
}
|
||||
TxnConnection::ExternalDriver { session, config, .. } => {
|
||||
execute_manual_txn_external_driver_statement(session, config, statement, database, schema, row_limit)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(query_result) => {
|
||||
@@ -4639,7 +4692,7 @@ pub async fn execute_in_manual_transaction_with_options(
|
||||
};
|
||||
if should_rollback {
|
||||
let _ = rollback_manual_txn_connection(&mut conn).await;
|
||||
release_manual_txn_agent_pool(state, &connection_id, &mut conn).await;
|
||||
release_manual_txn_session_pool(state, &connection_id, &mut conn).await;
|
||||
}
|
||||
return Err(format!("Statement {} failed: {}. The manual transaction was rolled back.", i + 1, e));
|
||||
}
|
||||
@@ -4774,6 +4827,9 @@ where
|
||||
TxnConnection::Agent { .. } => {
|
||||
Err("Streaming rows inside an agent manual transaction is not supported".to_string())
|
||||
}
|
||||
TxnConnection::ExternalDriver { .. } => {
|
||||
Err("Streaming rows inside an external driver manual transaction is not supported".to_string())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = &stream_result {
|
||||
@@ -4783,7 +4839,7 @@ where
|
||||
};
|
||||
if let Some(session) = removed {
|
||||
let _ = rollback_manual_txn_connection(&mut conn).await;
|
||||
release_manual_txn_agent_pool(state, &session.connection_id, &mut conn).await;
|
||||
release_manual_txn_session_pool(state, &session.connection_id, &mut conn).await;
|
||||
}
|
||||
return Err(format!("{err}. Transaction was auto-rolled back."));
|
||||
}
|
||||
@@ -4824,13 +4880,31 @@ async fn rollback_manual_txn_connection(conn: &mut TxnConnection) -> Result<(),
|
||||
// the pool map entry is cleaned up later.
|
||||
let _ = locked.disconnect().await;
|
||||
}
|
||||
TxnConnection::ExternalDriver { session, config, .. } => {
|
||||
match session
|
||||
.invoke_with_timeout::<serde_json::Value>(
|
||||
"rollbackManualTransaction",
|
||||
serde_json::json!({ "connection": config.as_ref() }),
|
||||
query_timeout_duration(Some(config.effective_query_timeout_secs())),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(error) if error.to_ascii_lowercase().contains("no manual transaction") => {}
|
||||
Err(error) => return Err(format!("ROLLBACK failed: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn release_manual_txn_agent_pool(state: &AppState, connection_id: &str, conn: &mut TxnConnection) {
|
||||
let TxnConnection::Agent { client_session_id, database, cleanup_guard, .. } = conn else {
|
||||
return;
|
||||
async fn release_manual_txn_session_pool(state: &AppState, connection_id: &str, conn: &mut TxnConnection) {
|
||||
let (client_session_id, database, cleanup_guard) = match conn {
|
||||
TxnConnection::Agent { client_session_id, database, cleanup_guard, .. }
|
||||
| TxnConnection::ExternalDriver { client_session_id, database, cleanup_guard, .. } => {
|
||||
(client_session_id, database, cleanup_guard)
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
if state.detach_client_session_pool(connection_id, database.as_deref(), client_session_id).await.unwrap_or(false) {
|
||||
cleanup_guard.disarm();
|
||||
@@ -4863,6 +4937,31 @@ async fn execute_manual_txn_agent_statement(
|
||||
.map_err(|error| error.into_legacy_string())
|
||||
}
|
||||
|
||||
async fn execute_manual_txn_external_driver_statement(
|
||||
session: &Arc<crate::plugins::PluginDriverSession>,
|
||||
config: &Arc<crate::models::connection::ConnectionConfig>,
|
||||
statement: &str,
|
||||
database: &str,
|
||||
schema: Option<&str>,
|
||||
row_limit: usize,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
let timeout_secs = config.effective_query_timeout_secs();
|
||||
let options = QueryExecutionOptions {
|
||||
max_rows: Some(row_limit.max(1)),
|
||||
timeout_secs: Some(timeout_secs),
|
||||
..QueryExecutionOptions::default()
|
||||
};
|
||||
let params = external_driver_query_params(config.as_ref(), statement, database, schema, &options);
|
||||
session
|
||||
.invoke_with_timeout::<db::QueryResult>(
|
||||
"executeInManualTransaction",
|
||||
params,
|
||||
query_timeout_duration(Some(timeout_secs)),
|
||||
)
|
||||
.await
|
||||
.map(|result| truncate_result_with_max_rows(result, Some(row_limit.max(1))))
|
||||
}
|
||||
|
||||
/// Spawn a background task that removes and rolls back a transaction session
|
||||
/// after 5 minutes of inactivity. The task does not hold the global lock across
|
||||
/// I/O: it briefly checks the map, and if the session exists and is expired,
|
||||
@@ -5019,8 +5118,18 @@ pub async fn commit_manual_transaction(state: &AppState, txn_session_id: &str) -
|
||||
.map_err(|error| format!("COMMIT failed: {error}"))?;
|
||||
let _ = locked.disconnect().await;
|
||||
}
|
||||
TxnConnection::ExternalDriver { session, config, .. } => {
|
||||
session
|
||||
.invoke_with_timeout::<serde_json::Value>(
|
||||
"commitManualTransaction",
|
||||
serde_json::json!({ "connection": config.as_ref() }),
|
||||
query_timeout_duration(Some(config.effective_query_timeout_secs())),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("COMMIT failed: {error}"))?;
|
||||
}
|
||||
}
|
||||
release_manual_txn_agent_pool(state, &session.connection_id, &mut conn).await;
|
||||
release_manual_txn_session_pool(state, &session.connection_id, &mut conn).await;
|
||||
|
||||
log::info!("[query][manual_txn:commit] session_id={}", txn_session_id);
|
||||
Ok(db::QueryResult {
|
||||
@@ -5049,7 +5158,7 @@ pub async fn rollback_manual_transaction(state: &AppState, txn_session_id: &str)
|
||||
|
||||
let mut conn = session.connection.lock().await;
|
||||
rollback_manual_txn_connection(&mut conn).await?;
|
||||
release_manual_txn_agent_pool(state, &session.connection_id, &mut conn).await;
|
||||
release_manual_txn_session_pool(state, &session.connection_id, &mut conn).await;
|
||||
|
||||
log::info!("[query][manual_txn:rollback] session_id={}", txn_session_id);
|
||||
Ok(db::QueryResult {
|
||||
@@ -6757,6 +6866,113 @@ for line in sys.stdin:
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn external_driver_manual_transaction_executes_commits_and_releases_session_pool() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("dbx-jdbc-manual-txn-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let executable = dir.join("plugin.sh");
|
||||
let calls = dir.join("calls.log");
|
||||
std::fs::write(
|
||||
&executable,
|
||||
format!(
|
||||
"#!/bin/sh\nwhile IFS= read -r line; do\n id=$(printf '%s' \"$line\" | sed -E 's/.*\"id\":([0-9]+).*/\\1/')\n case \"$line\" in\n *'\"method\":\"executeInManualTransaction\"'*)\n echo executeInManualTransaction >> '{}'\n printf '{{\"id\":%s,\"result\":{{\"columns\":[\"value\"],\"column_types\":[],\"column_sortables\":[],\"rows\":[[42]],\"affected_rows\":0,\"execution_time_ms\":1,\"truncated\":false,\"session_id\":null,\"has_more\":false}}}}\\n' \"$id\"\n ;;\n *'\"method\":\"beginManualTransaction\"'*)\n echo beginManualTransaction >> '{}'\n printf '{{\"id\":%s,\"result\":{{\"ok\":true}}}}\\n' \"$id\"\n ;;\n *'\"method\":\"commitManualTransaction\"'*)\n echo commitManualTransaction >> '{}'\n printf '{{\"id\":%s,\"result\":{{\"ok\":true}}}}\\n' \"$id\"\n ;;\n esac\ndone\n",
|
||||
calls.display(),
|
||||
calls.display(),
|
||||
calls.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut permissions = std::fs::metadata(&executable).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&executable, permissions).unwrap();
|
||||
|
||||
let plugin = InstalledPlugin {
|
||||
manifest: PluginManifest {
|
||||
id: "jdbc".to_string(),
|
||||
name: "JDBC".to_string(),
|
||||
version: "test".to_string(),
|
||||
protocol_version: 1,
|
||||
description: String::new(),
|
||||
executable: Some("plugin.sh".to_string()),
|
||||
drivers: vec![PluginDriverManifest {
|
||||
id: "jdbc".to_string(),
|
||||
label: "JDBC".to_string(),
|
||||
kind: "external".to_string(),
|
||||
database_type: Some("jdbc".to_string()),
|
||||
}],
|
||||
},
|
||||
path: dir.clone(),
|
||||
};
|
||||
let session = Arc::new(
|
||||
PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default())
|
||||
.await
|
||||
.expect("plugin should start"),
|
||||
);
|
||||
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
|
||||
let state = AppState::new(storage);
|
||||
let mut config = test_connection_config(DatabaseType::Jdbc);
|
||||
config.id = "jdbc-conn".to_string();
|
||||
config.database = Some("dbx_test".to_string());
|
||||
config.connection_string = Some("jdbc:test".to_string());
|
||||
state.configs.write().await.insert(config.id.clone(), config.clone());
|
||||
|
||||
session
|
||||
.invoke::<serde_json::Value>(
|
||||
"beginManualTransaction",
|
||||
serde_json::json!({ "connection": &config, "database": "dbx_test" }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let client_session_id = "manual-txn-test";
|
||||
let pool_key = "jdbc-conn:session:manual-txn-test";
|
||||
let config = Arc::new(config);
|
||||
state.connections.write().await.insert(
|
||||
pool_key.to_string(),
|
||||
PoolKind::ExternalDriver {
|
||||
driver_id: "jdbc".to_string(),
|
||||
config: config.clone(),
|
||||
session: session.clone(),
|
||||
},
|
||||
);
|
||||
let cleanup_guard =
|
||||
state.workload_session_pool_cleanup_guard("jdbc-conn", Some("dbx_test"), client_session_id).await.unwrap();
|
||||
state.transaction_sessions.write().await.insert(
|
||||
"txn-test".to_string(),
|
||||
TransactionSession {
|
||||
connection: Arc::new(tokio::sync::Mutex::new(TxnConnection::ExternalDriver {
|
||||
session,
|
||||
config,
|
||||
client_session_id: client_session_id.to_string(),
|
||||
database: Some("dbx_test".to_string()),
|
||||
cleanup_guard,
|
||||
})),
|
||||
pool_key: pool_key.to_string(),
|
||||
last_activity: std::time::Instant::now(),
|
||||
busy: false,
|
||||
connection_id: "jdbc-conn".to_string(),
|
||||
database: "dbx_test".to_string(),
|
||||
schema: None,
|
||||
},
|
||||
);
|
||||
|
||||
let results =
|
||||
execute_in_manual_transaction(&state, "txn-test", "SELECT 42", "dbx_test", None, Some(10)).await.unwrap();
|
||||
assert_eq!(results[0].rows, vec![vec![serde_json::json!(42)]]);
|
||||
commit_manual_transaction(&state, "txn-test").await.unwrap();
|
||||
assert!(!state.connections.read().await.contains_key(pool_key));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&calls).unwrap(),
|
||||
"beginManualTransaction\nexecuteInManualTransaction\ncommitManualTransaction\n"
|
||||
);
|
||||
|
||||
state.shutdown(Duration::from_secs(1)).await;
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn external_driver_query_page_falls_back_to_legacy_execute_query() {
|
||||
|
||||
Reference in new issue
Block a user