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

This commit is contained in:
zipg
2026-09-22 11:11:06 +08:00
committed by GitHub
parent 74f1704d09
commit dc6fee7e93
35 changed files with 1274 additions and 47 deletions
@@ -562,6 +562,7 @@ const showThemeCustomizer = ref(false);
const showDataGridTypeColorScheme = ref(false);
const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
const editDefaultTransactionMode = ref(settingsStore.editorSettings.defaultTransactionMode);
const editKeepExplicitTransactionInAutoCommit = ref(settingsStore.editorSettings.keepExplicitTransactionInAutoCommit);
const editShortcuts = ref(normalizeShortcutSettings(settingsStore.editorSettings.shortcuts));
function translateWithExecuteShortcut(key: string): string {
return t(key, { shortcut: formatShortcutDisplay(editShortcuts.value.executeSql) });
@@ -930,6 +931,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
activeCustomThemeId: editActiveCustomThemeId.value,
executeMode: editExecuteMode.value,
defaultTransactionMode: editDefaultTransactionMode.value,
keepExplicitTransactionInAutoCommit: editKeepExplicitTransactionInAutoCommit.value,
executeAllOnBlankLine: editExecuteAllOnBlankLine.value,
showExecutionTargetPicker: editShowExecutionTargetPicker.value,
showStatementRunButtons: editShowStatementRunButtons.value,
@@ -1571,6 +1573,7 @@ function syncEditorSettingsDraftFromStore() {
editActiveCustomThemeId.value = settingsStore.editorSettings.activeCustomThemeId;
editExecuteMode.value = settingsStore.editorSettings.executeMode;
editDefaultTransactionMode.value = settingsStore.editorSettings.defaultTransactionMode;
editKeepExplicitTransactionInAutoCommit.value = settingsStore.editorSettings.keepExplicitTransactionInAutoCommit;
editExecuteAllOnBlankLine.value = settingsStore.editorSettings.executeAllOnBlankLine;
editShowExecutionTargetPicker.value = settingsStore.editorSettings.showExecutionTargetPicker;
editShowStatementRunButtons.value = settingsStore.editorSettings.showStatementRunButtons;
@@ -1815,6 +1818,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = {
clickTableNavigationTarget: editClickTableNavigationTarget,
completionTriggerMode: editCompletionTriggerMode,
defaultTransactionMode: editDefaultTransactionMode,
keepExplicitTransactionInAutoCommit: editKeepExplicitTransactionInAutoCommit,
tableColumnTemplateFields: editTableColumnTemplateRows,
};
@@ -2159,6 +2163,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editFontSize.value = DEFAULT_EDITOR_SETTINGS.fontSize;
editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode;
editDefaultTransactionMode.value = DEFAULT_EDITOR_SETTINGS.defaultTransactionMode;
editKeepExplicitTransactionInAutoCommit.value = DEFAULT_EDITOR_SETTINGS.keepExplicitTransactionInAutoCommit;
editExecuteAllOnBlankLine.value = DEFAULT_EDITOR_SETTINGS.executeAllOnBlankLine;
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons;
@@ -2310,6 +2315,7 @@ function resetAllDefaults() {
editActiveCustomThemeId.value = DEFAULT_EDITOR_SETTINGS.activeCustomThemeId;
editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode;
editDefaultTransactionMode.value = DEFAULT_EDITOR_SETTINGS.defaultTransactionMode;
editKeepExplicitTransactionInAutoCommit.value = DEFAULT_EDITOR_SETTINGS.keepExplicitTransactionInAutoCommit;
editExecuteAllOnBlankLine.value = DEFAULT_EDITOR_SETTINGS.executeAllOnBlankLine;
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons;
@@ -6095,6 +6101,16 @@ onUnmounted(() => {
</Select>
</div>
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2 md:col-span-2" data-editor-keep-explicit-transaction>
<div class="min-w-0 space-y-1">
<Label for="editor-keep-explicit-transaction">{{ t("settings.keepExplicitTransactionInAutoCommit") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.keepExplicitTransactionInAutoCommitDescription") }}
</p>
</div>
<Switch id="editor-keep-explicit-transaction" v-model="editKeepExplicitTransactionInAutoCommit" class="mt-0.5 shrink-0" />
</div>
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2" :class="{ 'opacity-50': editExecuteMode !== 'current' }">
<div class="space-y-1">
<Label for="editor-execute-all-on-blank-line">{{ t("settings.executeAllOnBlankLine") }}</Label>
@@ -186,6 +186,9 @@ const groupExecutableSql = computed(() => {
:txn-session-id="activeTab.txnSessionId"
:txn-auto-rolled-back="activeTab.txnAutoRolledBack"
:txn-possibly-dirty="activeTab.txnPossiblyDirty"
:auto-commit-open-transaction="activeTab.autoCommitOpenTransaction"
:auto-commit-txn-rolled-back="activeTab.autoCommitTxnRolledBack"
:auto-commit-session-txn-rolled-back="activeTab.autoCommitSessionTxnRolledBack"
:sticky-proven-read-only-state="isGroupStickyManualTransaction"
@update:explain-mode="(m: 'explain' | 'autotrace') => (toolbar.explainMode.value = m)"
@update:block-dangerous-redis-commands="(v: boolean) => (toolbar.blockDangerousRedisCommands.value = v)"
@@ -199,6 +202,8 @@ const groupExecutableSql = computed(() => {
@commit="activeTab && queryStore.commitTransaction(activeTab.id)"
@rollback="activeTab && queryStore.rollbackTransaction(activeTab.id)"
@dismiss-txn-rolled-back="activeTab && (activeTab.txnAutoRolledBack = false)"
@dismiss-auto-commit-txn-rolled-back="activeTab && (activeTab.autoCommitTxnRolledBack = false)"
@dismiss-auto-commit-session-txn-rolled-back="activeTab && (activeTab.autoCommitSessionTxnRolledBack = false)"
@execute-pointer-down="toolbar.captureExecutionSnapshot(activeTab.id)"
@toolbar-execute="toolbar.toolbarExecute($event, activeTab.id)"
@multi-execute="toolbar.multiExecute()"
@@ -48,6 +48,16 @@ const props = defineProps<{
/** Oracle manual mode derived from the resolved database type (not raw
* db_type, which can be the agent transport). */
stickyProvenReadOnlyState?: boolean;
/** Auto-commit tabs (`Tx:A`): the tab's own connection holds a transaction
* the user opened explicitly (`BEGIN` / `START TRANSACTION`) that DBX kept
* open. Commit/Rollback act on that transaction. */
autoCommitOpenTransaction?: boolean;
/** Auto-commit tab: the backend rolled back an explicit transaction this tab
* left open (the tab did not opt into keeping them). */
autoCommitTxnRolledBack?: boolean;
/** Auto-commit tab: the backend rolled back an implicitly opened transaction
* (`SET autocommit = 0`), reported once per connection. */
autoCommitSessionTxnRolledBack?: boolean;
}>();
const emit = defineEmits<{
@@ -76,6 +86,8 @@ const emit = defineEmits<{
commit: [];
rollback: [];
dismissTxnRolledBack: [];
dismissAutoCommitTxnRolledBack: [];
dismissAutoCommitSessionTxnRolledBack: [];
}>();
const { t } = useI18n();
@@ -277,16 +289,23 @@ function toggleInsertValueHints() {
settingsStore.updateEditorSettings({ showInsertValueHints: !insertValueHintsEnabled.value });
}
const isTransactionActive = computed(() => !!props.txnSessionId);
/** Auto-commit tab whose connection holds a transaction the user opened with
* `BEGIN` / `START TRANSACTION` and that DBX kept open (`Tx:A`). */
const hasOpenAutoCommitTransaction = computed(() => props.autoCommitOpenTransaction === true);
const isManualTransactionMode = computed(() => props.autoCommit === false || isTransactionActive.value);
const transactionModeBadge = computed(() => (isManualTransactionMode.value ? "M" : "A"));
// Sticky proven-read-only dialects (Oracle/OceanBase-Oracle/MySQL/PostgreSQL)
// hide Commit/Rollback while the session is clean (no unproven statement
// executed). Every other database keeps the existing rule.
// executed). Every other database keeps the existing rule. An auto-commit tab
// that kept the user's explicit transaction always offers both actions: that
// transaction exists only because the user asked for it.
const showTxnActions = computed(() => {
if (hasOpenAutoCommitTransaction.value) return true;
if (props.stickyProvenReadOnlyState) return isTransactionActive.value && props.txnPossiblyDirty === true;
return isTransactionActive.value;
});
const transactionTooltip = computed(() => {
if (hasOpenAutoCommitTransaction.value) return t("settings.keepExplicitTransactionInAutoCommitDescription");
const isAgent = (props.activeConnection?.db_type as string) === "agent";
const isManual = isManualTransactionMode.value;
if (isAgent && isManual) return t("toolbar.manualTransactionAgent");
@@ -699,12 +718,15 @@ async function changeCatalog(selectedCatalog: string) {
:class="isManualTransactionMode ? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-300' : 'text-orange-600/70 hover:bg-orange-500/10 hover:text-orange-700 dark:text-orange-300/70 dark:hover:text-orange-200'"
:disabled="activeTab.isExecuting || activeTab.isExplaining"
:aria-label="transactionTooltip"
:aria-pressed="isManualTransactionMode"
:aria-pressed="isManualTransactionMode || hasOpenAutoCommitTransaction"
@click="emit('update:autoCommit', autoCommit === false)"
>
<span class="inline-flex items-center gap-px leading-none" aria-hidden="true">
<span class="text-[11px] font-bold">Tx:</span>
<span class="inline-flex h-3 min-w-3 items-center justify-center rounded-[3px] border border-current px-px text-[8px] font-extrabold leading-none">{{ transactionModeBadge }}</span>
<!-- Auto-commit tab with an uncommitted explicit transaction:
the commit/rollback actions next to the badge act on it. -->
<span v-if="hasOpenAutoCommitTransaction" data-toolbar-open-transaction-dot class="ml-px h-1.5 w-1.5 rounded-full bg-current" />
</span>
</Button>
</TooltipTrigger>
@@ -879,6 +901,20 @@ async function changeCatalog(selectedCatalog: string) {
<span class="truncate">{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}</span>
</div>
</div>
<div v-if="autoCommitTxnRolledBack" data-auto-commit-txn-rolled-back class="flex items-center gap-2 px-3 py-1 text-xs bg-amber-500/10 text-amber-700 dark:text-amber-300 border-b border-amber-500/20">
<AlertTriangle class="h-3.5 w-3.5 shrink-0" />
<span>{{ t("toolbar.autoCommitTxnRolledBack") }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 ml-auto" @click="emit('dismissAutoCommitTxnRolledBack')">
<X class="h-3 w-3" />
</Button>
</div>
<div v-else-if="autoCommitSessionTxnRolledBack" data-auto-commit-session-txn-rolled-back class="flex items-center gap-2 px-3 py-1 text-xs bg-amber-500/10 text-amber-700 dark:text-amber-300 border-b border-amber-500/20">
<AlertTriangle class="h-3.5 w-3.5 shrink-0" />
<span>{{ t("toolbar.autoCommitSessionTxnRolledBack") }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 ml-auto" @click="emit('dismissAutoCommitSessionTxnRolledBack')">
<X class="h-3 w-3" />
</Button>
</div>
<div v-if="txnAutoRolledBack" class="flex items-center gap-2 px-3 py-1 text-xs bg-amber-500/10 text-amber-700 dark:text-amber-300 border-b border-amber-500/20">
<AlertTriangle class="h-3.5 w-3.5 shrink-0" />
<span>{{ t("toolbar.txnAutoRolledBack") }}</span>
+5
View File
@@ -214,6 +214,8 @@ export default withEnglishFallback({
commit: "Təsdiqlə",
rollback: "Geri qaytar",
txnAutoRolledBack: "5 dəqiqə fəaliyyətsizlikdən sonra tranzaksiya avtomatik geri qaytarıldı",
autoCommitTxnRolledBack: 'Bu nişanın açıq qoyduğu tranzaksiya avtomatik geri qaytarıldı; icralar arasında saxlamaq üçün ayarlarda "Avtomatik commit rejimində açıq tranzaksiyaları saxla" seçimini aktivləşdirin',
autoCommitSessionTxnRolledBack: "Bu bağlantı autocommit söndürülmüş (SET autocommit = 0) işləyir, ona görə örtük tranzaksiya icradan sonra geri qaytarıldı; DBX bunu hər bağlantı üçün bir dəfə bildirir",
previewSql: "SQL önbaxışı",
previewQuery: "Sorğu önbaxışı",
hidePreviewSql: "SQL önbaxışını gizlət",
@@ -7480,6 +7482,9 @@ export default withEnglishFallback({
'Yeni SQL redaktoru açıldıqda standart olaraq istifadə olunan tranzaksiya rejimi. "Əl ilə tranzaksiya" seçildikdə yeni redaktorlar əl ilə tranzaksiya rejimində açılır və dəyişiklikləri əl ilə təsdiqləmək və ya geri qaytarmaq tələb olunur; "Avtomatik təsdiqləmə" seçildikdə hər əmr avtomatik təsdiqlənir. Yalnız tranzaksiyaları dəstəkləyən verilənlər bazalarına tətbiq olunur.',
defaultTransactionModeAuto: "Avtomatik təsdiqləmə",
defaultTransactionModeManual: "Əl ilə tranzaksiya",
keepExplicitTransactionInAutoCommit: "Avtomatik commit rejimində açıq tranzaksiyaları saxla",
keepExplicitTransactionInAutoCommitDescription:
"Avtomatik commit nişanında BEGIN / START TRANSACTION icra etdikdən sonra tranzaksiya siz COMMIT və ya ROLLBACK icra edənə qədər açıq qalır. Söndürüldükdə hər icra açıq qalan tranzaksiyanı geri qaytarır ki, qalıq tranzaksiya nişanın oxuma snapshot-ını sabitləməsin.",
executeAllOnBlankLine: "Boş sətirlərdən bütün SQL-i icra et",
executeAllOnBlankLineDescription: "Cari əmr rejimində kursorun olduğu yerdə əmr olmadıqda bütün SQL-i icra et. Standart olaraq deaktivdir.",
showExecutionTargetPicker: "İcra hədəfi seçicisini göstər",
+5
View File
@@ -380,6 +380,8 @@ export default {
commit: "Commit",
rollback: "Rollback",
txnAutoRolledBack: "Transaction auto-rolled back after 5 minutes of inactivity",
autoCommitTxnRolledBack: 'An explicit transaction left open by this tab was rolled back automatically; turn on "Keep explicit auto-commit transactions" to keep it across executions',
autoCommitSessionTxnRolledBack: "This connection runs with auto-commit off (SET autocommit = 0), so its implicit transaction was rolled back after the execution; DBX reports that once per connection",
previewSql: "Preview SQL",
previewQuery: "Preview query",
hidePreviewSql: "Hide SQL Preview",
@@ -7978,6 +7980,9 @@ export default {
'The transaction mode used by default when a new SQL editor is opened. With "Manual transaction", new editors start in manual transaction mode, requiring manual commit or rollback; with "Auto commit", each statement is committed automatically. Only applies to databases that support transactions.',
defaultTransactionModeAuto: "Auto commit",
defaultTransactionModeManual: "Manual transaction",
keepExplicitTransactionInAutoCommit: "Keep explicit auto-commit transactions",
keepExplicitTransactionInAutoCommitDescription:
"After a BEGIN / START TRANSACTION in an auto-commit tab, keep the transaction open until you run COMMIT or ROLLBACK. When this is off, each execution rolls back the explicit transaction it left open, which stops a leftover transaction from pinning the tab's read view.",
executeAllOnBlankLine: "Execute all SQL from blank lines",
executeAllOnBlankLineDescription: "In current-statement mode, execute all SQL when there is no statement at the cursor. Disabled by default.",
showExecutionTargetPicker: "Show execution target picker",
+5
View File
@@ -371,6 +371,8 @@ export default withEnglishFallback({
commit: "Confirmar",
rollback: "Revertir",
txnAutoRolledBack: "Transacción revertida automáticamente tras 5 minutos de inactividad",
autoCommitTxnRolledBack: 'La transacción explícita que esta pestaña dejó abierta se revirtió automáticamente; activa "Mantener transacciones explícitas en modo autocommit" para conservarla entre ejecuciones',
autoCommitSessionTxnRolledBack: "Esta conexión trabaja con autocommit desactivado (SET autocommit = 0), así que su transacción implícita se revirtió tras la ejecución; DBX lo avisa una vez por conexión",
previewSql: "Vista previa de SQL",
previewQuery: "Vista previa de la consulta",
hidePreviewSql: "Ocultar vista previa de SQL",
@@ -7334,6 +7336,9 @@ export default withEnglishFallback({
'El modo de transacción que se usa de forma predeterminada al abrir un nuevo editor SQL. Con "Transacción manual", los nuevos editores inician en modo de transacción manual y requieren confirmación o reversión manual; con "Confirmación automática", cada sentencia se confirma automáticamente. Solo se aplica a bases de datos que admiten transacciones.',
defaultTransactionModeAuto: "Confirmación automática",
defaultTransactionModeManual: "Transacción manual",
keepExplicitTransactionInAutoCommit: "Mantener transacciones explícitas en modo autocommit",
keepExplicitTransactionInAutoCommitDescription:
"Tras ejecutar BEGIN / START TRANSACTION en una pestaña de autocommit, la transacción se mantiene hasta que ejecutes COMMIT o ROLLBACK. Si está desactivado, cada ejecución revierte la transacción explícita que quede abierta, lo que evita que una transacción residual fije la instantánea de lectura de la pestaña.",
executeAllOnBlankLine: "Ejecutar todo el SQL desde líneas en blanco",
executeAllOnBlankLineDescription: "En el modo de sentencia actual, ejecuta todo el SQL cuando no hay una sentencia en el cursor. Desactivado de forma predeterminada.",
showExecutionTargetPicker: "Mostrar selector de objetivo",
+5
View File
@@ -370,6 +370,8 @@ export default withEnglishFallback({
commit: "Commit",
rollback: "Rollback",
txnAutoRolledBack: "Transazione annullata automaticamente dopo 5 minuti di inattività",
autoCommitTxnRolledBack: 'La transazione esplicita lasciata aperta da questa scheda è stata annullata automaticamente; attiva "Mantieni le transazioni esplicite in autocommit" per conservarla tra le esecuzioni',
autoCommitSessionTxnRolledBack: "Questa connessione lavora con autocommit disattivato (SET autocommit = 0), quindi la sua transazione implicita è stata annullata dopo l'esecuzione; DBX lo segnala una volta per connessione",
previewSql: "Anteprima SQL",
previewQuery: "Anteprima query",
hidePreviewSql: "Nascondi anteprima SQL",
@@ -7333,6 +7335,9 @@ export default withEnglishFallback({
'La modalità di transazione utilizzata per impostazione predefinita quando si apre un nuovo editor SQL. Con "Transazione manuale", i nuovi editori iniziano in modalità di transazione manuale, richiedendo commit o rollback manuale; con "Commit automatico", ogni istruzione viene committata automaticamente. Si applica solo ai database che supportano le transazioni.',
defaultTransactionModeAuto: "Commit automatico",
defaultTransactionModeManual: "Transazione manuale",
keepExplicitTransactionInAutoCommit: "Mantieni le transazioni esplicite in autocommit",
keepExplicitTransactionInAutoCommitDescription:
"Dopo aver eseguito BEGIN / START TRANSACTION in una scheda autocommit, la transazione resta aperta finché non esegui COMMIT o ROLLBACK. Se disattivato, ogni esecuzione annulla la transazione esplicita lasciata aperta, evitando che una transazione residua fissi lo snapshot di lettura della scheda.",
executeAllOnBlankLine: "Esegui tutto l'SQL dalle righe vuote",
executeAllOnBlankLineDescription: "In modalità istruzione corrente, esegue tutto l'SQL quando non è presente un'istruzione al cursore. Disattivato per impostazione predefinita.",
showExecutionTargetPicker: "Mostra selettore destinazione",
+5
View File
@@ -371,6 +371,8 @@ export default withEnglishFallback({
commit: "コミット",
rollback: "ロールバック",
txnAutoRolledBack: "5分間操作がなかったため、トランザクションは自動的にロールバックされました",
autoCommitTxnRolledBack: "このタブに残っていた明示的トランザクションは自動的にロールバックされました。実行をまたいで保持するには設定の「自動コミットでも明示的なトランザクションを保持」を有効にしてください",
autoCommitSessionTxnRolledBack: "この接続は autocommit=0 で動作しているため、実行後に暗黙のトランザクションがロールバックされました(同じ接続では一度だけ通知します)",
previewSql: "SQLをプレビュー",
previewQuery: "クエリをプレビュー",
hidePreviewSql: "SQLプレビューを非表示",
@@ -7356,6 +7358,9 @@ export default withEnglishFallback({
"新しい SQL エディタを開いたときに既定で使用するトランザクションモード。「手動トランザクション」を選択すると、新しいエディタは手動トランザクションモードで開始され、手動でコミットまたはロールバックします。「自動コミット」を選択すると、各ステートメントが自動的にコミットされます。トランザクションをサポートするデータベースにのみ適用されます。",
defaultTransactionModeAuto: "自動コミット",
defaultTransactionModeManual: "手動トランザクション",
keepExplicitTransactionInAutoCommit: "自動コミットでも明示的なトランザクションを保持",
keepExplicitTransactionInAutoCommitDescription:
"自動コミットのタブで BEGIN / START TRANSACTION を実行した後、COMMIT または ROLLBACK を実行するまでトランザクションを保持します。オフの場合、実行が終わるたびに未確定の明示的トランザクションがロールバックされ、残留トランザクションがタブの読み取りスナップショットを固定するのを防ぎます。",
executeAllOnBlankLine: "空行ではすべてのSQLを実行",
executeAllOnBlankLineDescription: "現在の文を実行するモードで、カーソル位置に文がない場合はすべてのSQLを実行します。既定では無効です。",
showExecutionTargetPicker: "実行対象ピッカーを表示",
+4
View File
@@ -379,6 +379,8 @@ export default withEnglishFallback({
commit: "커밋",
rollback: "롤백",
txnAutoRolledBack: "5분간 활동이 없어 트랜잭션이 자동으로 롤백되었습니다",
autoCommitTxnRolledBack: '이 탭에 남아 있던 명시적 트랜잭션이 자동으로 롤백되었습니다. 실행 간에 유지하려면 설정에서 "자동 커밋 모드에서 명시적 트랜잭션 유지"를 켜세요',
autoCommitSessionTxnRolledBack: "이 연결은 autocommit=0 상태이므로 실행 후 암시적 트랜잭션이 롤백되었습니다(같은 연결에서는 한 번만 알립니다)",
previewSql: "SQL 미리보기",
previewQuery: "쿼리 미리보기",
hidePreviewSql: "SQL 미리보기 숨기기",
@@ -7119,6 +7121,8 @@ export default withEnglishFallback({
'새 SQL 편집기를 열 때 기본적으로 사용되는 트랜잭션 모드입니다. "수동 트랜잭션"을 선택하면 새 편집기가 수동 트랜잭션 모드로 시작되어 수동으로 커밋 또는 롤백해야 합니다. "자동 커밋"을 선택하면 각 문이 자동으로 커밋됩니다. 트랜잭션을 지원하는 데이터베이스에만 적용됩니다.',
defaultTransactionModeAuto: "자동 커밋",
defaultTransactionModeManual: "수동 트랜잭션",
keepExplicitTransactionInAutoCommit: "자동 커밋 모드에서 명시적 트랜잭션 유지",
keepExplicitTransactionInAutoCommitDescription: "자동 커밋 탭에서 BEGIN / START TRANSACTION을 실행하면 COMMIT 또는 ROLLBACK을 실행할 때까지 트랜잭션을 유지합니다. 끄면 실행이 끝날 때마다 커밋되지 않은 명시적 트랜잭션이 롤백되어, 남은 트랜잭션이 탭의 읽기 스냅샷을 고정하는 것을 막습니다.",
executeAllOnBlankLine: "빈 줄에서 모든 SQL 실행",
executeAllOnBlankLineDescription: "현재 구문 실행 모드에서 커서 위치에 구문이 없으면 모든 SQL을 실행합니다. 기본적으로 비활성화됩니다.",
showExecutionTargetPicker: "실행 대상 선택기 표시",
+5
View File
@@ -371,6 +371,8 @@ export default withEnglishFallback({
commit: "Confirmar",
rollback: "Reverter",
txnAutoRolledBack: "Transação revertida automaticamente após 5 minutos de inatividade",
autoCommitTxnRolledBack: 'A transação explícita deixada aberta por esta aba foi revertida automaticamente; ative "Manter transações explícitas no autocommit" para mantê-la entre execuções',
autoCommitSessionTxnRolledBack: "Esta conexão está com o autocommit desativado (SET autocommit = 0), então sua transação implícita foi revertida após a execução; o DBX avisa uma vez por conexão",
previewSql: "Visualizar SQL",
previewQuery: "Visualizar consulta",
hidePreviewSql: "Ocultar Visualização SQL",
@@ -7335,6 +7337,9 @@ export default withEnglishFallback({
'O modo de transação usado por padrão ao abrir um novo editor SQL. Com "Transação manual", novos editores iniciam no modo de transação manual, exigindo confirmação ou reversão manual; com "Confirmação automática", cada instrução é confirmada automaticamente. Aplica-se apenas a bancos de dados que oferecem suporte a transações.',
defaultTransactionModeAuto: "Confirmação automática",
defaultTransactionModeManual: "Transação manual",
keepExplicitTransactionInAutoCommit: "Manter transações explícitas no autocommit",
keepExplicitTransactionInAutoCommitDescription:
"Depois de executar BEGIN / START TRANSACTION em uma aba de autocommit, a transação permanece aberta até você executar COMMIT ou ROLLBACK. Quando desativado, cada execução reverte a transação explícita deixada aberta, evitando que uma transação residual fixe o snapshot de leitura da aba.",
executeAllOnBlankLine: "Executar todo o SQL em linhas em branco",
executeAllOnBlankLineDescription: "No modo de instrução atual, executa todo o SQL quando não há instrução no cursor. Desativado por padrão.",
showExecutionTargetPicker: "Mostrar seletor de destino",
+5
View File
@@ -216,6 +216,8 @@ export default withEnglishFallback({
commit: "Commit",
rollback: "Rollback",
txnAutoRolledBack: "İşlem, 5 dakikalık hareketsizlik sonrası otomatik olarak geri alındı",
autoCommitTxnRolledBack: 'Bu sekmenin açık bıraktığı açık işlem otomatik olarak geri alındı; yürütmeler arasında korumak için ayarlardan "Otomatik işlemede açık işlemleri koru" seçeneğini açın',
autoCommitSessionTxnRolledBack: "Bu bağlantı autocommit kapalı (SET autocommit = 0) çalışıyor; bu nedenle örtük işlem yürütmeden sonra geri alındı. DBX bunu bağlantı başına bir kez bildirir",
previewSql: "SQL Önizleme",
previewQuery: "Sorguyu önizle",
hidePreviewSql: "SQL Önizlemesini Gizle",
@@ -7376,6 +7378,9 @@ export default withEnglishFallback({
'Yeni bir SQL düzenleyici açıldığında varsayılan olarak kullanılan işlem modu. "Manuel işlem" ile yeni düzenleyiciler manuel işlem modunda başlar ve elle commit ya da rollback gerektirir; "Otomatik commit" ile her ifade otomatik olarak uygulanır. Yalnızca işlemleri destekleyen veritabanları için geçerlidir.',
defaultTransactionModeAuto: "Otomatik commit",
defaultTransactionModeManual: "Manuel işlem",
keepExplicitTransactionInAutoCommit: "Otomatik işlemede açık işlemleri koru",
keepExplicitTransactionInAutoCommitDescription:
"Otomatik işlem (autocommit) sekmesinde BEGIN / START TRANSACTION çalıştırdıktan sonra işlem, siz COMMIT veya ROLLBACK çalıştırana kadar açık kalır. Kapalıyken her yürütme, açık kalan açık işlemi geri alır; böylece kalan bir işlemin sekmenin okuma anlık görüntüsünü sabitlemesi engellenir.",
executeAllOnBlankLine: "Boş satırlarda tüm SQL'i çalıştır",
executeAllOnBlankLineDescription: "Geçerli ifade modunda, imleçte bir ifade yoksa tüm SQL'i çalıştır. Varsayılan olarak kapalıdır.",
showExecutionTargetPicker: "Yürütme hedefi seçicisini göster",
+4
View File
@@ -296,6 +296,8 @@ export default withEnglishFallback({
commit: "提交",
rollback: "回滚",
txnAutoRolledBack: "事务已因 5 分钟无操作而自动回滚",
autoCommitTxnRolledBack: "该标签页遗留的显式事务已被自动回滚;如需跨多次执行保留事务,可在设置中开启「自动提交模式下保留显式事务」",
autoCommitSessionTxnRolledBack: "该连接处于 autocommit=0,本次执行留下的隐式事务已被自动回滚;同一连接只提示一次",
previewSql: "预览 SQL",
previewQuery: "预览查询",
hidePreviewSql: "隐藏 SQL 预览",
@@ -7948,6 +7950,8 @@ export default withEnglishFallback({
defaultTransactionModeDescription: "新建 SQL 编辑器时默认采用的事务提交方式。选择“手动事务”后,新打开的编辑器将以手动事务模式开始,需手动提交或回滚;选择“自动提交”则每条语句自动提交。仅对支持事务的数据库生效。",
defaultTransactionModeAuto: "自动提交",
defaultTransactionModeManual: "手动事务",
keepExplicitTransactionInAutoCommit: "自动提交模式下保留显式事务",
keepExplicitTransactionInAutoCommitDescription: "在自动提交标签页执行 BEGIN / START TRANSACTION 后,事务会一直保留到你执行 COMMIT 或 ROLLBACK。关闭时,每次执行结束都会自动回滚未提交的显式事务,以避免遗留事务把该标签页的读取快照钉住。",
executeAllOnBlankLine: "空白位置执行全部 SQL",
executeAllOnBlankLineDescription: "在“执行光标所在语句”模式下,光标没有对应语句时执行全部 SQL。默认关闭。",
showExecutionTargetPicker: "显示执行目标选择器",
+4
View File
@@ -374,6 +374,8 @@ export default withEnglishFallback({
commit: "提交",
rollback: "回溯",
txnAutoRolledBack: "事務已因 5 分鐘無操作而自動回溯",
autoCommitTxnRolledBack: "該分頁遺留的顯式交易已被自動回溯;如需跨多次執行保留交易,可在設定中開啟「自動提交模式下保留顯式交易」",
autoCommitSessionTxnRolledBack: "該連線處於 autocommit=0,本次執行留下的隱式交易已被自動回溯;同一連線只提示一次",
previewSql: "預覽 SQL",
previewQuery: "預覽查詢",
hidePreviewSql: "隱藏 SQL 預覽",
@@ -6650,6 +6652,8 @@ export default withEnglishFallback({
defaultTransactionModeDescription: "新增 SQL 編輯器時預設採用的交易提交方式。選擇「手動交易」後,新開啟的編輯器將以手動交易模式開始,需手動提交或回滾;選擇「自動提交」則每條語句自動提交。僅對支援交易的資料庫生效。",
defaultTransactionModeAuto: "自動提交",
defaultTransactionModeManual: "手動交易",
keepExplicitTransactionInAutoCommit: "自動提交模式下保留顯式交易",
keepExplicitTransactionInAutoCommitDescription: "在自動提交分頁執行 BEGIN / START TRANSACTION 後,交易會保留到你執行 COMMIT 或 ROLLBACK。關閉時,每次執行結束都會自動回溯未提交的顯式交易,以避免遺留交易把該分頁的讀取快照固定住。",
executeAllOnBlankLine: "空白位置執行全部 SQL",
executeAllOnBlankLineDescription: "在「執行游標所在語句」模式下,游標沒有對應語句時執行全部 SQL。預設關閉。",
showExecutionTargetPicker: "顯示執行目標選擇器",
+5
View File
@@ -1382,6 +1382,10 @@ export async function executeMulti(
useTransaction?: boolean;
continueOnError?: boolean;
executionMode?: "simple";
/** MySQL auto-commit tabs: keep a transaction the user opened explicitly
* (`BEGIN` / `START TRANSACTION`) open across executions until COMMIT /
* ROLLBACK instead of rolling it back when the batch ends. */
preserveExplicitTransaction?: boolean;
},
): Promise<QueryResult[]> {
return postQueryWithDiagnostics(
@@ -1430,6 +1434,7 @@ export async function executeMultiWithProgress(
useTransaction?: boolean;
continueOnError?: boolean;
executionMode?: "simple";
preserveExplicitTransaction?: boolean;
executionId?: string;
},
): Promise<QueryResult[]> {
+5
View File
@@ -1618,6 +1618,10 @@ export async function executeMulti(
useTransaction?: boolean;
continueOnError?: boolean;
executionMode?: "simple";
/** MySQL auto-commit tabs: keep a transaction the user opened explicitly
* (`BEGIN` / `START TRANSACTION`) open across executions until COMMIT /
* ROLLBACK instead of rolling it back when the batch ends. */
preserveExplicitTransaction?: boolean;
},
): Promise<QueryResult[]> {
const diagnosticsEnabled = isDebugLoggingEnabled();
@@ -1684,6 +1688,7 @@ export async function executeMultiWithProgress(
useTransaction?: boolean;
continueOnError?: boolean;
executionMode?: "simple";
preserveExplicitTransaction?: boolean;
executionId?: string;
},
): Promise<QueryResult[]> {
@@ -129,6 +129,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"clickTableNavigationTarget",
"completionTriggerMode",
"defaultTransactionMode",
"keepExplicitTransactionInAutoCommit",
] as const satisfies readonly (keyof EditorSettings)[];
export type EditorSettingsDraftKey = (typeof EDITOR_SETTINGS_DRAFT_KEYS)[number];
@@ -75,6 +75,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readon
editor: [
"executeMode",
"defaultTransactionMode",
"keepExplicitTransactionInAutoCommit",
"executeAllOnBlankLine",
"showExecutionTargetPicker",
"showStatementRunButtons",
@@ -0,0 +1,196 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
analyzeEditableQueryEditability: vi.fn(),
beginManualTransaction: vi.fn(),
closeClientConnectionSession: vi.fn(),
closeQuerySession: vi.fn(),
commitManualTransaction: vi.fn(),
executeInManualTransaction: vi.fn(),
executeMulti: vi.fn(),
getConnectionConfig: vi.fn(),
prepareQueryPaginationExecutionPlan: vi.fn(),
rollbackManualTransaction: vi.fn(),
saveOpenTabsState: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
analyzeEditableQueryEditability: mocks.analyzeEditableQueryEditability,
beginManualTransaction: mocks.beginManualTransaction,
closeClientConnectionSession: mocks.closeClientConnectionSession,
closeQuerySession: mocks.closeQuerySession,
commitManualTransaction: mocks.commitManualTransaction,
executeInManualTransaction: mocks.executeInManualTransaction,
executeMulti: mocks.executeMulti,
prepareQueryPaginationExecutionPlan: mocks.prepareQueryPaginationExecutionPlan,
rollbackManualTransaction: mocks.rollbackManualTransaction,
saveOpenTabsState: mocks.saveOpenTabsState,
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
getConfig: mocks.getConnectionConfig,
recordConnectionLostError: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: {
autoCalculateTotalRows: false,
continueOnErrorOnBatch: false,
pageSize: 100,
queryResultMaxRowsEnabled: false,
queryResultMaxRows: 1000,
openTabsRestoreMode: "all",
confirmUnsavedSqlClose: false,
},
}),
}));
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
type AutoCommitRolledBackTab = {
autoCommitOpenTransaction?: boolean;
autoCommitTxnRolledBack?: boolean;
autoCommitSessionTxnRolledBack?: boolean;
autoCommitSessionTxnRolledBackNotified?: boolean;
};
function emptyTab(): AutoCommitRolledBackTab {
return {};
}
describe("applyAutoCommitTransactionReport", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("mirrors the open-transaction marker the backend reported", async () => {
const { applyAutoCommitTransactionReport } = await import("@/stores/queryStore");
const tab = emptyTab();
applyAutoCommitTransactionReport(tab as never, [{ auto_commit_open_transaction: true }] as never);
expect(tab.autoCommitOpenTransaction).toBe(true);
applyAutoCommitTransactionReport(tab as never, [{ auto_commit_open_transaction: false }] as never);
expect(tab.autoCommitOpenTransaction).toBe(false);
});
it("keeps reporting an explicitly opened transaction that was rolled back", async () => {
const { applyAutoCommitTransactionReport } = await import("@/stores/queryStore");
const tab = emptyTab();
applyAutoCommitTransactionReport(tab as never, [{ auto_commit_explicit_transaction_rolled_back: true }] as never);
expect(tab.autoCommitTxnRolledBack).toBe(true);
// The user dismisses the notice; the next execution that rolls back another
// explicit transaction must raise it again — the tab lost real work.
tab.autoCommitTxnRolledBack = false;
applyAutoCommitTransactionReport(tab as never, [{ auto_commit_explicit_transaction_rolled_back: true }] as never);
expect(tab.autoCommitTxnRolledBack).toBe(true);
});
it("reports a session-level implicit rollback once per connection", async () => {
const { applyAutoCommitTransactionReport } = await import("@/stores/queryStore");
const tab = emptyTab();
applyAutoCommitTransactionReport(tab as never, [{ auto_commit_session_autocommit_rolled_back: true }] as never);
expect(tab.autoCommitSessionTxnRolledBack).toBe(true);
expect(tab.autoCommitTxnRolledBack).toBeUndefined();
// `SET autocommit = 0` rolls back an implicit transaction after *every*
// execution; dismissing the notice must not bring it straight back.
tab.autoCommitSessionTxnRolledBack = false;
applyAutoCommitTransactionReport(tab as never, [{ auto_commit_session_autocommit_rolled_back: true }] as never);
expect(tab.autoCommitSessionTxnRolledBack).toBe(false);
// Auto-commit is back on (or the execution never used the tab connection):
// the next auto-commit-off session is reported again.
applyAutoCommitTransactionReport(tab as never, [{ auto_commit_open_transaction: false }] as never);
applyAutoCommitTransactionReport(tab as never, [{ auto_commit_session_autocommit_rolled_back: true }] as never);
expect(tab.autoCommitSessionTxnRolledBack).toBe(true);
});
it("prefers the explicit notice when a batch carries both markers", async () => {
const { applyAutoCommitTransactionReport } = await import("@/stores/queryStore");
const tab = emptyTab();
applyAutoCommitTransactionReport(tab as never, [{ auto_commit_explicit_transaction_rolled_back: true, auto_commit_session_autocommit_rolled_back: true }] as never);
expect(tab.autoCommitTxnRolledBack).toBe(true);
expect(tab.autoCommitSessionTxnRolledBack).toBeUndefined();
});
});
describe("auto-commit tab transaction actions", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
mocks.getConnectionConfig.mockReturnValue({
id: "mysql-1",
name: "MySQL",
db_type: "mysql",
database: "dbx",
query_timeout_secs: 30,
});
mocks.prepareQueryPaginationExecutionPlan.mockImplementation(async (options) => ({
sqlToExecute: options.sql,
pageSql: undefined,
pageLimit: undefined,
pageOffset: undefined,
countSql: undefined,
useAgentResultSession: false,
}));
mocks.analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "not-select" });
mocks.saveOpenTabsState.mockResolvedValue(undefined);
mocks.executeMulti.mockResolvedValue({
results: [{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1, auto_commit_open_transaction: false }],
});
});
async function setupAutoCommitTabWithKeptTransaction() {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "dbx", "Query", "query");
const tab = store.tabs.find((item) => item.id === tabId);
if (!tab) throw new Error("tab missing");
// The backend reported a kept explicit transaction on this tab connection.
tab.autoCommitOpenTransaction = true;
tab.autoCommitSessionTxnRolledBack = true;
tab.autoCommitSessionTxnRolledBackNotified = true;
return { store, tabId, tab };
}
it("commits a kept auto-commit transaction as an ordinary statement", async () => {
const { store, tabId } = await setupAutoCommitTabWithKeptTransaction();
await store.commitTransaction(tabId);
expect(mocks.executeMulti).toHaveBeenCalledOnce();
expect(mocks.executeMulti.mock.calls[0][2]).toBe("COMMIT");
});
it("rolls back a kept auto-commit transaction as an ordinary statement", async () => {
const { store, tabId } = await setupAutoCommitTabWithKeptTransaction();
await store.rollbackTransaction(tabId);
expect(mocks.executeMulti).toHaveBeenCalledOnce();
expect(mocks.executeMulti.mock.calls[0][2]).toBe("ROLLBACK");
});
it("drops the session-level notice when the tab switches connection", async () => {
const { store, tabId, tab } = await setupAutoCommitTabWithKeptTransaction();
store.updateConnection(tabId, "mysql-2", "dbx");
expect(tab.autoCommitSessionTxnRolledBack).toBeUndefined();
expect(tab.autoCommitSessionTxnRolledBackNotified).toBeUndefined();
});
});
+60 -2
View File
@@ -904,6 +904,39 @@ function getI18nT() {
}
}
/** Mirrors the MySQL auto-commit settlement the backend reported for this
* execution onto the tab. Absent markers (non-MySQL connections, executions
* that never touched a tab connection) leave the previous state untouched.
*
* The two rollback notices are deliberately different: a `BEGIN` the user
* typed is reported on every execution (the tab just lost real work), while a
* session sitting on `SET autocommit = 0` has an implicit transaction rolled
* back after *every* execution — that one is raised once per connection and
* re-armed only after the connection stops reporting it, so dismissing it
* does not bring it back on the next keystroke batch. */
export function applyAutoCommitTransactionReport(tab: QueryTab, results: QueryResult[]) {
const openTransaction = results.find((result) => result.auto_commit_open_transaction !== undefined)?.auto_commit_open_transaction;
if (openTransaction !== undefined) tab.autoCommitOpenTransaction = openTransaction;
const explicitRolledBack = results.some((result) => result.auto_commit_explicit_transaction_rolled_back === true);
const sessionRolledBack = results.some((result) => result.auto_commit_session_autocommit_rolled_back === true);
if (explicitRolledBack) {
tab.autoCommitTxnRolledBack = true;
tab.autoCommitSessionTxnRolledBackNotified = false;
return;
}
if (sessionRolledBack) {
if (!tab.autoCommitSessionTxnRolledBackNotified) {
tab.autoCommitSessionTxnRolledBack = true;
tab.autoCommitSessionTxnRolledBackNotified = true;
}
return;
}
// This execution rolled nothing back: the auto-commit-off session may be gone
// (or the execution never used the tab connection), so re-arm the notice for
// the next time it happens.
tab.autoCommitSessionTxnRolledBackNotified = false;
}
export const useQueryStore = defineStore("query", () => {
const redisMonitors = new Map<string, () => void>();
const t = getI18nT();
@@ -4881,6 +4914,16 @@ export const useQueryStore = defineStore("query", () => {
if (tab.txnPossiblyDirty !== undefined) tab.txnPossiblyDirty = false;
}
/** Auto-commit tabs mirror the backend's report of an open explicit
* transaction. The flag is dropped whenever the tab stops pointing at the
* connection that reported it (target switch, tab close), so a stale badge
* can never outlive the session it describes. */
function clearAutoCommitOpenTransaction(tab: { autoCommitOpenTransaction?: boolean; autoCommitSessionTxnRolledBack?: boolean; autoCommitSessionTxnRolledBackNotified?: boolean }) {
if (tab.autoCommitOpenTransaction !== undefined) tab.autoCommitOpenTransaction = false;
tab.autoCommitSessionTxnRolledBack = undefined;
tab.autoCommitSessionTxnRolledBackNotified = undefined;
}
/** Centralized manual-session cleanup. Clears every field tied to a manual
* transaction session exactly when that session is conclusively ended or
* discarded. Callers must not assign these fields individually. */
@@ -4903,11 +4946,19 @@ export const useQueryStore = defineStore("query", () => {
}
clearTxnPossiblyDirty(tab);
tab.txnAutoRolledBack = false;
clearAutoCommitOpenTransaction(tab);
}
async function commitTransaction(id: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab?.txnSessionId) return;
if (!tab) return;
if (!tab.txnSessionId) {
// Auto-commit tab (`Tx:A`) that keeps explicit user transactions: the
// transaction lives on the tab's own connection, so COMMIT is an ordinary
// statement on that connection.
if (tab.autoCommitOpenTransaction) await executeCurrentSql("COMMIT", { tabId: tab.id });
return;
}
try {
await api.commitManualTransaction(tab.txnSessionId);
} finally {
@@ -4917,7 +4968,11 @@ export const useQueryStore = defineStore("query", () => {
async function rollbackTransaction(id: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab?.txnSessionId) return;
if (!tab) return;
if (!tab.txnSessionId) {
if (tab.autoCommitOpenTransaction) await executeCurrentSql("ROLLBACK", { tabId: tab.id });
return;
}
const sessionId = tab.txnSessionId;
// Remove the old session before the backend responds: a target switch may
// start a new transaction while this rollback is still in flight.
@@ -7343,6 +7398,8 @@ export const useQueryStore = defineStore("query", () => {
timeoutSecs: queryTimeoutSecs,
catalog: executionCatalog,
continueOnError: continueOnBatchError,
// MySQL-family connections only use this; other drivers ignore it.
...(settingsStore.editorSettings.keepExplicitTransactionInAutoCommit ? { preserveExplicitTransaction: true } : {}),
};
queryExecutionLog("info", "execute-multi:invoke", {
traceId,
@@ -7527,6 +7584,7 @@ export const useQueryStore = defineStore("query", () => {
console.warn("[DBX] Failed to resolve SAP HANA CURRENT_SCHEMA", error);
}
}
if (tab.autoCommit !== false) applyAutoCommitTransactionReport(tab, results);
const current = findExecutionTab(id);
if (current?.executionId === executionId && manualTransactionTargetEpoch(current) === executionTargetEpoch) {
if (captureResultRun && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
+9
View File
@@ -945,6 +945,12 @@ export interface EditorSettings {
clickTableNavigationTarget: ClickTableNavigationTarget;
completionTriggerMode: SqlCompletionTriggerMode;
defaultTransactionMode: DefaultTransactionMode;
/** Auto-commit (`Tx:A`) tabs with a MySQL-family connection: keep a
* transaction the user opens explicitly (`BEGIN` / `START TRANSACTION`) open
* across executions until COMMIT / ROLLBACK instead of rolling it back when
* each execution ends. Off by default: the rollback is what stops a leftover
* transaction from pinning the tab's read snapshot (#9479). */
keepExplicitTransactionInAutoCommit: boolean;
}
export interface ToolbarItems {
@@ -1202,6 +1208,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
clickTableNavigationTarget: "data",
completionTriggerMode: "positional",
defaultTransactionMode: "auto",
keepExplicitTransactionInAutoCommit: false,
};
export const STORAGE_KEY = "dbx-editor-settings";
@@ -1790,6 +1797,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
clickTableNavigationTarget: normalizeClickTableNavigationTarget(settings.clickTableNavigationTarget),
completionTriggerMode: normalizeCompletionTriggerMode(settings.completionTriggerMode),
defaultTransactionMode: normalizeDefaultTransactionMode(settings.defaultTransactionMode),
keepExplicitTransactionInAutoCommit: settings.keepExplicitTransactionInAutoCommit === true,
backgroundImage: normalizeBackgroundImageSettings(settings.backgroundImage),
};
}
@@ -2542,6 +2550,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.clickTableNavigationTarget !== undefined) editorSettings.value.clickTableNavigationTarget = normalizeClickTableNavigationTarget(partial.clickTableNavigationTarget);
if (partial.completionTriggerMode !== undefined) editorSettings.value.completionTriggerMode = normalizeCompletionTriggerMode(partial.completionTriggerMode);
if (partial.defaultTransactionMode !== undefined) editorSettings.value.defaultTransactionMode = normalizeDefaultTransactionMode(partial.defaultTransactionMode);
if (partial.keepExplicitTransactionInAutoCommit !== undefined) editorSettings.value.keepExplicitTransactionInAutoCommit = partial.keepExplicitTransactionInAutoCommit === true;
if (partial.flatteningMultiLineText !== undefined) editorSettings.value.flatteningMultiLineText = partial.flatteningMultiLineText;
if (partial.dataGridShowWhitespace !== undefined) editorSettings.value.dataGridShowWhitespace = partial.dataGridShowWhitespace;
}
+34
View File
@@ -1118,6 +1118,21 @@ export interface QueryResult {
/** Manual-transaction UX marker for the same dialects: set on the synthetic
* successful result of an empty/whitespace/comments-only manual script. */
manual_transaction_no_statement?: true;
/** MySQL auto-commit tab session state reported by the backend for this
* execution: true = the tab connection still holds a transaction the user
* opened explicitly (`BEGIN` / `START TRANSACTION`) and DBX kept it open;
* false = the backend settled the connection and no such transaction is
* open. Absent when the execution never observed a tab-scoped MySQL
* connection, so the tab must keep its previous state. */
auto_commit_open_transaction?: boolean;
/** MySQL auto-commit tab: the backend rolled back a transaction the user
* opened explicitly and left open (the tab did not opt into keeping them). */
auto_commit_explicit_transaction_rolled_back?: true;
/** MySQL auto-commit tab: the backend rolled back a transaction the session
* opened implicitly because auto-commit was off (`SET autocommit = 0`).
* Nobody typed `BEGIN`, so the tab reports it separately — and only once per
* connection instead of after every execution. */
auto_commit_session_autocommit_rolled_back?: true;
/** Structured backend error; authoritative when execution_error is true. */
error?: BackendError;
/** Zero-based index of the submitted statement that produced this result. */
@@ -2004,6 +2019,25 @@ export interface QueryTab {
* statement DBX cannot prove read-only. Commit/Rollback actions are hidden
* while a session is clean. Never cleared by a later read. */
txnPossiblyDirty?: boolean;
/** Auto-commit tabs (`Tx:A`) with a MySQL-family connection: whether the tab's
* connection currently holds a transaction the user opened explicitly
* (`BEGIN` / `START TRANSACTION`). The backend reports it on every execution
* that observed the connection; the tab mirrors it into the `Tx` badge and
* the commit/rollback actions. Not persisted. */
autoCommitOpenTransaction?: boolean;
/** Auto-commit tab: show the notice that the backend rolled back an explicit
* transaction this tab left open, so the cleanup is never silent. */
autoCommitTxnRolledBack?: boolean;
/** Same cleanup, but the rolled-back transaction came from a session with
* auto-commit turned off (`SET autocommit = 0`) rather than from a `BEGIN`
* the user typed. Shown with its own wording so the notice is not mistaken
* for a lost explicit transaction. */
autoCommitSessionTxnRolledBack?: boolean;
/** Dedupe marker for {@link autoCommitSessionTxnRolledBack}: an
* auto-commit-off session rolls back an implicit transaction after *every*
* execution, so the notice is raised once and re-armed only after the
* connection stops reporting that rollback. */
autoCommitSessionTxnRolledBackNotified?: boolean;
}
export interface SavedSqlFolder {
+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();
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

+2
View File
@@ -193,6 +193,7 @@ pub async fn execute_multi(
use_transaction: Option<bool>,
continue_on_error: Option<bool>,
execution_mode: Option<dbx_core::query::QueryExecutionMode>,
preserve_explicit_transaction: Option<bool>,
) -> Result<Vec<dbx_core::query::ExecuteMultiResult>, BackendError> {
let execution_id = execution_id.filter(|id| !id.trim().is_empty());
let registered_query = execution_id.as_ref().map(|id| {
@@ -256,6 +257,7 @@ pub async fn execute_multi(
use_transaction,
continue_on_error: continue_on_error.unwrap_or(false),
execution_mode: execution_mode.unwrap_or_default(),
preserve_explicit_transaction: preserve_explicit_transaction.unwrap_or(false),
},
progress,
)