mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-25 02:00:09 +08:00
🐛 fix(query-editor): 修复大库下SQL AI补全阻塞主线程导致全局卡顿
根因:全库列元数据加载后,每次内联补全在主线程对全部列做 O(列数×表数) 正则匹配, 且补全上下文被重复构建两次,8万列规模下单次请求耗时约900ms;同时 table_name 意图 每次补全都真实查库。改为按表名末段建索引(WeakMap按请求缓存)、复用已收敛上下文、 warmup 成功后会话内缓存,单次请求耗时降至约12ms。
This commit is contained in:
@@ -1090,7 +1090,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const aiInlineGhostTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const aiInlineGhostRequestSeqRef = useRef(0);
|
||||
const triggerAiInlineCompletionRef = useRef<(() => void) | null>(null);
|
||||
const aiContextMetadataWarmupRef = useRef<Record<string, Promise<void> | undefined>>({});
|
||||
const aiContextMetadataWarmupRef = useRef<Record<string, Promise<boolean> | undefined>>({});
|
||||
const triggerSqlAiCompletionAltPressedRef = useRef(false);
|
||||
const triggerSqlAiCompletionAltGestureAtRef = useRef(0);
|
||||
const triggerSqlAiCompletionFallbackRef = useRef<{ observedAt: number } | null>(null);
|
||||
@@ -1772,11 +1772,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return;
|
||||
}
|
||||
|
||||
const warmupPromise = (async () => {
|
||||
const warmupPromise = (async (): Promise<boolean> => {
|
||||
const conn = connectionsRef.current.find((item) => item.id === connectionId);
|
||||
if (!conn) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let warmupSucceeded = true;
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
@@ -1794,6 +1795,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
fetchCompletionTableCommentMap(config, dbName, metadataDialect).catch(() => new Map<string, string>()),
|
||||
DBGetTables(buildRpcConnectionConfig(config) as any, dbName),
|
||||
]);
|
||||
if (!resTables?.success) {
|
||||
warmupSucceeded = false;
|
||||
}
|
||||
if (resTables?.success && Array.isArray(resTables.data)) {
|
||||
const fetchedTables = resTables.data
|
||||
.map((row: any) => buildCompletionTableMeta(dbName, row, tableComments))
|
||||
@@ -1817,6 +1821,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
warmupSucceeded = false;
|
||||
console.warn('GoNavi AI inline table metadata warmup failed', error);
|
||||
}
|
||||
}
|
||||
@@ -1824,6 +1829,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
if (needsColumns) {
|
||||
try {
|
||||
const resCols = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName);
|
||||
if (!resCols?.success) {
|
||||
warmupSucceeded = false;
|
||||
}
|
||||
if (resCols?.success && Array.isArray(resCols.data)) {
|
||||
const fetchedColumns = resCols.data.map((col: any) => ({
|
||||
dbName,
|
||||
@@ -1850,16 +1858,22 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
warmupSucceeded = false;
|
||||
console.warn('GoNavi AI inline column metadata warmup failed', error);
|
||||
}
|
||||
}
|
||||
return warmupSucceeded;
|
||||
})();
|
||||
|
||||
// 成功的 warmup 结果整个会话内复用,避免每次内联补全都真实查库;失败时删除缓存以便重试。
|
||||
aiContextMetadataWarmupRef.current[warmupKey] = warmupPromise;
|
||||
let warmupSucceeded = false;
|
||||
try {
|
||||
await warmupPromise;
|
||||
warmupSucceeded = await warmupPromise;
|
||||
} finally {
|
||||
delete aiContextMetadataWarmupRef.current[warmupKey];
|
||||
if (!warmupSucceeded) {
|
||||
delete aiContextMetadataWarmupRef.current[warmupKey];
|
||||
}
|
||||
}
|
||||
}, [currentConnectionId, currentDb, tab.connectionId, tab.dbName]);
|
||||
|
||||
|
||||
@@ -437,6 +437,32 @@ describe('QueryEditorAiAssist', () => {
|
||||
expect(focused.columns).toEqual([{ dbName: 'shop', tableName: 'videos', name: 'code', type: 'varchar' }]);
|
||||
});
|
||||
|
||||
it('matches schema-qualified table metadata columns by table name last part', () => {
|
||||
const focused = buildQueryEditorInlineCompletionContext({
|
||||
connectionName: 'Local Oracle',
|
||||
sourceType: 'oracle',
|
||||
currentDb: 'APP',
|
||||
visibleDbs: ['APP'],
|
||||
tables: [
|
||||
{ dbName: 'APP', tableName: 'SCOTT.ORDERS' },
|
||||
{ dbName: 'APP', tableName: 'SCOTT.USERS' },
|
||||
],
|
||||
columns: [
|
||||
{ dbName: 'APP', tableName: 'SCOTT.ORDERS', name: 'ORDER_ID', type: 'number' },
|
||||
{ dbName: 'APP', tableName: 'SCOTT.USERS', name: 'USER_ID', type: 'number' },
|
||||
],
|
||||
}, {
|
||||
prefix: 'select * from orders o where',
|
||||
suffix: '',
|
||||
currentLineBeforeCursor: 'select * from orders o where',
|
||||
currentLineAfterCursor: '',
|
||||
});
|
||||
|
||||
expect(focused.inlineSchemaScope).toBe('referenced_tables');
|
||||
expect(focused.tables).toEqual([{ dbName: 'APP', tableName: 'SCOTT.ORDERS' }]);
|
||||
expect(focused.columns).toEqual([{ dbName: 'APP', tableName: 'SCOTT.ORDERS', name: 'ORDER_ID', type: 'number' }]);
|
||||
});
|
||||
|
||||
it('checks active provider readiness before inline AI requests', async () => {
|
||||
const service = readyService('select * from users where id > 1;');
|
||||
const readiness = await resolveQueryEditorAiRuntimeReadiness(service);
|
||||
|
||||
@@ -409,7 +409,10 @@ export const buildQueryEditorInlineCompletionMessages = ({
|
||||
editorSnapshot: QueryEditorAiEditorSnapshot;
|
||||
userPromptSettings: AIUserPromptSettings;
|
||||
}): QueryEditorAiMessage[] => {
|
||||
const inlineAiContext = buildQueryEditorInlineCompletionContext(aiContext, editorSnapshot);
|
||||
// inlineCompletionIntent 已存在说明调用方传入的已是收敛后的内联上下文,避免重复做 O(列数) 的过滤。
|
||||
const inlineAiContext = aiContext.inlineCompletionIntent !== undefined
|
||||
? aiContext
|
||||
: buildQueryEditorInlineCompletionContext(aiContext, editorSnapshot);
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -904,6 +907,48 @@ const collectReferencedSchemaTables = (
|
||||
return result.slice(0, MAX_INLINE_SCHEMA_TABLES);
|
||||
};
|
||||
|
||||
// 大库列元数据可达数十万条,逐列正则匹配会阻塞主线程;按表名末段建一次索引,同一 columns 数组内复用。
|
||||
const inlineColumnIndexCache = new WeakMap<CompletionColumnMeta[], Map<string, CompletionColumnMeta[]>>();
|
||||
|
||||
const getInlineColumnsByTableLastPart = (
|
||||
columns: CompletionColumnMeta[],
|
||||
): Map<string, CompletionColumnMeta[]> => {
|
||||
const cached = inlineColumnIndexCache.get(columns);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const index = new Map<string, CompletionColumnMeta[]>();
|
||||
columns.forEach((column) => {
|
||||
const lastPart = getInlineIdentifierLastPart(column.tableName || '').toLowerCase();
|
||||
if (!lastPart) {
|
||||
return;
|
||||
}
|
||||
const list = index.get(lastPart);
|
||||
if (list) {
|
||||
list.push(column);
|
||||
} else {
|
||||
index.set(lastPart, [column]);
|
||||
}
|
||||
});
|
||||
inlineColumnIndexCache.set(columns, index);
|
||||
return index;
|
||||
};
|
||||
|
||||
const collectColumnsMatchingReference = (
|
||||
columns: CompletionColumnMeta[],
|
||||
ref: QueryEditorAiTableReference,
|
||||
): CompletionColumnMeta[] => {
|
||||
const refLastPart = getInlineIdentifierLastPart(ref.tableName || '').toLowerCase();
|
||||
if (!refLastPart) {
|
||||
return [];
|
||||
}
|
||||
const candidates = getInlineColumnsByTableLastPart(columns).get(refLastPart) || [];
|
||||
return candidates.filter((column) => tableMatchesInlineReference(
|
||||
{ dbName: column.dbName, tableName: column.tableName },
|
||||
ref,
|
||||
));
|
||||
};
|
||||
|
||||
const filterColumnsForTables = (
|
||||
columns: CompletionColumnMeta[],
|
||||
tables: CompletionTableMeta[],
|
||||
@@ -912,18 +957,22 @@ const filterColumnsForTables = (
|
||||
if (!columns.length || (!tables.length && !refs.length)) {
|
||||
return [];
|
||||
}
|
||||
return columns.filter((column) => {
|
||||
if (tables.some((table) => tableMatchesInlineReference(
|
||||
{ dbName: column.dbName, tableName: column.tableName },
|
||||
{ dbName: table.dbName, tableName: table.tableName, raw: table.tableName },
|
||||
))) {
|
||||
return true;
|
||||
}
|
||||
return refs.some((ref) => tableMatchesInlineReference(
|
||||
{ dbName: column.dbName, tableName: column.tableName },
|
||||
ref,
|
||||
));
|
||||
const targets: QueryEditorAiTableReference[] = [
|
||||
...tables.map((table) => ({ dbName: table.dbName, tableName: table.tableName, raw: table.tableName })),
|
||||
...refs,
|
||||
];
|
||||
const seen = new Set<CompletionColumnMeta>();
|
||||
const result: CompletionColumnMeta[] = [];
|
||||
targets.forEach((ref) => {
|
||||
collectColumnsMatchingReference(columns, ref).forEach((column) => {
|
||||
if (seen.has(column)) {
|
||||
return;
|
||||
}
|
||||
seen.add(column);
|
||||
result.push(column);
|
||||
});
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const collectCurrentDatabaseTables = (
|
||||
@@ -1087,11 +1136,7 @@ const collectInlineColumnCandidateLabels = (
|
||||
return [];
|
||||
}
|
||||
|
||||
return (context.columns || [])
|
||||
.filter((column) => tableMatchesInlineReference(
|
||||
{ dbName: column.dbName, tableName: column.tableName },
|
||||
ownerRef,
|
||||
))
|
||||
return collectColumnsMatchingReference(context.columns || [], ownerRef)
|
||||
.map((column) => stripInlineIdentifierQuotes(column.name || '').trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user