Prometheus: Performant Code Mode autocomplete with long metric names (#96580)

* fix: autocomplete performance

* refactor: simplify the complex search strategy

* chore: allow json imports to aid testing

* perf: prefer for loop for perf-critical path

* perf: use ufuzzy in intraMode: 0 (#96584)

* refactor: add clarity

* refactor: simplify

---------

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Nick Richmond 2024-11-18 12:12:25 -05:00 committed by GitHub
parent 9cd2598e8c
commit 466688436e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 199 additions and 16 deletions

View File

@ -3,8 +3,8 @@ import { config } from '@grafana/runtime';
import { SUGGESTIONS_LIMIT } from '../../../language_provider';
import { FUNCTIONS } from '../../../promql';
import { getCompletions } from './completions';
import { DataProvider, DataProviderParams } from './data_provider';
import { filterMetricNames, getCompletions } from './completions';
import { DataProvider, type DataProviderParams } from './data_provider';
import type { Situation } from './situation';
const history: string[] = ['previous_metric_name_1', 'previous_metric_name_2', 'previous_metric_name_3'];
@ -41,6 +41,133 @@ afterEach(() => {
jest.restoreAllMocks();
});
describe('filterMetricNames', () => {
const sampleMetrics = [
'http_requests_total',
'http_requests_failed',
'node_cpu_seconds_total',
'node_memory_usage_bytes',
'very_long_metric_name_with_many_underscores_and_detailed_description',
'metric_name_1_with_extra_terms_included',
];
describe('empty input', () => {
it('should return all metrics up to limit when input is empty', () => {
const result = filterMetricNames({
metricNames: sampleMetrics,
inputText: '',
limit: 3,
});
expect(result).toEqual(sampleMetrics.slice(0, 3));
});
it('should return all metrics when input is whitespace', () => {
const result = filterMetricNames({
metricNames: sampleMetrics,
inputText: ' ',
limit: 3,
});
expect(result).toEqual(sampleMetrics.slice(0, 3));
});
});
describe('simple searches (≤ 4 terms)', () => {
it('should match exact strings', () => {
const result = filterMetricNames({
metricNames: sampleMetrics,
inputText: 'http_requests_total',
limit: 10,
});
expect(result).toContainEqual('http_requests_total');
});
it('should match with single character errors', () => {
// substitution
let result = filterMetricNames({
metricNames: sampleMetrics,
inputText: 'http_requezts_total', // 's' replaced with 'z'
limit: 10,
});
expect(result).toContainEqual('http_requests_total');
// ransposition
result = filterMetricNames({
metricNames: sampleMetrics,
inputText: 'http_reqeust_total', // 'ue' swapped
limit: 10,
});
expect(result).toContainEqual('http_requests_total');
// deletion
result = filterMetricNames({
metricNames: sampleMetrics,
inputText: 'http_reqests_total', // missing 'u'
limit: 10,
});
expect(result).toContainEqual('http_requests_total');
// insertion
result = filterMetricNames({
metricNames: sampleMetrics,
inputText: 'http_reqquests_total', // extra 'q'
limit: 10,
});
expect(result).toContainEqual('http_requests_total');
});
it('should match partial strings', () => {
const result = filterMetricNames({
metricNames: sampleMetrics,
inputText: 'requests', // partial match
limit: 10,
});
expect(result).toContainEqual('http_requests_total');
expect(result).toContainEqual('http_requests_failed');
});
it('should not match with multiple errors', () => {
const result = filterMetricNames({
metricNames: sampleMetrics,
inputText: 'htp_reqests_total', // two errors: missing 't' and missing 'u'
limit: 10,
});
expect(result).not.toContainEqual('http_requests_total');
});
});
describe('complex searches (> 4 terms)', () => {
it('should use substring matching for each term', () => {
const result = filterMetricNames({
metricNames: sampleMetrics,
inputText: 'metric name 1 with extra terms',
limit: 10,
});
expect(result).toContainEqual('metric_name_1_with_extra_terms_included');
});
it('should return empty array when no metrics match all terms', () => {
const result = filterMetricNames({
metricNames: sampleMetrics,
inputText: 'metric name 1 with nonexistent terms',
limit: 10,
});
expect(result).toHaveLength(0);
});
it('should stop searching after limit is reached', () => {
const manyMetrics = Array.from({ length: 10 }, (_, i) => `metric_name_${i}_with_terms`);
const result = filterMetricNames({
metricNames: manyMetrics,
inputText: 'metric name with terms other words', // > 4 terms
limit: 3,
});
expect(result.length).toBeLessThanOrEqual(3);
});
});
});
type MetricNameSituation = Extract<Situation['type'], 'AT_ROOT' | 'EMPTY' | 'IN_FUNCTION'>;
const metricNameCompletionSituations = ['AT_ROOT', 'IN_FUNCTION', 'EMPTY'] as MetricNameSituation[];
@ -74,22 +201,22 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat
expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount);
});
it('should limit completions for metric names when the number of metric names is greater than the limit', async () => {
it('should limit completions for metric names when the number exceeds the limit', async () => {
const situation: Situation = {
type: situationType,
};
const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.beyondLimit.length);
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.beyondLimit);
// No text input
dataProvider.monacoSettings.setInputInRange('');
// Complex query
dataProvider.monacoSettings.setInputInRange('metric name one two three four five');
let completions = await getCompletions(situation, dataProvider);
expect(completions).toHaveLength(expectedCompletionsCount);
expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount);
// With text input (use fuzzy search)
dataProvider.monacoSettings.setInputInRange('name_1');
// Simple query with fuzzy match
dataProvider.monacoSettings.setInputInRange('metric_name_');
completions = await getCompletions(situation, dataProvider);
expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount);
expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount);
});
it('should enable autocomplete suggestions update when the number of metric names is greater than the limit', async () => {
@ -115,4 +242,35 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat
await getCompletions(situation, dataProvider);
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true);
});
it('should handle complex queries efficiently', async () => {
const situation: Situation = {
type: situationType,
};
const testMetrics = ['metric_name_1', 'metric_name_2', 'metric_name_1_with_extra_terms', 'unrelated_metric'];
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(testMetrics);
// Test with a complex query (> 4 terms)
dataProvider.monacoSettings.setInputInRange('metric name 1 with extra terms more');
const completions = await getCompletions(situation, dataProvider);
const metricCompletions = completions.filter((c) => c.type === 'METRIC_NAME');
expect(metricCompletions.some((c) => c.label === 'metric_name_1_with_extra_terms')).toBe(true);
});
it('should handle multiple term queries efficiently', async () => {
const situation: Situation = {
type: situationType,
};
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.beyondLimit);
// Test with multiple terms
dataProvider.monacoSettings.setInputInRange('metric name 1 2 3 4 5');
const completions = await getCompletions(situation, dataProvider);
const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.beyondLimit.length);
expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount);
});
});

