fix(oceanbase-oracle): handle manual transactions on grid save/switch
This commit is contained in:
1 parent
30d8b3970a
commit
a19a4f595d
6 files changed
+302
-30
No files matched your search
+164
@@ -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());
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user