fix(editor): skip idle rollback notice for read-only manual transactions

This commit is contained in:
zipg authored and t8y2 committed 2026-09-22 22:33:07 +08:00
1 parent 7b74cb42ff
commit 4c1809f0c7
2 files changed
+71 -3

No files matched your search

@@ -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",
+22 -3
View File
@@ -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.