View File

@ -22,7 +22,31 @@ type Completion = {
triggerOnInsert?: boolean;
};
const metricNamesSearchClient = new UFuzzy({ intraMode: 1 });
const metricNamesSearch = {
// see https://github.com/leeoniya/uFuzzy?tab=readme-ov-file#how-it-works for details
multiInsert: new UFuzzy({ intraMode: 0 }),
singleError: new UFuzzy({ intraMode: 1 }),
};
interface MetricFilterOptions {
metricNames: string[];
inputText: string;
limit: number;
}
export function filterMetricNames({ metricNames, inputText, limit }: MetricFilterOptions): string[] {
if (!inputText?.trim()) {
return metricNames.slice(0, limit);
}
const terms = metricNamesSearch.multiInsert.split(inputText); // e.g. 'some_metric_name or-another' -> ['some', 'metric', 'name', 'or', 'another']
const isComplexSearch = terms.length > 4;
const fuzzyResults = isComplexSearch
? metricNamesSearch.multiInsert.filter(metricNames, inputText) // for complex searches, prioritize performance by using MultiInsert fuzzy search
: metricNamesSearch.singleError.filter(metricNames, inputText); // for simple searches, prioritize flexibility by using SingleError fuzzy search
return fuzzyResults ? fuzzyResults.slice(0, limit).map((idx) => metricNames[idx]) : [];
}
// we order items like: history, functions, metrics
function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] {
@ -36,11 +60,11 @@ function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[]
monacoSettings.enableAutocompleteSuggestionsUpdate();
if (monacoSettings.inputInRange) {
metricNames =
metricNamesSearchClient
.filter(metricNames, monacoSettings.inputInRange)
?.slice(0, dataProvider.metricNamesSuggestionLimit)
.map((idx) => metricNames[idx]) ?? [];
metricNames = filterMetricNames({
metricNames,
inputText: monacoSettings.inputInRange,
limit: dataProvider.metricNamesSuggestionLimit,
});
} else {
metricNames = metricNames.slice(0, dataProvider.metricNamesSuggestionLimit);
}

View File

@ -6,7 +6,8 @@
"emitDeclarationOnly": true,
"isolatedModules": true,
"allowJs": true,
"rootDirs": ["."]
"rootDirs": ["."],
"resolveJsonModule": true
},
"exclude": ["dist/**/*"],
"extends": "@grafana/tsconfig",