From 4c1809f0c7bf80bceb578b7753d8102eafb8f239 Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 22 Sep 2026 21:13:27 +0800 Subject: [PATCH] fix(editor): skip idle rollback notice for read-only manual transactions --- ...queryStore.manualTransactionExpiry.spec.ts | 49 +++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 25 ++++++++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts index fb53764296..a1246e3de0 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts @@ -76,6 +76,10 @@ function successfulSelect() { return [{ columns: ["VALUE"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]; } +function successfulReadOnlySelect() { + return [{ columns: ["VALUE"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, manual_transaction_proven_read_only: true }]; +} + describe("queryStore manual transaction expiry recovery", () => { beforeEach(() => { vi.clearAllMocks(); @@ -125,6 +129,51 @@ describe("queryStore manual transaction expiry recovery", () => { expect(tab.result?.affected_rows).toBe(1); }); + it("restarts an expired read-only transaction without the rollback notice", async () => { + mocks.beginManualTransaction.mockResolvedValueOnce("txn-old").mockResolvedValueOnce("txn-new"); + mocks.executeInManualTransaction.mockResolvedValueOnce(successfulReadOnlySelect()).mockRejectedValueOnce(expiredTransactionError()).mockResolvedValueOnce(successfulReadOnlySelect()); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("oracle-1", "ORCL", "Query", "query", "APP"); + store.setAutoCommit(tabId, false); + + await store.executeTabSql(tabId, "SELECT VALUE FROM DUAL"); + await store.executeTabSql(tabId, "SELECT VALUE FROM DUAL"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + expect(tab.txnSessionId).toBe("txn-new"); + expect(tab.result?.rows).toEqual([[1]]); + // 只读会话没有任何未提交改动,空闲回收后静默重建即可,不应提示「事务已自动回滚」(#9831) + expect(tab.txnAutoRolledBack).not.toBe(true); + }); + + it("keeps the rollback notice for a manual dialect without sticky read-only tracking", async () => { + mocks.getConnectionConfig.mockReturnValue({ + id: "dameng-1", + name: "DM", + db_type: "dameng", + database: "DMHR", + query_timeout_secs: 30, + }); + mocks.beginManualTransaction.mockResolvedValueOnce("txn-old").mockResolvedValueOnce("txn-new"); + mocks.executeInManualTransaction.mockResolvedValueOnce(successfulSelect()).mockRejectedValueOnce(expiredTransactionError()).mockResolvedValueOnce(successfulSelect()); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("dameng-1", "DMHR", "Query", "query", "SYSDBA"); + store.setAutoCommit(tabId, false); + + await store.executeTabSql(tabId, "SELECT 1 FROM DUAL"); + await store.executeTabSql(tabId, "SELECT 1 FROM DUAL"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + expect(tab.txnSessionId).toBe("txn-new"); + expect(tab.result?.rows).toEqual([[1]]); + // 不跟踪只读状态的方言无法判断会话是否干净,保留原有提示。 + expect(tab.txnAutoRolledBack).toBe(true); + }); + it("normalizes a stale manual tab for a non-transactional database before query dispatch", async () => { mocks.getConnectionConfig.mockReturnValue({ id: "sqlite-1", diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 8d07636562..bf67db43cd 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -4931,6 +4931,20 @@ export const useQueryStore = defineStore("query", () => { if (tab.txnPossiblyDirty !== undefined) tab.txnPossiblyDirty = false; } + /** Whether an expired manual transaction still owes the user the + * `toolbar.txnAutoRolledBack` notice. + * + * Sticky proven-read-only dialects (Oracle / OceanBase-Oracle / MySQL / + * PostgreSQL) track whether the session ever ran a statement that is not + * proven read-only: `txnPossiblyDirty` stays unset while every batch was + * proven read-only, so an idle expiry discarded no uncommitted work and the + * session can be rebuilt silently (#9831). Dialects without that tracking + * cannot tell a clean session from a dirty one, so they keep the notice. */ + function manualTransactionRollbackNoticeRequired(tab: { txnPossiblyDirty?: boolean }, dbType?: string): boolean { + if (!usesProvenReadOnlyStickyTransactionState(dbType)) return true; + return tab.txnPossiblyDirty === true; + } + /** 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 @@ -7523,11 +7537,14 @@ export const useQueryStore = defineStore("query", () => { if (options?.pagination?.sessionId || manualTransactionRecoveryAttempted || !isManualTransactionSessionExpired(error)) throw error; if (tab.executionId !== executionId || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) throw error; manualTransactionRecoveryAttempted = true; + // A session that only ever ran proven read-only statements lost + // nothing to the idle rollback, so it restarts without the notice. + const rollbackNoticeRequired = manualTransactionRollbackNoticeRequired(tab, effectiveDbType); // The expired session was discarded by the backend; the replacement // session starts fresh, so the old sticky state resets with it. clearTxnPossiblyDirty(tab); tab.txnSessionId = undefined; - tab.txnAutoRolledBack = true; + tab.txnAutoRolledBack = rollbackNoticeRequired; queryExecutionLog("info", "manual-txn:expired-recover", { traceId, elapsed: elapsed() }); const refreshedSessionId = await ensureManualTransactionSession(id, executionDatabase, executionSchema, executionCatalog); if (tab.executionId !== executionId || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) { @@ -7809,10 +7826,12 @@ export const useQueryStore = defineStore("query", () => { const idleTimeout = /5 minutes of inactivity/i.test(errMsg) || errMsg.includes("5 分钟无操作") || errMsg.includes("已自动回滚"); if (idleTimeout) { // Backend session was removed and rolled back after idle expiry: clear - // the sticky dirty state together with the session. + // the sticky dirty state together with the session. Same rule as the + // restart path: a session proven read-only reports no lost work. + const rollbackNoticeRequired = manualTransactionRollbackNoticeRequired(tab, effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId))); clearTxnPossiblyDirty(tab); tab.txnSessionId = undefined; - tab.txnAutoRolledBack = true; + tab.txnAutoRolledBack = rollbackNoticeRequired; } else if (/rolled.?back/i.test(errMsg) || /transaction session not found/i.test(errMsg) || /agent runtime terminated/i.test(errMsg)) { // Statement failure that disposed the manual session: the `rolled back` // message fragment is a frontend cleanup compatibility contract.