🐛 fix(sql-editor): 防止事务重复提交误报失败

This commit is contained in:
Syngnat
2026-06-12 07:45:32 +08:00
parent 3427a8844a
commit 0573155285
2 changed files with 131 additions and 3 deletions

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useSqlEditorTransactionController } from './useSqlEditorTransactionController';
import type { PendingSqlEditorTransaction } from './QueryEditorTransactionToolbar';
const storeState = vi.hoisted(() => ({
setSqlEditorPendingTransaction: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
DBCommitTransaction: vi.fn(),
DBRollbackTransaction: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
success: vi.fn(),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('antd', () => ({
message: messageApi,
}));
const createPendingTransaction = (overrides: Partial<PendingSqlEditorTransaction> = {}): PendingSqlEditorTransaction => ({
id: 'tx-1',
commitMode: 'manual',
autoCommitDelayMs: 0,
createdAt: Date.now(),
statementCount: 1,
...overrides,
});
describe('useSqlEditorTransactionController', () => {
let controller: ReturnType<typeof useSqlEditorTransactionController> | null = null;
let renderer: ReactTestRenderer | null = null;
const renderController = () => {
const Harness = () => {
controller = useSqlEditorTransactionController({ tabId: 'tab-1' });
return null;
};
act(() => {
renderer = create(<Harness />);
});
};
beforeEach(() => {
controller = null;
renderer = null;
storeState.setSqlEditorPendingTransaction.mockReset();
backendApp.DBCommitTransaction.mockReset();
backendApp.DBRollbackTransaction.mockReset();
messageApi.error.mockReset();
messageApi.success.mockReset();
backendApp.DBCommitTransaction.mockResolvedValue({ success: true, message: '事务已提交' });
backendApp.DBRollbackTransaction.mockResolvedValue({ success: true, message: '事务已回滚' });
});
afterEach(() => {
act(() => {
renderer?.unmount();
});
});
it('ignores duplicate finish requests for the same pending transaction', async () => {
renderController();
await act(async () => {
controller?.activatePendingSqlTransaction(createPendingTransaction());
});
await act(async () => {
const first = controller?.finishPendingSqlTransaction('commit', 'manual');
const second = controller?.finishPendingSqlTransaction('commit', 'manual');
await Promise.all([first, second]);
});
expect(backendApp.DBCommitTransaction).toHaveBeenCalledTimes(1);
expect(backendApp.DBCommitTransaction).toHaveBeenCalledWith('tx-1');
expect(backendApp.DBRollbackTransaction).not.toHaveBeenCalled();
expect(messageApi.success).toHaveBeenCalledWith('SQL 事务已提交');
});
it('does not rollback a transaction while its auto commit is in flight', async () => {
let resolveCommit!: (value: { success: boolean; message: string }) => void;
backendApp.DBCommitTransaction.mockReturnValue(new Promise((resolve) => {
resolveCommit = resolve;
}));
renderController();
await act(async () => {
controller?.activatePendingSqlTransaction(createPendingTransaction({
commitMode: 'auto',
autoCommitDelayMs: 0,
}));
});
const finishPromise = controller?.finishPendingSqlTransaction('commit', 'auto');
act(() => {
renderer?.unmount();
renderer = null;
});
expect(backendApp.DBRollbackTransaction).not.toHaveBeenCalled();
expect(backendApp.DBCommitTransaction).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCommit({ success: true, message: '事务已提交' });
await finishPromise;
});
expect(messageApi.success).toHaveBeenCalledWith('SQL 事务已自动提交');
});
});

View File

@@ -19,6 +19,7 @@ export const useSqlEditorTransactionController = ({
const setSqlEditorPendingTransaction = useStore(state => state.setSqlEditorPendingTransaction);
const [pendingSqlTransaction, setPendingSqlTransaction] = useState<PendingSqlEditorTransaction | null>(null);
const pendingSqlTransactionRef = useRef<PendingSqlEditorTransaction | null>(null);
const finishingTransactionIdsRef = useRef<Set<string>>(new Set());
const autoCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoCommitCountdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [autoCommitRemainingSeconds, setAutoCommitRemainingSeconds] = useState<number | null>(null);
@@ -50,13 +51,17 @@ export const useSqlEditorTransactionController = ({
if (!transaction || (transactionId && transaction.id !== transactionId)) {
return;
}
if (finishingTransactionIdsRef.current.has(transaction.id)) {
return;
}
clearAutoCommitTimer();
finishingTransactionIdsRef.current.add(transaction.id);
updatePendingSqlTransaction(null);
try {
const res = action === 'commit'
? await DBCommitTransaction(transaction.id)
: await DBRollbackTransaction(transaction.id);
if (res?.success) {
updatePendingSqlTransaction(null);
if (action === 'commit') {
message.success(source === 'auto' ? 'SQL 事务已自动提交' : 'SQL 事务已提交');
} else {
@@ -64,13 +69,13 @@ export const useSqlEditorTransactionController = ({
}
return;
}
updatePendingSqlTransaction(null);
const fallback = action === 'commit' ? '提交失败' : '回滚失败';
message.error(`${source === 'auto' ? '自动提交失败' : fallback}: ${formatSqlExecutionError(res?.message || '未知错误')}`);
} catch (err: any) {
updatePendingSqlTransaction(null);
const fallback = action === 'commit' ? '提交失败' : '回滚失败';
message.error(`${source === 'auto' ? '自动提交失败' : fallback}: ${formatSqlExecutionError(err?.message || err || '未知错误')}`);
} finally {
finishingTransactionIdsRef.current.delete(transaction.id);
}
}, [clearAutoCommitTimer, updatePendingSqlTransaction]);