fix(mysql): optionally keep explicit transactions in autocommit tabs
This commit is contained in:
35 files changed
+1274
-47
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user