fix(oceanbase-oracle): handle manual transactions on grid save/switch

This commit is contained in:
efeire
2026-09-20 00:49:33 +08:00
committed by GitHub
parent 30d8b3970a
commit a19a4f595d
6 changed files with 302 additions and 30 deletions
@@ -534,6 +534,7 @@ interface DataGridProps {
exportFileBaseName?: string;
customSaveHandler?: import("@/composables/useDataGridEditor").CustomSaveHandler;
manualTransactionSessionId?: string;
ensureManualTransactionSession?: () => Promise<string>;
onManualTransactionMutation?: () => void;
mongoUpdateTarget?: MongoCopyUpdateTarget;
/** Enables MongoDB collection-grid presentation for BSON null values. */
@@ -3706,6 +3707,7 @@ const editor = useDataGridEditor({
onExecuteSql: computed(() => props.onExecuteSql),
customSaveHandler: computed(() => props.customSaveHandler),
manualTransactionSessionId: computed(() => props.manualTransactionSessionId),
ensureManualTransactionSession: computed(() => props.ensureManualTransactionSession),
onManualTransactionMutation: () => props.onManualTransactionMutation?.(),
sql: computed(() => props.sql),
searchText,
@@ -2091,6 +2091,7 @@ defineExpose({
:mongo-update-target="mongoQueryResultSaveHandler && activeTab.result.mongo_copy_documents?.length === activeTab.result.rows.length ? activeTab.mongoEditTarget : undefined"
:query-editability-reason="activeTab.queryEditabilityReason"
:manual-transaction-session-id="activeTab.txnSessionId"
:ensure-manual-transaction-session="activeTab.autoCommit === false ? () => queryStore.ensureManualTransactionSession(activeTab.id, activeResultDatabase, activeResultSchema) : undefined"
:on-manual-transaction-mutation="() => queryStore.markManualTransactionDirty(activeTab.id)"
:allow-insert-rows="activeTab.queryAnalysis?.allowInsert ?? activeTab.queryAnalysis?.allowInsertDelete !== false"
:allow-delete-rows="activeTab.queryAnalysis?.allowDelete ?? activeTab.queryAnalysis?.allowInsertDelete !== false"
@@ -949,6 +949,7 @@ describe("useDataGridEditor saveChanges reload", () => {
prepareFullReload?: () => void;
customSaveHandler?: { save: ReturnType<typeof vi.fn> };
manualTransactionSessionId?: string;
ensureManualTransactionSession?: () => Promise<string>;
refreshSavedRows?: ReturnType<typeof vi.fn>;
onManualTransactionMutation?: ReturnType<typeof vi.fn>;
connectionId?: string;
@@ -958,6 +959,7 @@ describe("useDataGridEditor saveChanges reload", () => {
) {
const emit = vi.fn();
const currentPage = options.currentPage ?? ref(1);
const ensureManualTransactionSession = ref(options.ensureManualTransactionSession);
const result = ref<{ columns: string[]; rows: CellValue[][] }>({
columns: ["id", "status"],
rows: [
@@ -985,6 +987,7 @@ describe("useDataGridEditor saveChanges reload", () => {
onExecuteSql: computed(() => options.onExecuteSql),
customSaveHandler: computed(() => options.customSaveHandler),
manualTransactionSessionId: computed(() => options.manualTransactionSessionId),
ensureManualTransactionSession: computed(() => ensureManualTransactionSession.value),
onManualTransactionMutation: options.onManualTransactionMutation,
sql: computed(() => undefined),
searchText: ref(""),
@@ -1001,7 +1004,7 @@ describe("useDataGridEditor saveChanges reload", () => {
refreshSavedRows: options.refreshSavedRows,
emit,
});
return { editor, emit, currentPage };
return { editor, emit, currentPage, ensureManualTransactionSession };
}
// https://github.com/t8y2/dbx/issues/8321: without a primary key the row is
@@ -1148,6 +1151,53 @@ describe("useDataGridEditor saveChanges reload", () => {
expect(emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0);
});
it("starts a manual transaction before saving an existing result grid", async () => {
const statement = "UPDATE orders_test SET status='shipped' WHERE id=1";
const ensureManualTransactionSession = vi.fn().mockResolvedValue("txn-grid-1");
mocks.prepareDataGridSave.mockResolvedValue({ statements: [statement], rollbackStatements: [] });
mocks.executeInManualTransaction.mockResolvedValue([{ affected_rows: 1 }]);
const { editor, ensureManualTransactionSession: transactionMode } = createSaveTestEditor();
transactionMode.value = ensureManualTransactionSession;
editor.dirtyRows.value.set(0, new Map([[1, "shipped"]]));
await editor.saveChanges();
expect(ensureManualTransactionSession).toHaveBeenCalledOnce();
expect(mocks.executeInManualTransaction).toHaveBeenCalledWith("txn-grid-1", statement, "app", undefined);
expect(mocks.executeBatch).not.toHaveBeenCalled();
});
it("checks a keyless edit on the newly opened manual transaction session", async () => {
mocks.prepareDataGridSave.mockResolvedValue({
statements: ["UPDATE orders_test SET status='shipped' WHERE status = 'pending'"],
rollbackStatements: [],
keylessGuards: [keylessGuard],
});
mocks.executeInManualTransaction.mockResolvedValueOnce([{ columns: ["matches"], rows: [[1]] }]).mockResolvedValueOnce([{ affected_rows: 1 }]);
const { editor } = createSaveTestEditor({ primaryKeys: [], ensureManualTransactionSession: vi.fn().mockResolvedValue("txn-grid-2") });
editor.dirtyRows.value.set(0, new Map([[1, "shipped"]]));
await editor.saveChanges();
expect(mocks.executeInManualTransaction.mock.calls[0]?.[0]).toBe("txn-grid-2");
expect(mocks.executeInManualTransaction.mock.calls[0]?.[1]).toBe(keylessGuard.sql);
expect(mocks.executeInManualTransaction.mock.calls[1]?.[0]).toBe("txn-grid-2");
expect(mocks.executeQuery).not.toHaveBeenCalled();
expect(mocks.executeBatch).not.toHaveBeenCalled();
});
it("keeps edits pending if a manual transaction cannot start", async () => {
mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE orders_test SET status='shipped' WHERE id=1"], rollbackStatements: [] });
const ensureManualTransactionSession = vi.fn().mockRejectedValue(new Error("BEGIN failed"));
const { editor } = createSaveTestEditor({ ensureManualTransactionSession });
editor.dirtyRows.value.set(0, new Map([[1, "shipped"]]));
await editor.saveChanges();
expect(editor.saveError.value).toContain("BEGIN failed");
expect(editor.dirtyRows.value.get(0)?.get(1)).toBe("shipped");
expect(mocks.executeBatch).not.toHaveBeenCalled();
expect(mocks.executeInManualTransaction).not.toHaveBeenCalled();
});
it("marks the manual transaction dirty before a result-grid mutation can fail", async () => {
const onManualTransactionMutation = vi.fn();
mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE orders_test SET status='shipped' WHERE id=1"], rollbackStatements: [] });
@@ -109,6 +109,7 @@ export interface UseDataGridEditorOptions {
onExecuteSql: ComputedRef<((sql: string) => Promise<void>) | undefined>;
customSaveHandler?: ComputedRef<CustomSaveHandler | undefined>;
manualTransactionSessionId?: ComputedRef<string | undefined>;
ensureManualTransactionSession?: ComputedRef<(() => Promise<string>) | undefined>;
onManualTransactionMutation?: () => void;
sql: ComputedRef<string | undefined>;
searchText: Ref<string>;
@@ -1598,9 +1599,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
// A keyless save can only be trusted once the server confirms that each
// predicate it sends addresses a single physical row; the loaded page cannot
// see rows outside it. Returns the error to fail with, or undefined to allow.
async function verifyKeylessGuards(guards: DataGridSaveGuard[], executionSchema?: string) {
async function verifyKeylessGuards(guards: DataGridSaveGuard[], executionSchema?: string, txnSessionId = manualTransactionSessionId.value) {
if (!guards.length) return undefined;
const txnSessionId = manualTransactionSessionId.value;
// Without a connection to count against there is no way to verify the
// predicate, and an unverified keyless write is exactly what must not run.
if (!hasBackendSaveTarget.value) return KEYLESS_GUARD_UNVERIFIED_ERROR;
@@ -1948,8 +1948,19 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return;
}
const rollbackStmts = preparedSave?.rollbackStatements ?? [];
let txnSessionId = manualTransactionSessionId.value;
if (options.ensureManualTransactionSession?.value && hasBackendSaveTarget.value) {
try {
txnSessionId = await options.ensureManualTransactionSession.value();
if (!txnSessionId) throw new Error("Manual transaction session was not initialized");
} catch (error) {
saveError.value = normalizeDataGridSaveError(databaseType.value, error);
await finishInterruptedSaveChanges(snapshot);
return;
}
}
try {
const guardError = await verifyKeylessGuards(preparedSave?.keylessGuards ?? [], preparedSave?.executionSchema);
const guardError = await verifyKeylessGuards(preparedSave?.keylessGuards ?? [], preparedSave?.executionSchema, txnSessionId);
if (guardError) {
saveError.value = guardError;
await finishInterruptedSaveChanges(snapshot);
@@ -1991,10 +2002,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
statements: stmts,
rollbackStatements: rollbackStmts,
});
if (manualTransactionSessionId.value && hasBackendSaveTarget.value) {
if (txnSessionId && hasBackendSaveTarget.value) {
options.onManualTransactionMutation?.();
try {
const results = await api.executeInManualTransaction(manualTransactionSessionId.value, stmts.join(";\n"), database.value ?? "", preparedSave?.executionSchema);
const results = await api.executeInManualTransaction(txnSessionId, stmts.join(";\n"), database.value ?? "", preparedSave?.executionSchema);
apiResult = {
affected_rows: results.reduce((total, result) => total + (result.affected_rows ?? 0), 0),
};
@@ -2038,7 +2049,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
applyDirtyRowsToResult(snapshot);
options.onResultPayloadMutated?.();
let savedRowsRefreshed = false;
if (!joinedWriteTargets.value?.length && !manualTransactionSessionId.value && !shouldReloadAfterSave && snapshot.dirtyRows.size > 0 && options.refreshSavedRows) {
if (!joinedWriteTargets.value?.length && !txnSessionId && !shouldReloadAfterSave && snapshot.dirtyRows.size > 0 && options.refreshSavedRows) {
try {
savedRowsRefreshed = await options.refreshSavedRows({
dirtyRows: snapshot.dirtyRows,
@@ -118,6 +118,122 @@ describe("queryStore Oracle/OceanBase manual-transaction sticky state (behavior
return tabId;
}
it("starts one session for a result-grid save before the next SQL execution", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
const sessionId = await store.ensureManualTransactionSession(tabId, "ORCL", "APP");
expect(sessionId).toBe("txn-oracle");
expect(mocks.beginManualTransaction).toHaveBeenCalledWith("oracle-1", "ORCL", "APP", undefined);
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBe("txn-oracle");
await store.ensureManualTransactionSession(tabId, "ORCL", "APP");
expect(mocks.beginManualTransaction).toHaveBeenCalledOnce();
});
it("rolls back a session opened after the tab leaves manual mode", async () => {
let finishBegin!: (sessionId: string) => void;
mocks.beginManualTransaction.mockReturnValue(
new Promise<string>((resolve) => {
finishBegin = resolve;
}),
);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query", "APP");
store.setAutoCommit(tabId, false);
const starting = store.ensureManualTransactionSession(tabId, "ORCL", "APP");
store.setAutoCommit(tabId, true);
finishBegin("txn-late");
await expect(starting).rejects.toThrow("Query tab changed");
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-late");
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBeUndefined();
});
it("rolls back a session opened for a database the tab has left", async () => {
let finishBegin!: (sessionId: string) => void;
mocks.beginManualTransaction.mockReturnValue(
new Promise<string>((resolve) => {
finishBegin = resolve;
}),
);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query");
store.setAutoCommit(tabId, false);
const starting = store.ensureManualTransactionSession(tabId, "ORCL");
store.updateDatabase(tabId, "OTHER_DB");
finishBegin("txn-old-database");
await expect(starting).rejects.toThrow("Query tab changed");
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-old-database");
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBeUndefined();
});
it("does not attach a pending session after leaving and returning to the same database", async () => {
let finishBegin!: (sessionId: string) => void;
mocks.beginManualTransaction.mockReturnValue(
new Promise<string>((resolve) => {
finishBegin = resolve;
}),
);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query");
store.setAutoCommit(tabId, false);
const starting = store.ensureManualTransactionSession(tabId, "ORCL");
store.updateDatabase(tabId, "OTHER_DB");
store.updateDatabase(tabId, "ORCL");
finishBegin("txn-before-switch");
await expect(starting).rejects.toThrow("Query tab changed");
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-before-switch");
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBeUndefined();
});
it("rolls back a session opened for a catalog the tab has left", async () => {
let finishBegin!: (sessionId: string) => void;
mocks.beginManualTransaction.mockReturnValue(
new Promise<string>((resolve) => {
finishBegin = resolve;
}),
);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query");
store.setAutoCommit(tabId, false);
const starting = store.ensureManualTransactionSession(tabId, "ORCL");
store.updateCatalog(tabId, "NEW_CATALOG", "ORCL");
finishBegin("txn-old-catalog");
await expect(starting).rejects.toThrow("Query tab changed");
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-old-catalog");
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBeUndefined();
});
it("does not reuse a previous database's transaction while rollback is pending", async () => {
mocks.beginManualTransaction.mockResolvedValueOnce("txn-old").mockResolvedValueOnce("txn-new");
mocks.rollbackManualTransaction.mockReturnValueOnce(new Promise<void>(() => {}));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query");
store.setAutoCommit(tabId, false);
await store.ensureManualTransactionSession(tabId, "ORCL");
store.updateDatabase(tabId, "OTHER_DB");
const newSession = await store.ensureManualTransactionSession(tabId, "OTHER_DB");
expect(newSession).toBe("txn-new");
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-old");
expect(mocks.beginManualTransaction).toHaveBeenNthCalledWith(2, "oracle-1", "OTHER_DB", undefined, undefined);
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBe("txn-new");
});
it("sends classificationSql only for the initial Oracle manual execution", async () => {
mocks.executeInManualTransaction.mockResolvedValue(cleanSelect());
@@ -261,6 +377,54 @@ describe("queryStore Oracle/OceanBase manual-transaction sticky state (behavior
expect(tab.txnPossiblyDirty).not.toBe(true);
});
it("does not replay an expired transaction's SQL after its database changes during recovery", async () => {
let finishBegin!: (sessionId: string) => void;
mocks.beginManualTransaction.mockResolvedValueOnce("txn-old").mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
finishBegin = resolve;
}),
);
mocks.executeInManualTransaction.mockRejectedValueOnce(expiredTransactionError()).mockResolvedValueOnce(cleanSelect());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
const running = store.executeTabSql(tabId, "SELECT * FROM EMP");
await vi.waitFor(() => expect(mocks.beginManualTransaction).toHaveBeenCalledTimes(2));
store.updateDatabase(tabId, "OTHER_DB");
finishBegin("txn-recovered-old-database");
await running;
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-recovered-old-database");
expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(1);
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBeUndefined();
expect(store.tabs.find((item) => item.id === tabId)?.result).toBeUndefined();
});
it("does not publish results from a database the tab has left", async () => {
let finishQuery!: (results: StickyResult[]) => void;
mocks.executeInManualTransaction.mockReturnValueOnce(
new Promise<StickyResult[]>((resolve) => {
finishQuery = resolve;
}),
);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
const running = store.executeTabSql(tabId, "SELECT * FROM EMP");
await vi.waitFor(() => expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(1));
store.updateDatabase(tabId, "OTHER_DB");
finishQuery(cleanSelect());
await running;
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.database).toBe("OTHER_DB");
expect(tab.result).toBeUndefined();
expect(tab.isExecuting).toBe(false);
});
it("clears the sticky state together with the session on DBX rollback", async () => {
mocks.executeInManualTransaction.mockResolvedValue(dirtyUpdate());
+67 -23
View File
@@ -4661,9 +4661,52 @@ export const useQueryStore = defineStore("query", () => {
}
}
const manualTransactionTargetEpochs = new WeakMap<QueryTab, number>();
const pendingManualTransactionStarts = new Map<string, { epoch: number; promise: Promise<string> }>();
function manualTransactionTargetEpoch(tab: QueryTab): number {
return manualTransactionTargetEpochs.get(tab) ?? 0;
}
function invalidateManualTransactionTarget(tab: QueryTab) {
manualTransactionTargetEpochs.set(tab, manualTransactionTargetEpoch(tab) + 1);
}
async function ensureManualTransactionSession(id: string, database: string, schema?: string, catalog?: string): Promise<string> {
const tab = tabs.value.find((item) => item.id === id);
if (!tab || tab.mode !== "query" || tab.autoCommit !== false || !tab.connectionId) {
throw new Error("Manual transaction mode is no longer active for this query tab");
}
if (tab.txnSessionId) return tab.txnSessionId;
const epoch = manualTransactionTargetEpoch(tab);
const pending = pendingManualTransactionStarts.get(id);
if (pending?.epoch === epoch) return pending.promise;
const connectionId = tab.connectionId;
const originalDatabase = tab.database;
const originalCatalog = tab.catalog;
const originalSchema = tab.schema;
const start = api
.beginManualTransaction(connectionId, database, schema, catalog)
.then(async (sessionId) => {
if (tabs.value.find((item) => item.id === id) !== tab || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== epoch || tab.connectionId !== connectionId || tab.database !== originalDatabase || tab.catalog !== originalCatalog || tab.schema !== originalSchema) {
await api.rollbackManualTransaction(sessionId);
throw new Error("Query tab changed while the manual transaction was starting");
}
tab.txnSessionId = sessionId;
return sessionId;
})
.finally(() => {
if (pendingManualTransactionStarts.get(id)?.promise === start) pendingManualTransactionStarts.delete(id);
});
pendingManualTransactionStarts.set(id, { epoch, promise: start });
return start;
}
function setAutoCommit(id: string, autoCommit: boolean) {
const tab = tabs.value.find((t) => t.id === id);
if (tab) {
if (tab.autoCommit !== autoCommit) invalidateManualTransactionTarget(tab);
const wasManual = tab.autoCommit === false;
tab.autoCommit = autoCommit;
if (autoCommit && wasManual) {
@@ -4701,6 +4744,7 @@ export const useQueryStore = defineStore("query", () => {
}
function rollbackTabTransaction(tab: QueryTab, options?: { resetAutoCommit?: boolean; resetAutoCommitDbType?: string }) {
invalidateManualTransactionTarget(tab);
if (tab.txnSessionId) void rollbackTransaction(tab.id);
if (options?.resetAutoCommit) {
// Callers switching a tab to another connection pass the target db type
@@ -4727,11 +4771,11 @@ export const useQueryStore = defineStore("query", () => {
async function rollbackTransaction(id: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab?.txnSessionId) return;
try {
await api.rollbackManualTransaction(tab.txnSessionId);
} finally {
clearManualTransactionSession(tab);
}
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.
clearManualTransactionSession(tab);
await api.rollbackManualTransaction(sessionId);
}
function updateEditorViewport(id: string, viewport: { scrollTop: number; scrollLeft: number }) {
@@ -6246,6 +6290,7 @@ export const useQueryStore = defineStore("query", () => {
const executionEditorFingerprint = tab.mode === "query" ? sqlTextFingerprint(tab.sql) : undefined;
const traceId = executionId.slice(0, 8);
const startedAt = performance.now();
const executionTargetEpoch = manualTransactionTargetEpoch(tab);
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
const batchResume = options?.batchResume;
const continueOnBatchError = batchResume?.continueOnError ?? settingsStore.editorSettings.continueOnErrorOnBatch;
@@ -7101,7 +7146,7 @@ export const useQueryStore = defineStore("query", () => {
const sourceLabelDatabase = targetDatabase || conn?.database;
const executionClientSessionId = options?.pagination?.clientSessionId ?? (tab.mode === "query" || tab.mode === "data" ? tabClientSessionId(tab) : undefined);
const currentBeforeDispatch = findExecutionTab(id);
if (currentBeforeDispatch?.executionId !== executionId || currentBeforeDispatch.isCancelling) {
if (currentBeforeDispatch?.executionId !== executionId || currentBeforeDispatch.isCancelling || manualTransactionTargetEpoch(currentBeforeDispatch) !== executionTargetEpoch) {
queryExecutionLog("info", "dispatch:skipped-cancelled", { traceId, elapsed: elapsed() });
return false;
}
@@ -7199,7 +7244,7 @@ export const useQueryStore = defineStore("query", () => {
if (!tab.txnSessionId) {
queryExecutionLog("info", "begin-manual-txn:start", { traceId, elapsed: elapsed() });
try {
tab.txnSessionId = await api.beginManualTransaction(executionConnectionId, executionDatabase, executionSchema, executionCatalog);
tab.txnSessionId = await ensureManualTransactionSession(id, executionDatabase, executionSchema, executionCatalog);
queryExecutionLog("info", "begin-manual-txn:done", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() });
} catch (error) {
const risk = classifySqlRisk(sqlToExecute, { dialect: effectiveDbType }).risk;
@@ -7240,6 +7285,7 @@ export const useQueryStore = defineStore("query", () => {
return await executeInTransaction(txnSessionId);
} catch (error) {
if (options?.pagination?.sessionId || manualTransactionRecoveryAttempted || !isManualTransactionSessionExpired(error)) throw error;
if (tab.executionId !== executionId || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) throw error;
manualTransactionRecoveryAttempted = true;
// The expired session was discarded by the backend; the replacement
// session starts fresh, so the old sticky state resets with it.
@@ -7247,7 +7293,12 @@ export const useQueryStore = defineStore("query", () => {
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = true;
queryExecutionLog("info", "manual-txn:expired-recover", { traceId, elapsed: elapsed() });
const refreshedSessionId = await api.beginManualTransaction(executionConnectionId, executionDatabase, executionSchema, executionCatalog);
const refreshedSessionId = await ensureManualTransactionSession(id, executionDatabase, executionSchema, executionCatalog);
if (tab.executionId !== executionId || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) {
if (tab.txnSessionId === refreshedSessionId) clearManualTransactionSession(tab);
await api.rollbackManualTransaction(refreshedSessionId);
throw new Error("Query tab changed while the manual transaction was restarting");
}
tab.txnSessionId = refreshedSessionId;
queryExecutionLog("info", "manual-txn:restarted", { traceId, txnSessionId: refreshedSessionId, elapsed: elapsed() });
return executeInTransaction(refreshedSessionId);
@@ -7257,20 +7308,11 @@ export const useQueryStore = defineStore("query", () => {
} else {
executionPromise = executeWithoutManualTransaction();
}
const annotatedResults = annotateQueryResultSources(
markQueryResultsRowsRaw(
await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }), () => {
void api.cancelQuery(executionId).catch((error) => queryExecutionLog("warn", "frontend-timeout:cancel-failed", { traceId, error }));
}),
),
queryBaseSql,
sourceLabelDatabase,
effectiveDbType,
options?.sourceOffset,
sqlStatementParameterOptions,
sqlToExecute,
options?.sourceOffset === undefined ? undefined : tab.sql,
);
const responseResults = await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }), () => {
void api.cancelQuery(executionId).catch((error) => queryExecutionLog("warn", "frontend-timeout:cancel-failed", { traceId, error }));
});
if (findExecutionTab(id) !== tab || tab.executionId !== executionId || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) return false;
const annotatedResults = annotateQueryResultSources(markQueryResultsRowsRaw(responseResults), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset, sqlStatementParameterOptions, sqlToExecute, options?.sourceOffset === undefined ? undefined : tab.sql);
const results = offsetBatchQueryResultIndexes(annotatedResults.results, batchResume?.startStatementIndex ?? 0);
if (paginationRowNumberColumn && results.length === 1) {
results[0] = markQueryResultsRowsRaw([stripPaginationRowNumber(results[0]!, paginationRowNumberColumn)])[0]!;
@@ -7319,7 +7361,7 @@ export const useQueryStore = defineStore("query", () => {
}
}
const current = findExecutionTab(id);
if (current?.executionId === executionId) {
if (current?.executionId === executionId && manualTransactionTargetEpoch(current) === executionTargetEpoch) {
if (captureResultRun && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
if (successfulOracleSchemaChanges > 0) {
current.completionContextVersion = (current.completionContextVersion ?? 0) + successfulOracleSchemaChanges;
@@ -7503,6 +7545,7 @@ export const useQueryStore = defineStore("query", () => {
queryExecutionLog("error", "error", { traceId, elapsed: elapsed(), error: e });
// Sync connection state if the error indicates a lost connection
useConnectionStore().recordConnectionLostError(executionConnectionId ?? tab.connectionId, e);
if (findExecutionTab(id) !== tab || tab.executionId !== executionId || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) return false;
// Handle manual transaction auto-rollback (idle timeout only for the banner;
// other statement failures still clear the session without the 5-minute notice).
if (tab.autoCommit === false) {
@@ -8802,6 +8845,7 @@ export const useQueryStore = defineStore("query", () => {
markManualTransactionDirty,
commitTransaction,
rollbackTransaction,
ensureManualTransactionSession,
renameTab,
openDatabaseBrowser,
openDriverProfileWorkspace,