fix(mysql): optionally keep explicit transactions in autocommit tabs

This commit is contained in:
zipg authored and GitHub committed 2026-09-22 11:11:06 +08:00
1 parent 74f1704d09
commit dc6fee7e93
35 files changed
+1274 -47

No files matched your search

+39
View File
@@ -384,6 +384,13 @@ pub struct AppState {
/// PostgreSQL TLS cancel context, keyed by pool_key.
/// Used to reconstruct a TLS connector compatible with the original connection when cancelling.
postgres_cancel_contexts: Arc<RwLock<HashMap<String, db::postgres::PostgresCancelContext>>>,
/// Pool keys whose tab-scoped MySQL connection holds a transaction the user
/// opened explicitly and DBX deliberately kept open
/// (`preserve_explicit_transaction`). Keeping it here — not on the driver
/// connection — makes the state die with the pool: a reconnect, a rebuilt
/// pool, or a closed tab can never inherit a transaction that no longer
/// exists.
mysql_preserved_transactions: Arc<RwLock<HashSet<String>>>,
pub transaction_sessions: Arc<RwLock<HashMap<String, TransactionSession>>>,
/// `save_password=false` 连接本次运行期的临时密码(内存,进程退出即丢,
/// 绝不落盘)。键为 `(owner_scope, connection_id)`:桌面端 owner 为空串,
@@ -532,6 +539,13 @@ struct PoolRoutingControl {
connections: Arc<RwLock<ConnectionPoolRegistry>>,
pool_activity: Arc<RwLock<HashMap<String, PoolActivity>>>,
postgres_cancel_contexts: Arc<RwLock<HashMap<String, db::postgres::PostgresCancelContext>>>,
/// The same set as [`AppState::mysql_preserved_transactions`]. Every detach
/// path (including `ClientSessionPoolCleanupGuard`'s `Drop`, which never
/// reaches `AppState`) has to clear the marker together with the pool:
/// otherwise a pool rebuilt under the same key would read a stale
/// `already_preserved` and keep a leftover transaction the way #9479
/// described, even with the opt-in turned off.
mysql_preserved_transactions: Arc<RwLock<HashSet<String>>>,
task_supervisor: TaskSupervisor,
}
@@ -688,9 +702,11 @@ impl PoolRoutingControl {
{
let mut activity = self.pool_activity.write().await;
let mut cancel_contexts = self.postgres_cancel_contexts.write().await;
let mut preserved = self.mysql_preserved_transactions.write().await;
for (key, _) in &removed {
activity.remove(key);
cancel_contexts.remove(key);
preserved.remove(key);
}
}
self.close_removed_in_background(removed);
@@ -1396,6 +1412,7 @@ impl AppState {
connections: self.connections.clone(),
pool_activity: self.pool_activity.clone(),
postgres_cancel_contexts: self.postgres_cancel_contexts.clone(),
mysql_preserved_transactions: self.mysql_preserved_transactions.clone(),
task_supervisor: self.task_supervisor.clone(),
}
}
@@ -1523,6 +1540,7 @@ impl AppState {
duckdb_worker_process_isolation: AtomicBool::new(false),
duckdb_worker_max_processes: AtomicUsize::new(DUCKDB_WORKER_MAX_PROCESSES_DEFAULT),
postgres_cancel_contexts: Arc::new(RwLock::new(HashMap::new())),
mysql_preserved_transactions: Arc::new(RwLock::new(HashSet::new())),
transaction_sessions: Arc::new(RwLock::new(HashMap::new())),
session_credentials: SessionCredentialStore::new(),
write_unlock_windows: crate::write_unlock::WriteUnlockWindows::default(),
@@ -4616,14 +4634,30 @@ impl AppState {
self.stop_keepalive_task(&pool_key).await;
self.pool_activity.write().await.remove(&pool_key);
self.postgres_cancel_contexts.write().await.remove(&pool_key);
self.mysql_preserved_transactions.write().await.remove(&pool_key);
let removed = self.update_connection_pools(|connections| connections.remove(&pool_key)).await;
Ok(removed.map(|pool| (pool_key, pool)))
}
/// Whether `pool_key` keeps a transaction the user opened explicitly open
/// on purpose (MySQL auto-commit tabs with `preserve_explicit_transaction`).
pub(crate) async fn has_preserved_explicit_transaction(&self, pool_key: &str) -> bool {
self.mysql_preserved_transactions.read().await.contains(pool_key)
}
pub(crate) async fn mark_preserved_explicit_transaction(&self, pool_key: &str) {
self.mysql_preserved_transactions.write().await.insert(pool_key.to_string());
}
pub(crate) async fn clear_preserved_explicit_transaction(&self, pool_key: &str) {
self.mysql_preserved_transactions.write().await.remove(pool_key);
}
pub async fn remove_pool_by_key(&self, pool_key: &str) -> bool {
self.stop_keepalive_task(pool_key).await;
self.pool_activity.write().await.remove(pool_key);
self.postgres_cancel_contexts.write().await.remove(pool_key);
self.mysql_preserved_transactions.write().await.remove(pool_key);
let removed = self.connections.write().await.remove(pool_key);
if let Some(pool) = removed {
self.pool_routing_control().close_pool_with_timeout(pool_key.to_string(), pool).await;
@@ -9842,10 +9876,15 @@ for line in sys.stdin:
let pool = crate::db::sqlite::connect_path(":memory:").await.unwrap();
state.connections.write().await.insert(pool_key.to_string(), PoolKind::Sqlite(pool));
state.pool_activity.write().await.insert(pool_key.to_string(), super::PoolActivity::now());
// A tab that kept a user transaction and was then detached: the marker
// must not outlive the pool, otherwise rebuilding the same pool key
// would look like "already preserved".
state.mark_preserved_explicit_transaction(pool_key).await;
assert!(state.detach_pool_by_key(pool_key, false).await);
assert!(!state.connections.read().await.contains_key(pool_key));
assert!(!state.pool_activity.read().await.contains_key(pool_key));
assert!(!state.has_preserved_explicit_transaction(pool_key).await);
for _ in 0..100 {
if state.supervised_task_count() == 0 {
+446 -39
View File
@@ -253,6 +253,31 @@ pub struct ExecuteMultiResult {
/// treat it as a no-op rather than an unproven statement.
#[serde(skip_serializing_if = "is_false")]
pub manual_transaction_no_statement: bool,
/// MySQL auto-commit tab: set on every result of the batch when the tab's
/// connection was settled. `Some(true)` means the connection still holds a
/// transaction the user opened explicitly and DBX kept it open
/// (`preserve_explicit_transaction`); `Some(false)` means the settlement ran
/// and no such transaction is open. `None` means this execution never
/// observed a tab-scoped MySQL connection, so it says nothing about the
/// tab's state. The frontend mirrors `Some(..)` into the tab's transaction
/// badge/actions so the state is never silent.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_commit_open_transaction: Option<bool>,
/// MySQL auto-commit tab: set on every result of the batch when DBX rolled
/// back a transaction the user opened explicitly (`BEGIN` /
/// `START TRANSACTION`) and left open. Lets the UI report the cleanup
/// instead of discarding the transaction silently.
#[serde(skip_serializing_if = "is_false")]
pub auto_commit_explicit_transaction_rolled_back: bool,
/// MySQL auto-commit tab: set on every result of the batch when DBX rolled
/// back a transaction nobody opened explicitly — the session turned
/// auto-commit off (`SET autocommit = 0`), so its transactions are implicit.
/// Reported separately from the explicit case: the user never asked for a
/// transaction, so the tab shows a distinct notice that is raised once per
/// connection instead of repeating "your explicit transaction was rolled
/// back" after every execution.
#[serde(skip_serializing_if = "is_false")]
pub auto_commit_session_autocommit_rolled_back: bool,
}
#[derive(Debug, Clone, PartialEq)]
@@ -301,6 +326,9 @@ impl ExecuteMultiResult {
server_message: false,
manual_transaction_proven_read_only: false,
manual_transaction_no_statement: false,
auto_commit_open_transaction: None,
auto_commit_explicit_transaction_rolled_back: false,
auto_commit_session_autocommit_rolled_back: false,
}
}
@@ -316,6 +344,9 @@ impl ExecuteMultiResult {
server_message: false,
manual_transaction_proven_read_only: false,
manual_transaction_no_statement: false,
auto_commit_open_transaction: None,
auto_commit_explicit_transaction_rolled_back: false,
auto_commit_session_autocommit_rolled_back: false,
}
}
@@ -333,6 +364,9 @@ impl ExecuteMultiResult {
server_message: false,
manual_transaction_proven_read_only: false,
manual_transaction_no_statement: false,
auto_commit_open_transaction: None,
auto_commit_explicit_transaction_rolled_back: false,
auto_commit_session_autocommit_rolled_back: false,
}
}
@@ -346,6 +380,9 @@ impl ExecuteMultiResult {
server_message: false,
manual_transaction_proven_read_only: false,
manual_transaction_no_statement: false,
auto_commit_open_transaction: None,
auto_commit_explicit_transaction_rolled_back: false,
auto_commit_session_autocommit_rolled_back: false,
}
}
@@ -370,6 +407,9 @@ impl ExecuteMultiResult {
server_message: false,
manual_transaction_proven_read_only: false,
manual_transaction_no_statement: false,
auto_commit_open_transaction: None,
auto_commit_explicit_transaction_rolled_back: false,
auto_commit_session_autocommit_rolled_back: false,
}
}
@@ -393,6 +433,9 @@ impl ExecuteMultiResult {
server_message: false,
manual_transaction_proven_read_only: false,
manual_transaction_no_statement: false,
auto_commit_open_transaction: None,
auto_commit_explicit_transaction_rolled_back: false,
auto_commit_session_autocommit_rolled_back: false,
}
}
@@ -694,6 +737,9 @@ impl From<db::QueryResult> for ExecuteMultiResult {
server_message: false,
manual_transaction_proven_read_only: false,
manual_transaction_no_statement: false,
auto_commit_open_transaction: None,
auto_commit_explicit_transaction_rolled_back: false,
auto_commit_session_autocommit_rolled_back: false,
}
}
}
@@ -709,6 +755,9 @@ impl From<db::sqlserver::SqlServerBatchResult> for ExecuteMultiResult {
server_message: result.server_message,
manual_transaction_proven_read_only: false,
manual_transaction_no_statement: false,
auto_commit_open_transaction: None,
auto_commit_explicit_transaction_rolled_back: false,
auto_commit_session_autocommit_rolled_back: false,
}
}
}
@@ -1143,6 +1192,13 @@ pub struct QueryExecutionOptions {
/// `PostgresReadOnlyTransaction` executes on an isolated client session and
/// always rolls the transaction back after the result is collected.
pub execution_mode: QueryExecutionMode,
/// MySQL auto-commit tabs only: keep a transaction the user opened
/// explicitly (`BEGIN` / `START TRANSACTION`, or a batch that disabled
/// auto-commit) open across executions instead of rolling it back when the
/// batch finishes. Opt-in per execution (driven by the editor setting);
/// `false` keeps the historical cleanup that stops a leftover transaction
/// from pinning the tab's read view (#9479).
pub preserve_explicit_transaction: bool,
}
fn validate_query_execution_mode(
@@ -1646,6 +1702,7 @@ async fn do_execute_typed(
schema: Option<&str>,
cancel_token: Option<CancellationToken>,
options: QueryExecutionOptions,
transaction_outcome: &mut Option<MysqlAutoCommitTransaction>,
) -> Result<db::QueryResult, QueryExecutionError> {
crate::sql_diagnostics::debug_sql("do_execute", sql);
if let Some(execution_id) = options.execution_id.as_deref() {
@@ -1769,19 +1826,31 @@ async fn do_execute_typed(
// would therefore leave its transaction open and pin the
// connection's REPEATABLE READ read view, so every later
// auto-commit query in the tab would keep reading the same stale
// snapshot until the connection was closed. Clear it here, exactly
// like the multi-statement MySQL path does, so each execution
// restores the auto-commit contract (ROLLBACK is a server no-op
// when no transaction is open).
// snapshot until the connection was closed. Settle it here, exactly
// like the multi-statement MySQL path does: roll the transaction
// back, unless the tab keeps explicit user transactions open
// (`preserve_explicit_transaction`) or a later execution already
// decided to keep this one.
if p.is_client_session_pool() {
if let Err(error) = db::mysql::rollback_open_transaction(&mut conn).await {
log::warn!(
"[query][mysql] trace_id={} open_txn_rollback_failed error={}",
options.execution_id.as_deref().unwrap_or_default(),
error
);
let _ = tokio::time::timeout(Duration::from_secs(5), conn.disconnect()).await;
state.remove_pool_by_key(pool_key).await;
let transaction = settle_mysql_auto_commit_transaction_boxed(
state,
pool_key,
&mut conn,
options.preserve_explicit_transaction,
crate::query_execution_sql::mysql_statement_opens_explicit_transaction(sql),
)
.await;
match transaction {
Ok(transaction) => *transaction_outcome = Some(transaction),
Err(error) => {
log::warn!(
"[query][mysql] trace_id={} open_txn_rollback_failed error={}",
options.execution_id.as_deref().unwrap_or_default(),
error
);
let _ = tokio::time::timeout(Duration::from_secs(5), conn.disconnect()).await;
state.remove_pool_by_key(pool_key).await;
}
}
}
statement_result
@@ -2221,7 +2290,7 @@ pub async fn do_execute(
cancel_token: Option<CancellationToken>,
options: QueryExecutionOptions,
) -> Result<db::QueryResult, String> {
do_execute_typed(state, pool_key, mysql_dialect, database, sql, schema, cancel_token, options)
do_execute_typed(state, pool_key, mysql_dialect, database, sql, schema, cancel_token, options, &mut None)
.await
.map_err(QueryExecutionError::into_legacy_string)
}
@@ -2610,8 +2679,48 @@ pub async fn execute_sql_statement_with_options_typed(
cancel_token: Option<CancellationToken>,
options: QueryExecutionOptions,
) -> Result<db::QueryResult, QueryExecutionError> {
// `_with_outcome` is a second async layer in front of
// `execute_sql_statement_with_options_typed_inner`. Awaiting it inline nests
// one more concrete future type into every caller's generator, which pushed
// Rust targets of this workspace past rustc's query depth limit on Linux
// (`error: queries overflow the depth limit!`). Erasing the type keeps this
// wrapper as shallow as it was before the outcome was added.
let executed: ExecutedSqlStatement<'_> = Box::pin(execute_sql_statement_with_options_typed_with_outcome(
state,
connection_id,
database,
sql,
schema,
cancel_token,
options,
));
executed.await.map(|(result, _)| result)
}
/// Boxed, type-erased run of one SQL statement, awaiting it without nesting
/// another concrete future type into the caller's generator.
type ExecutedSqlStatement<'a> =
std::pin::Pin<Box<dyn std::future::Future<Output = ExecutedSqlStatementOutcome> + Send + 'a>>;
/// Result of [`ExecutedSqlStatement`].
type ExecutedSqlStatementOutcome = Result<(db::QueryResult, Option<MysqlAutoCommitTransaction>), QueryExecutionError>;
/// Same as [`execute_sql_statement_with_options_typed`], but also returns how
/// the MySQL auto-commit settlement left the tab-scoped connection (see
/// [`MysqlAutoCommitTransaction`]). `None` means the statement did not run on a
/// tab-scoped MySQL connection, so there is no such state to report.
pub(crate) async fn execute_sql_statement_with_options_typed_with_outcome(
state: &AppState,
connection_id: &str,
database: &str,
sql: &str,
schema: Option<&str>,
cancel_token: Option<CancellationToken>,
options: QueryExecutionOptions,
) -> Result<(db::QueryResult, Option<MysqlAutoCommitTransaction>), QueryExecutionError> {
let db_type = connection_database_type(state, connection_id).await;
let invalidate = crate::object_cache::sql_may_change_object_metadata(sql, db_type);
let mut transaction = None;
let result = execute_sql_statement_with_options_typed_inner(
state,
connection_id,
@@ -2620,12 +2729,13 @@ pub async fn execute_sql_statement_with_options_typed(
schema,
cancel_token,
options,
&mut transaction,
)
.await;
if invalidate {
crate::object_cache::invalidate_connection_object_cache(&state.storage, connection_id).await;
}
result
result.map(|result| (result, transaction))
}
async fn recover_postgres_create_table_after_connection_error(
@@ -2665,6 +2775,7 @@ async fn recover_postgres_create_table_after_connection_error(
schema,
cancel_token.clone(),
options.clone(),
&mut None,
)
.await
{
@@ -2699,6 +2810,7 @@ async fn recover_postgres_create_table_after_connection_error(
None,
cancel_token,
verify_options,
&mut None,
)
.await
.ok()
@@ -2715,6 +2827,7 @@ async fn execute_sql_statement_with_options_typed_inner(
schema: Option<&str>,
cancel_token: Option<CancellationToken>,
options: QueryExecutionOptions,
transaction_outcome: &mut Option<MysqlAutoCommitTransaction>,
) -> Result<db::QueryResult, QueryExecutionError> {
// MongoDB connections use shell-style commands dispatched through the
// frontend parser. Queries that fall through to the generic SQL executor
@@ -2763,6 +2876,7 @@ async fn execute_sql_statement_with_options_typed_inner(
schema,
cancel_token.clone(),
options.clone(),
transaction_outcome,
)
.await;
@@ -2796,8 +2910,18 @@ async fn execute_sql_statement_with_options_typed_inner(
.await
.map_err(|e| query_error_with_omitted_sql_context(&e, sql))?;
with_sql_context(
do_execute_typed(state, &new_key, mysql_dialect, Some(database), sql, schema, cancel_token, options)
.await,
do_execute_typed(
state,
&new_key,
mysql_dialect,
Some(database),
sql,
schema,
cancel_token,
options,
&mut None,
)
.await,
)
}
Some(PoolErrorAction::Discard) => {
@@ -3184,6 +3308,7 @@ async fn execute_multi_core_with_options_for_client_and_progress_typed_inner(
)
.await,
table_data_preview,
None,
);
}
@@ -3205,19 +3330,21 @@ async fn execute_multi_core_with_options_for_client_and_progress_typed_inner(
{
let single_sql = statements.into_iter().next().unwrap_or_default();
let table_data_preview = options.table_data_preview;
return single_statement_multi_result(
execute_sql_statement_with_options_typed(
state,
connection_id,
database,
&single_sql,
schema,
cancel_token,
options,
)
.await,
table_data_preview,
);
let executed = execute_sql_statement_with_options_typed_with_outcome(
state,
connection_id,
database,
&single_sql,
schema,
cancel_token,
options,
)
.await;
let (result, transaction) = match executed {
Ok((result, transaction)) => (Ok(result), transaction),
Err(error) => (Err(error), None),
};
return single_statement_multi_result(result, table_data_preview, transaction);
}
if let Some((pool, mode)) = mysql_pool {
@@ -3372,8 +3499,15 @@ pub async fn connection_pool_is_sqlserver_agent(state: &AppState, connection_id:
fn single_statement_multi_result(
result: Result<db::QueryResult, QueryExecutionError>,
table_data_preview: bool,
transaction: Option<MysqlAutoCommitTransaction>,
) -> Result<Vec<ExecuteMultiResult>, QueryExecutionError> {
result.map(|result| vec![ExecuteMultiResult::success_with_optional_server_large_values(result, table_data_preview)])
result.map(|result| {
let mut result = ExecuteMultiResult::success_with_optional_server_large_values(result, table_data_preview);
if let Some(transaction) = transaction {
transaction.mark(&mut result);
}
vec![result]
})
}
fn mysql_single_statement_uses_batch_route(
@@ -3658,6 +3792,146 @@ where
(results, None)
}
/// Settled transaction state of a tab-scoped MySQL connection after one
/// execution finished.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MysqlAutoCommitTransaction {
/// No transaction is open on the connection.
None,
/// A transaction the user opened explicitly is still open and was kept.
Preserved,
/// DBX rolled back a transaction the user opened explicitly.
RolledBackExplicit,
/// DBX rolled back a transaction the session opened implicitly because
/// auto-commit was turned off (`SET autocommit = 0`).
RolledBackSessionAutocommit,
}
impl MysqlAutoCommitTransaction {
fn mark(self, result: &mut ExecuteMultiResult) {
result.auto_commit_open_transaction = Some(self == Self::Preserved);
result.auto_commit_explicit_transaction_rolled_back = self == Self::RolledBackExplicit;
result.auto_commit_session_autocommit_rolled_back = self == Self::RolledBackSessionAutocommit;
}
}
/// What the settlement did with the transaction that was still open.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MysqlAutoCommitRollback {
/// Nothing was rolled back.
None,
/// A transaction the batch opened explicitly (`BEGIN` / `START TRANSACTION`).
Explicit,
/// A transaction the session opened implicitly: auto-commit was off
/// (`SET autocommit = 0`), so every statement starts one.
SessionAutocommit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MysqlAutoCommitDecision {
preserve: bool,
rollback: MysqlAutoCommitRollback,
}
/// Decides what to do with a transaction that is still open when an
/// auto-commit tab finished executing.
///
/// A `BEGIN` / `START TRANSACTION` the user typed (or a batch that turned
/// auto-commit off) is an explicit request to hold the changes until `COMMIT` /
/// `ROLLBACK`, so `preserve_explicit_transaction` keeps it — including the
/// later executions that never mention a transaction, which is what
/// `already_preserved` carries. Everything else keeps the historical cleanup
/// that stops a leftover transaction from pinning the tab's read view (#9479),
/// and is reported so the cleanup is never silent.
fn decide_mysql_auto_commit_transaction(
allow_preserve: bool,
already_preserved: bool,
explicit_start_in_batch: bool,
status: Option<db::mysql::MySqlSessionStatus>,
) -> MysqlAutoCommitDecision {
// A session or batch that turned auto-commit off opens its transactions
// implicitly, so the server status is the only signal for those.
let explicit_transaction = explicit_start_in_batch || status.is_some_and(|status| !status.autocommit);
let transaction_open = match status {
Some(status) => status.in_transaction,
// No usable status packet (the last statement ended with an ERR packet
// and the refresh failed): an explicit opener is the only evidence left.
None => explicit_start_in_batch || already_preserved,
};
if !transaction_open {
return MysqlAutoCommitDecision { preserve: false, rollback: MysqlAutoCommitRollback::None };
}
if already_preserved || (allow_preserve && explicit_transaction) {
return MysqlAutoCommitDecision { preserve: true, rollback: MysqlAutoCommitRollback::None };
}
// Distinguish who opened it: only a batch with its own `BEGIN` /
// `START TRANSACTION` is a user transaction; `autocommit = 0` opens
// transactions implicitly for every statement.
let rollback = if explicit_start_in_batch {
MysqlAutoCommitRollback::Explicit
} else if status.is_some_and(|status| !status.autocommit) {
MysqlAutoCommitRollback::SessionAutocommit
} else {
MysqlAutoCommitRollback::None
};
MysqlAutoCommitDecision { preserve: false, rollback }
}
/// Type-erased entry point for [`settle_mysql_auto_commit_transaction`].
///
/// `do_execute_typed` is one of the largest async fns in the crate and is
/// reached transitively by the workspace's live tests and examples. Awaiting the
/// settle future inline nests its concrete future type inside that generator,
/// which pushed `dbx-core` targets past rustc's query depth limit on Linux
/// (`error: queries overflow the depth limit!`). Erasing the type keeps the
/// enclosing generators as shallow as they were before the settlement moved in.
fn settle_mysql_auto_commit_transaction_boxed<'a>(
state: &'a AppState,
pool_key: &'a str,
conn: &'a mut mysql_async::Conn,
allow_preserve: bool,
explicit_start_in_batch: bool,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<MysqlAutoCommitTransaction, String>> + Send + 'a>> {
Box::pin(settle_mysql_auto_commit_transaction(state, pool_key, conn, allow_preserve, explicit_start_in_batch))
}
/// Settles the open transaction of a tab-scoped MySQL connection after an
/// execution: keeps an explicit user transaction when the caller opted in,
/// otherwise rolls it back exactly as before.
///
/// `Err` means the transaction state could not be settled, so the caller must
/// discard the connection instead of returning it to the pool.
async fn settle_mysql_auto_commit_transaction(
state: &AppState,
pool_key: &str,
conn: &mut mysql_async::Conn,
allow_preserve: bool,
explicit_start_in_batch: bool,
) -> Result<MysqlAutoCommitTransaction, String> {
let already_preserved = state.has_preserved_explicit_transaction(pool_key).await;
let mut status = db::mysql::session_status_from_last_ok(conn);
if status.is_none() && (allow_preserve || already_preserved) {
// Only pay for the extra round trip when the answer can change the
// outcome: without the opt-in the historical cleanup runs regardless.
status = db::mysql::ping_session_status_on_conn(conn).await.ok();
}
let decision =
decide_mysql_auto_commit_transaction(allow_preserve, already_preserved, explicit_start_in_batch, status);
if decision.preserve {
state.mark_preserved_explicit_transaction(pool_key).await;
return Ok(MysqlAutoCommitTransaction::Preserved);
}
state.clear_preserved_explicit_transaction(pool_key).await;
// Historical cleanup: `ROLLBACK` is a server no-op when no transaction is
// open, and releases the read view when one is.
db::mysql::rollback_open_transaction(conn).await?;
Ok(match decision.rollback {
MysqlAutoCommitRollback::Explicit => MysqlAutoCommitTransaction::RolledBackExplicit,
MysqlAutoCommitRollback::SessionAutocommit => MysqlAutoCommitTransaction::RolledBackSessionAutocommit,
MysqlAutoCommitRollback::None => MysqlAutoCommitTransaction::None,
})
}
#[allow(clippy::too_many_arguments)]
async fn execute_multi_mysql(
state: &AppState,
@@ -3734,7 +4008,7 @@ async fn execute_multi_mysql(
diagnostic_trace_id: options.execution_id.as_deref(),
};
let statements_started_at = std::time::Instant::now();
let (results, error_action) = execute_mysql_batch_statements(
let (mut results, error_action) = execute_mysql_batch_statements(
&mut executor,
statements,
db_type,
@@ -3755,19 +4029,42 @@ async fn execute_multi_mysql(
// the REPEATABLE READ snapshot for every later auto-commit query on that
// tab, making the tab read stale rows until disconnect. Closing any open
// transaction before returning the connection restores the auto-commit
// contract; ROLLBACK on an already-committed/implicit transaction is a
// server no-op, and a failure here only discards this connection.
// contract, unless the tab keeps explicit user transactions open
// (`preserve_explicit_transaction`); ROLLBACK on an already-committed/
// implicit transaction is a server no-op, and a failure here only discards
// this connection.
{
let tab_scoped = pool.is_client_session_pool();
let explicit_start_in_batch = tab_scoped
&& statements
.iter()
.any(|statement| crate::query_execution_sql::mysql_statement_opens_explicit_transaction(statement));
let rollback_started_at = std::time::Instant::now();
match db::mysql::rollback_open_transaction(&mut conn).await {
Ok(()) => {
let transaction = if tab_scoped {
settle_mysql_auto_commit_transaction(
state,
pool_key,
&mut conn,
options.preserve_explicit_transaction,
explicit_start_in_batch,
)
.await
} else {
db::mysql::rollback_open_transaction(&mut conn).await.map(|()| MysqlAutoCommitTransaction::None)
};
match transaction {
Ok(transaction) => {
if rollback_started_at.elapsed() > std::time::Duration::from_millis(5) {
log::info!(
"[query][mysql-batch] trace_id={} open_txn_rollback_ms={}",
"[query][mysql-batch] trace_id={} open_txn_rollback_ms={} preserved={}",
trace_id,
rollback_started_at.elapsed().as_millis()
rollback_started_at.elapsed().as_millis(),
transaction == MysqlAutoCommitTransaction::Preserved
);
}
for result in &mut results {
transaction.mark(result);
}
}
Err(error) => {
// A failed ROLLBACK leaves the transaction state unknown: drop
@@ -9352,7 +9649,7 @@ for line in sys.stdin:
)
.with_omitted_sql_context("SELECT * FROM dbx_table_that_does_not_exist");
let error = single_statement_multi_result(Err(error), false).unwrap_err();
let error = single_statement_multi_result(Err(error), false, None).unwrap_err();
let backend_error = error.into_backend_error();
assert_eq!(backend_error.code(), "DBX-JDBC-4001");
@@ -11517,7 +11814,7 @@ for line in sys.stdin:
#[test]
fn single_statement_preview_preserves_absent_statement_index() {
let result = single_statement_multi_result(Ok(empty_query_result(1)), true).unwrap();
let result = single_statement_multi_result(Ok(empty_query_result(1)), true, None).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].statement_index, None);
@@ -11541,6 +11838,116 @@ for line in sys.stdin:
assert_eq!(opts.use_transaction, Some(false));
}
fn mysql_status(in_transaction: bool, autocommit: bool) -> db::mysql::MySqlSessionStatus {
db::mysql::MySqlSessionStatus { in_transaction, autocommit }
}
#[test]
fn mysql_auto_commit_default_rolls_back_an_explicitly_opened_transaction() {
// The historical behavior for the default setting: a `BEGIN` a user
// left open is rolled back, but the cleanup is now reported so the tab
// can tell the user instead of discarding the transaction silently.
let decision = decide_mysql_auto_commit_transaction(false, false, true, Some(mysql_status(true, true)));
assert!(!decision.preserve);
assert_eq!(decision.rollback, MysqlAutoCommitRollback::Explicit);
}
#[test]
fn mysql_auto_commit_opt_in_keeps_an_explicitly_opened_transaction() {
// The opt-in decision is made from the *submitted* statement list, not
// from how much of the batch actually ran. A batch that opens a
// transaction and is then cancelled (or aborted by an error) has only
// executed a prefix of its statements, yet the opener is still part of
// the batch, so the connection keeps whatever the prefix did and stays
// in the transaction. Preserving is what makes the abandoned work
// visible — the tab shows the open-transaction badge and the manual
// rollback action — instead of silently rolling back a transaction the
// user asked for. `decide(true, false, true, ..)` below is exactly that
// cancelled-batch case: the caller still passes the opener it was
// about to run, so the decision must be preserve, not roll back.
let decision = decide_mysql_auto_commit_transaction(true, false, true, Some(mysql_status(true, true)));
assert!(decision.preserve);
assert_eq!(decision.rollback, MysqlAutoCommitRollback::None);
}
#[test]
fn mysql_auto_commit_opt_in_keeps_the_transaction_of_a_later_execution() {
// `START TRANSACTION` ran first, this execution only ran an UPDATE, so
// the batch itself carries no opener: the kept state is the only signal.
let decision = decide_mysql_auto_commit_transaction(true, true, false, Some(mysql_status(true, true)));
assert!(decision.preserve);
assert_eq!(decision.rollback, MysqlAutoCommitRollback::None);
// The kept state survives even when the caller does not pass the option
// (auxiliary queries, result paging): an open user transaction is never
// destroyed from another code path.
let no_option = decide_mysql_auto_commit_transaction(false, true, false, Some(mysql_status(true, true)));
assert!(no_option.preserve);
assert_eq!(no_option.rollback, MysqlAutoCommitRollback::None);
}
#[test]
fn mysql_auto_commit_opt_in_keeps_a_transaction_opened_by_auto_commit_off() {
let decision = decide_mysql_auto_commit_transaction(true, false, false, Some(mysql_status(true, false)));
assert!(decision.preserve);
// Without the opt-in the same connection is cleaned up as before, but
// reported as a session-level implicit transaction: nobody typed
// `BEGIN`, the connection simply runs with auto-commit off.
let default = decide_mysql_auto_commit_transaction(false, false, false, Some(mysql_status(true, false)));
assert!(!default.preserve);
assert_eq!(default.rollback, MysqlAutoCommitRollback::SessionAutocommit);
}
#[test]
fn mysql_auto_commit_reports_a_batch_opener_ahead_of_autocommit_off() {
// Both signals at once: a `BEGIN` in the batch wins, so the notice is
// the explicit-transaction one.
let decision = decide_mysql_auto_commit_transaction(false, false, true, Some(mysql_status(true, false)));
assert!(!decision.preserve);
assert_eq!(decision.rollback, MysqlAutoCommitRollback::Explicit);
}
#[test]
fn mysql_auto_commit_rolls_back_a_leftover_transaction_without_reporting_it() {
// No opener in the batch and auto-commit still on: this is a leftover
// transaction (cancelled or aborted batch), not a user transaction.
let decision = decide_mysql_auto_commit_transaction(true, false, false, Some(mysql_status(true, true)));
assert!(!decision.preserve);
assert_eq!(decision.rollback, MysqlAutoCommitRollback::None);
}
#[test]
fn mysql_auto_commit_leaves_a_clean_connection_alone() {
// `BEGIN; ...; COMMIT;` in one batch ends with nothing open.
let decision = decide_mysql_auto_commit_transaction(true, false, true, Some(mysql_status(false, true)));
assert!(!decision.preserve);
assert_eq!(decision.rollback, MysqlAutoCommitRollback::None);
// A kept transaction that the user committed in this batch must be
// forgotten, so later executions are auto-commit again.
let after_commit = decide_mysql_auto_commit_transaction(true, true, false, Some(mysql_status(false, true)));
assert!(!after_commit.preserve);
assert_eq!(after_commit.rollback, MysqlAutoCommitRollback::None);
}
#[test]
fn mysql_auto_commit_uses_the_batch_opener_when_the_status_packet_is_missing() {
// The last statement ended with an ERR packet, so no status is cached
// and the COM_PING refresh failed.
let kept = decide_mysql_auto_commit_transaction(true, false, true, None);
assert!(kept.preserve);
assert_eq!(kept.rollback, MysqlAutoCommitRollback::None);
let cleaned = decide_mysql_auto_commit_transaction(false, false, true, None);
assert!(!cleaned.preserve);
assert_eq!(cleaned.rollback, MysqlAutoCommitRollback::Explicit);
let unknown_without_opener = decide_mysql_auto_commit_transaction(true, false, false, None);
assert!(!unknown_without_opener.preserve);
assert_eq!(unknown_without_opener.rollback, MysqlAutoCommitRollback::None);
}
#[test]
fn database_backup_transactions_request_consistent_snapshots() {
assert_eq!(postgres_transaction_begin_sql(true), "BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY");
@@ -8,7 +8,10 @@
use dbx_core::connection::AppState;
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
use dbx_core::query::{execute_sql_statement, execute_sql_statement_with_options, QueryExecutionOptions};
use dbx_core::query::{
execute_multi_core_with_options_for_client_typed, execute_sql_statement, execute_sql_statement_with_options,
ExecuteMultiResult, QueryExecutionOptions,
};
use dbx_core::storage::Storage;
use std::sync::Arc;
@@ -89,6 +92,95 @@ async fn run_in_tab(
.expect("execute statement in tab client session")
}
/// Same as [`run_in_tab`] for a tab that opted into keeping explicit user
/// transactions open (`editor` setting `keepExplicitTransactionInAutoCommit`).
async fn run_in_tab_keeping_transactions(
state: &AppState,
config: &ConnectionConfig,
database: &str,
client_session_id: &str,
sql: &str,
) -> dbx_core::db::QueryResult {
execute_sql_statement_with_options(
state,
&config.id,
database,
sql,
None,
None,
QueryExecutionOptions {
client_session_id: Some(client_session_id.to_string()),
preserve_explicit_transaction: true,
..Default::default()
},
)
.await
.expect("execute statement in tab client session")
}
/// Creates an isolated `(id, v)` probe table for the explicit-transaction
/// cases, seeded with `(1, 0)`.
async fn setup_probe(config: &ConnectionConfig, tag: &str) -> (Arc<AppState>, std::path::PathBuf, String) {
let storage_path = std::env::temp_dir().join(format!("{tag}-{}.db", uuid::Uuid::new_v4().simple()));
let state = Arc::new(AppState::new(Storage::open(&storage_path).await.expect("temporary storage")));
let table_name = format!("dbx_issue_9749_{}", uuid::Uuid::new_v4().simple());
let database = config.database.clone().expect("database");
state.configs.write().await.insert(config.id.clone(), config.clone());
for sql in [
format!("CREATE TABLE {table_name} (id INTEGER PRIMARY KEY, v INTEGER NOT NULL) ENGINE=InnoDB"),
format!("INSERT INTO {table_name} (id, v) VALUES (1, 0)"),
] {
execute_sql_statement(&state, &config.id, &database, &sql, None, None).await.expect("prepare probe table");
}
(state, storage_path, table_name)
}
/// Runs a batch on the tab-scoped client session through the same multi-result
/// route the SQL editor uses, so the auto-commit settlement runs exactly like
/// in the app.
async fn run_batch_in_tab(
state: &AppState,
config: &ConnectionConfig,
database: &str,
client_session_id: &str,
sql: &str,
preserve_explicit_transaction: bool,
) -> Vec<ExecuteMultiResult> {
execute_multi_core_with_options_for_client_typed(
state,
&config.id,
database,
sql,
None,
None,
QueryExecutionOptions {
client_session_id: Some(client_session_id.to_string()),
preserve_explicit_transaction,
..Default::default()
},
)
.await
.expect("execute batch in tab client session")
}
fn last_result(results: &[ExecuteMultiResult]) -> &ExecuteMultiResult {
results.last().expect("batch returns at least one result")
}
/// Reads `v` through a separate auto-commit connection, so uncommitted changes
/// on the tab connection stay invisible.
async fn committed_value(state: &AppState, config: &ConnectionConfig, database: &str, table: &str) -> i64 {
let result =
execute_sql_statement(state, &config.id, database, &format!("SELECT v FROM {table} WHERE id = 1"), None, None)
.await
.expect("read committed value");
assert_eq!(result.rows.len(), 1);
result.rows[0][0]
.as_i64()
.or_else(|| result.rows[0][0].as_str().and_then(|value| value.parse().ok()))
.expect("integer probe value")
}
async fn cleanup(state: &AppState, config: &ConnectionConfig, table_name: &str, storage_path: std::path::PathBuf) {
execute_sql_statement(
state,
@@ -136,3 +228,162 @@ async fn live_mysql_single_statement_begin_does_not_pin_the_tab_snapshot() {
cleanup(&state, &config, &table_name, storage_path).await;
}
/// #9749 case A: `START TRANSACTION` and `UPDATE` in one execution, `COMMIT` in
/// the next one. The change must survive the execution boundary and become
/// visible to other connections only after the `COMMIT`.
#[tokio::test]
#[ignore = "requires DBX_LIVE_AUTOCOMMIT_MYSQL_* pointing at a writable MySQL database"]
async fn live_mysql_kept_explicit_transaction_commits_across_executions() {
let config = live_config("DBX_LIVE_AUTOCOMMIT_MYSQL");
let database = config.database.clone().expect("database");
let (state, storage_path, table_name) = setup_probe(&config, "dbx-issue-9749-commit").await;
let client_session_id = format!("query-tab-{}", uuid::Uuid::new_v4().simple());
let update = format!("UPDATE {table_name} SET v = 1 WHERE id = 1");
let first = run_batch_in_tab(
&state,
&config,
&database,
&client_session_id,
&format!("START TRANSACTION;\n{update}"),
true,
)
.await;
assert_eq!(
last_result(&first).auto_commit_open_transaction,
Some(true),
"the tab must keep the explicit transaction open"
);
assert!(!last_result(&first).auto_commit_explicit_transaction_rolled_back);
assert_eq!(committed_value(&state, &config, &database, &table_name).await, 0, "the change is uncommitted");
let commit = run_batch_in_tab(&state, &config, &database, &client_session_id, "COMMIT", true).await;
assert_eq!(last_result(&commit).auto_commit_open_transaction, Some(false), "COMMIT ends the kept transaction");
assert_eq!(committed_value(&state, &config, &database, &table_name).await, 1, "COMMIT persists the change");
// Acceptance 5: after the COMMIT the tab auto-commits again.
run_batch_in_tab(
&state,
&config,
&database,
&client_session_id,
&format!("UPDATE {table_name} SET v = 5 WHERE id = 1"),
true,
)
.await;
assert_eq!(committed_value(&state, &config, &database, &table_name).await, 5, "plain UPDATE auto-commits again");
cleanup(&state, &config, &table_name, storage_path).await;
}
/// #9749 case B: `START TRANSACTION`, `UPDATE` and `ROLLBACK` each executed on
/// their own. The `ROLLBACK` must undo the change, which requires the UPDATE to
/// still run inside the transaction the first execution opened.
#[tokio::test]
#[ignore = "requires DBX_LIVE_AUTOCOMMIT_MYSQL_* pointing at a writable MySQL database"]
async fn live_mysql_kept_explicit_transaction_rolls_back_across_executions() {
let config = live_config("DBX_LIVE_AUTOCOMMIT_MYSQL");
let database = config.database.clone().expect("database");
let (state, storage_path, table_name) = setup_probe(&config, "dbx-issue-9749-rollback").await;
let client_session_id = format!("query-tab-{}", uuid::Uuid::new_v4().simple());
let update = format!("UPDATE {table_name} SET v = 2 WHERE id = 1");
let start = run_batch_in_tab(&state, &config, &database, &client_session_id, "START TRANSACTION", true).await;
assert_eq!(last_result(&start).auto_commit_open_transaction, Some(true));
let updated = run_batch_in_tab(&state, &config, &database, &client_session_id, &update, true).await;
assert_eq!(
last_result(&updated).auto_commit_open_transaction,
Some(true),
"a follow-up execution must not end the transaction the tab kept"
);
assert_eq!(committed_value(&state, &config, &database, &table_name).await, 0, "the change is uncommitted");
let rollback = run_batch_in_tab(&state, &config, &database, &client_session_id, "ROLLBACK", true).await;
assert_eq!(last_result(&rollback).auto_commit_open_transaction, Some(false));
assert_eq!(committed_value(&state, &config, &database, &table_name).await, 0, "ROLLBACK undoes the change");
run_batch_in_tab(
&state,
&config,
&database,
&client_session_id,
&format!("UPDATE {table_name} SET v = 7 WHERE id = 1"),
true,
)
.await;
assert_eq!(committed_value(&state, &config, &database, &table_name).await, 7, "plain UPDATE auto-commits again");
cleanup(&state, &config, &table_name, storage_path).await;
}
/// Without the opt-in the historical cleanup stays in place — it now reports
/// the rollback so the UI can tell the user instead of losing it silently.
#[tokio::test]
#[ignore = "requires DBX_LIVE_AUTOCOMMIT_MYSQL_* pointing at a writable MySQL database"]
async fn live_mysql_default_auto_commit_still_rolls_back_explicit_transactions() {
let config = live_config("DBX_LIVE_AUTOCOMMIT_MYSQL");
let database = config.database.clone().expect("database");
let (state, storage_path, table_name) = setup_probe(&config, "dbx-issue-9749-default").await;
let client_session_id = format!("query-tab-{}", uuid::Uuid::new_v4().simple());
let update = format!("UPDATE {table_name} SET v = 1 WHERE id = 1");
let first = run_batch_in_tab(
&state,
&config,
&database,
&client_session_id,
&format!("START TRANSACTION;\n{update}"),
false,
)
.await;
assert_eq!(last_result(&first).auto_commit_open_transaction, Some(false));
assert!(
last_result(&first).auto_commit_explicit_transaction_rolled_back,
"the cleanup of a user transaction must be reported"
);
let commit = run_batch_in_tab(&state, &config, &database, &client_session_id, "COMMIT", false).await;
assert!(!last_result(&commit).auto_commit_explicit_transaction_rolled_back);
assert_eq!(committed_value(&state, &config, &database, &table_name).await, 0, "the change was rolled back");
cleanup(&state, &config, &table_name, storage_path).await;
}
/// The opt-in also keeps a transaction opened by a single-statement `BEGIN`,
/// which is its documented trade-off: the tab then reads the snapshot the user
/// asked for, and the snapshot is released by `COMMIT`.
#[tokio::test]
#[ignore = "requires DBX_LIVE_AUTOCOMMIT_MYSQL_* pointing at a writable MySQL database"]
async fn live_mysql_opted_in_single_statement_begin_keeps_its_snapshot() {
let config = live_config("DBX_LIVE_AUTOCOMMIT_MYSQL");
let database = config.database.clone().expect("database");
let (state, storage_path, table_name) = setup(&config).await;
let client_session_id = format!("query-tab-{}", uuid::Uuid::new_v4().simple());
let count_sql = format!("SELECT COUNT(*) AS row_count FROM {table_name}");
run_in_tab_keeping_transactions(&state, &config, &database, &client_session_id, "BEGIN").await;
let first = run_in_tab_keeping_transactions(&state, &config, &database, &client_session_id, &count_sql).await;
assert_row_count(&first.rows, 1);
execute_sql_statement(
&state,
&config.id,
&database,
&format!("INSERT INTO {table_name} (id) VALUES (2)"),
None,
None,
)
.await
.expect("external committed write");
let second = run_in_tab_keeping_transactions(&state, &config, &database, &client_session_id, &count_sql).await;
assert_row_count(&second.rows, 1);
run_in_tab_keeping_transactions(&state, &config, &database, &client_session_id, "COMMIT").await;
let third = run_in_tab_keeping_transactions(&state, &config, &database, &client_session_id, &count_sql).await;
assert_row_count(&third.rows, 2);
cleanup(&state, &config, &table_name, storage_path).await;
}
+39 -3
View File
@@ -161,6 +161,33 @@ pub async fn rollback_open_transaction(conn: &mut mysql_async::Conn) -> Result<(
conn.query_drop("ROLLBACK").await.map_err(|error| error.to_string())
}
/// Session state reported by the final status packet of the last statement.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MySqlSessionStatus {
/// `SERVER_STATUS_IN_TRANS`: a multi-statement transaction is open.
pub in_transaction: bool,
/// `SERVER_STATUS_AUTOCOMMIT`: the session still commits every statement.
/// `false` means auto-commit was disabled for the session (or for this
/// batch), so the next statement opens a transaction that only an explicit
/// `COMMIT`/`ROLLBACK` ends.
pub autocommit: bool,
}
/// Reads the status flags of the last final OK/EOF packet.
///
/// `None` means the server reported no usable status: mysql_async clears its
/// cached packet when a statement ends with an ERR packet, so callers must not
/// read "no transaction is open" out of a missing packet.
pub fn session_status_from_last_ok(conn: &mysql_async::Conn) -> Option<MySqlSessionStatus> {
conn.last_ok_packet().map(|packet| {
let flags = packet.status_flags();
MySqlSessionStatus {
in_transaction: flags.contains(StatusFlags::SERVER_STATUS_IN_TRANS),
autocommit: flags.contains(StatusFlags::SERVER_STATUS_AUTOCOMMIT),
}
})
}
const MYSQL_TCP_KEEPALIVE_MS: u32 = 30_000;
const MYSQL_SQL_PACKET_MARGIN_MAX_BYTES: usize = 64 * 1024;
@@ -219,8 +246,8 @@ fn transaction_error_from_mysql_error(error: mysql_async::Error) -> MySqlTransac
}
fn transaction_status_from_last_ok(conn: &mysql_async::Conn) -> Result<bool, MySqlTransactionError> {
conn.last_ok_packet()
.map(|packet| packet.status_flags().contains(StatusFlags::SERVER_STATUS_IN_TRANS))
session_status_from_last_ok(conn)
.map(|status| status.in_transaction)
.ok_or_else(|| MySqlTransactionError::Transport("MySQL did not return a final status packet".to_string()))
}
@@ -228,8 +255,17 @@ fn transaction_status_from_last_ok(conn: &mysql_async::Conn) -> Result<bool, MyS
/// intentionally clears the cached OK packet for ERR, so COM_PING is required
/// to obtain fresh status from the same physical connection.
pub async fn ping_transaction_status_on_conn(conn: &mut mysql_async::Conn) -> Result<bool, MySqlTransactionError> {
ping_session_status_on_conn(conn).await.map(|status| status.in_transaction)
}
/// Read the full session status after a recoverable server ERR packet. See
/// [`ping_transaction_status_on_conn`].
pub async fn ping_session_status_on_conn(
conn: &mut mysql_async::Conn,
) -> Result<MySqlSessionStatus, MySqlTransactionError> {
conn.ping().await.map_err(transaction_error_from_mysql_error)?;
transaction_status_from_last_ok(conn)
session_status_from_last_ok(conn)
.ok_or_else(|| MySqlTransactionError::Transport("MySQL did not return a final status packet".to_string()))
}
impl MySqlQueryResult {
+70
View File
@@ -744,6 +744,35 @@ fn starts_with_keyword(upper: &str, keyword: &str) -> bool {
&& (upper.len() == keyword.len() || !upper.as_bytes()[keyword.len()].is_ascii_alphanumeric())
}
/// Whether one MySQL statement is an explicit transaction opener that a
/// tab-scoped auto-commit session may keep alive across executions: a bare
/// `BEGIN` / `BEGIN WORK` / `START TRANSACTION [modifiers]`.
///
/// `COMMIT` / `ROLLBACK` close a transaction instead of opening one, and
/// compound statements (`BEGIN ... END`, only valid inside stored programs)
/// never open a transaction at the top level, so neither is matched. Anything
/// ambiguous — extra statements in the same text, other `BEGIN` continuations —
/// is reported as "not an opener" so the caller falls back to the historical
/// cleanup instead of keeping a transaction open by accident.
pub fn mysql_statement_opens_explicit_transaction(sql: &str) -> bool {
let cleaned = strip_sql_comments(sql);
let mut parts = cleaned.split(';');
let statement = parts.next().unwrap_or_default().trim();
if statement.is_empty() || parts.any(|part| !part.trim().is_empty()) {
return false;
}
let upper = statement.to_ascii_uppercase();
let mut tokens = upper.split_whitespace();
match tokens.next() {
Some("BEGIN") => matches!(tokens.next(), None | Some("WORK")),
// Modifiers (`READ ONLY`, `READ WRITE`, `WITH CONSISTENT SNAPSHOT`) are
// optional and are verified by the server; a malformed one fails the
// statement, so no transaction is left open for this execution.
Some("START") => tokens.next() == Some("TRANSACTION"),
_ => false,
}
}
/// Check whether a SQL statement is allowed under read-only mode.
/// Returns Err with a descriptive message if the statement is a write operation.
pub fn check_read_only(sql: &str, connection_name: &str, database_type: DatabaseType) -> Result<(), String> {
@@ -2219,4 +2248,45 @@ mod tests {
Ok(())
);
}
#[test]
fn mysql_explicit_transaction_openers_are_recognized() {
for sql in [
"BEGIN",
"begin",
"BEGIN;",
" BEGIN ",
"BEGIN WORK",
"BEGIN WORK;",
"-- open a transaction\nBEGIN",
"/* keep */ START TRANSACTION",
"START TRANSACTION",
"start transaction;",
"START TRANSACTION READ ONLY",
"START TRANSACTION READ WRITE",
"START TRANSACTION WITH CONSISTENT SNAPSHOT",
] {
assert!(mysql_statement_opens_explicit_transaction(sql), "expected opener: {sql}");
}
}
#[test]
fn mysql_non_openers_are_not_treated_as_explicit_transactions() {
for sql in [
"",
" ",
";",
"SELECT 1",
"COMMIT",
"ROLLBACK",
"BEGIN;\nUPDATE t SET a = 1;",
"BEGIN\nDECLARE x INT;\nEND",
"BEGIN END",
"START REPLICA",
"SET autocommit = 0",
"SELECT 'BEGIN' FROM t",
] {
assert!(!mysql_statement_opens_explicit_transaction(sql), "expected non-opener: {sql}");
}
}
}
+9
View File
@@ -36,6 +36,11 @@ pub struct ExecuteQueryRequest {
pub use_transaction: Option<bool>,
pub continue_on_error: Option<bool>,
pub execution_mode: Option<dbx_core::query::QueryExecutionMode>,
/// MySQL auto-commit tabs: keep a transaction the user opened explicitly
/// (`BEGIN` / `START TRANSACTION`) open across executions until COMMIT /
/// ROLLBACK. Defaults to the historical cleanup when omitted.
#[serde(default)]
pub preserve_explicit_transaction: bool,
}
#[derive(Deserialize)]
@@ -600,6 +605,7 @@ pub async fn execute_multi(
use_transaction: req.use_transaction,
continue_on_error: req.continue_on_error.unwrap_or(false),
execution_mode: req.execution_mode.unwrap_or_default(),
preserve_explicit_transaction: req.preserve_explicit_transaction,
},
)
.await;
@@ -1410,6 +1416,9 @@ mod tests {
server_message: false,
manual_transaction_proven_read_only: false,
manual_transaction_no_statement: false,
auto_commit_open_transaction: None,
auto_commit_explicit_transaction_rolled_back: false,
auto_commit_session_autocommit_rolled_back: false,
};
let response = execute_multi_response(vec![result], 17).unwrap();