🐛 fix(query-editor): 修复大库下SQL AI补全阻塞主线程导致全局卡顿

根因:全库列元数据加载后,每次内联补全在主线程对全部列做 O(列数×表数) 正则匹配,
且补全上下文被重复构建两次,8万列规模下单次请求耗时约900ms;同时 table_name 意图
每次补全都真实查库。改为按表名末段建索引(WeakMap按请求缓存)、复用已收敛上下文、
warmup 成功后会话内缓存,单次请求耗时降至约12ms。
This commit is contained in:
Syngnat
2026-07-06 20:29:23 +08:00
parent 4a63a175e4
commit 50b4169148
3 changed files with 107 additions and 22 deletions

View File

@@ -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]);

View File

@@ -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);

View File

@@ -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);
};