Alerting: Fix useAlertingQueryRunner re-rendering loop (#100206)

* Fix AlertingQueryRunner infinite re-rendering loop

* Update tests
This commit is contained in:
Konrad Lalik 2025-02-10 10:51:54 +01:00 committed by GitHub
parent 00155abf1b
commit 6723159b12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 65 additions and 84 deletions

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { FC, useEffect, useState } from 'react'; import { FC, useCallback, useEffect, useState } from 'react';
import { useAsync } from 'react-use'; import { useAsync } from 'react-use';
import { CoreApp, GrafanaTheme2, LoadingState, PanelData } from '@grafana/data'; import { CoreApp, GrafanaTheme2, LoadingState, PanelData } from '@grafana/data';
@ -51,39 +51,42 @@ export const RecordingRuleEditor: FC<RecordingRuleEditorProps> = ({
return getDataSourceSrv().get(dataSourceName); return getDataSourceSrv().get(dataSourceName);
}, [dataSourceName]); }, [dataSourceName]);
const handleChangedQuery = (changedQuery: DataQuery) => { const handleChangedQuery = useCallback(
if (!isPromOrLokiQuery(changedQuery) || !dataSource) { (changedQuery: DataQuery) => {
return; if (!isPromOrLokiQuery(changedQuery) || !dataSource) {
} return;
}
const [query] = queries; const [query] = queries;
const { uid: dataSourceId, type } = dataSource; const { uid: dataSourceId, type } = dataSource;
const isLoki = type === DataSourceType.Loki; const isLoki = type === DataSourceType.Loki;
const expr = changedQuery.expr; const expr = changedQuery.expr;
const merged = { const merged = {
...query, ...query,
...changedQuery, ...changedQuery,
datasourceUid: dataSourceId, datasourceUid: dataSourceId,
expr,
model: {
expr, expr,
datasource: changedQuery.datasource, model: {
refId: changedQuery.refId, expr,
editorMode: changedQuery.editorMode, datasource: changedQuery.datasource,
// Instant and range are used by Prometheus queries refId: changedQuery.refId,
instant: changedQuery.instant, editorMode: changedQuery.editorMode,
range: changedQuery.range, // Instant and range are used by Prometheus queries
// Query type is used by Loki queries instant: changedQuery.instant,
// On first render/when creating a recording rule, the query type is not set range: changedQuery.range,
// unless the user has changed it betwee range/instant. The cleanest way to handle this // Query type is used by Loki queries
// is to default to instant, or whatever the changed type is // On first render/when creating a recording rule, the query type is not set
queryType: isLoki ? changedQuery.queryType || LokiQueryType.Instant : changedQuery.queryType, // unless the user has changed it betwee range/instant. The cleanest way to handle this
legendFormat: changedQuery.legendFormat, // is to default to instant, or whatever the changed type is
}, queryType: isLoki ? changedQuery.queryType || LokiQueryType.Instant : changedQuery.queryType,
}; legendFormat: changedQuery.legendFormat,
onChangeQuery([merged]); },
}; };
onChangeQuery([merged]);
},
[dataSource, queries, onChangeQuery]
);
if (loading || dataSource?.name !== dataSourceName) { if (loading || dataSource?.name !== dataSourceName) {
return null; return null;

View File

@ -1,25 +1,21 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { useObservable } from 'react-use';
import { LoadingState, PanelData } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { Alert, Stack } from '@grafana/ui'; import { Alert, Stack } from '@grafana/ui';
import { CombinedRule } from 'app/types/unified-alerting'; import { CombinedRule } from 'app/types/unified-alerting';
import { GrafanaRuleQueryViewer, QueryPreview } from '../../../GrafanaRuleQueryViewer'; import { GrafanaRuleQueryViewer, QueryPreview } from '../../../GrafanaRuleQueryViewer';
import { useAlertQueriesStatus } from '../../../hooks/useAlertQueriesStatus'; import { useAlertQueriesStatus } from '../../../hooks/useAlertQueriesStatus';
import { AlertingQueryRunner } from '../../../state/AlertingQueryRunner';
import { alertRuleToQueries } from '../../../utils/query'; import { alertRuleToQueries } from '../../../utils/query';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules'; import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules';
import { useAlertQueryRunner } from '../../rule-editor/query-and-alert-condition/useAlertQueryRunner';
interface Props { interface Props {
rule: CombinedRule; rule: CombinedRule;
} }
const QueryResults = ({ rule }: Props) => { const QueryResults = ({ rule }: Props) => {
const runner = useMemo(() => new AlertingQueryRunner(), []); const { queryPreviewData, runQueries, isPreviewLoading } = useAlertQueryRunner();
const data = useObservable(runner.get());
const loadingData = isLoading(data);
const queries = useMemo(() => alertRuleToQueries(rule), [rule]); const queries = useMemo(() => alertRuleToQueries(rule), [rule]);
@ -31,9 +27,9 @@ const QueryResults = ({ rule }: Props) => {
if (rule && isGrafanaRulerRule(rule.rulerRule)) { if (rule && isGrafanaRulerRule(rule.rulerRule)) {
condition = rule.rulerRule.grafana_alert.condition; condition = rule.rulerRule.grafana_alert.condition;
} }
runner.run(queries, condition ?? 'A'); runQueries(queries, condition ?? 'A');
} }
}, [queries, allDataSourcesAvailable, rule, runner]); }, [queries, allDataSourcesAvailable, rule, runQueries]);
useEffect(() => { useEffect(() => {
if (allDataSourcesAvailable) { if (allDataSourcesAvailable) {
@ -41,15 +37,11 @@ const QueryResults = ({ rule }: Props) => {
} }
}, [allDataSourcesAvailable, onRunQueries]); }, [allDataSourcesAvailable, onRunQueries]);
useEffect(() => {
return () => runner.destroy();
}, [runner]);
const isFederatedRule = isFederatedRuleGroup(rule.group); const isFederatedRule = isFederatedRuleGroup(rule.group);
return ( return (
<> <>
{loadingData ? ( {isPreviewLoading ? (
'Loading...' 'Loading...'
) : ( ) : (
<> <>
@ -58,27 +50,30 @@ const QueryResults = ({ rule }: Props) => {
rule={rule} rule={rule}
condition={rule.rulerRule.grafana_alert.condition} condition={rule.rulerRule.grafana_alert.condition}
queries={queries} queries={queries}
evalDataByQuery={data} evalDataByQuery={queryPreviewData}
/> />
)} )}
{!isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && data && Object.keys(data).length > 0 && ( {!isGrafanaRulerRule(rule.rulerRule) &&
<Stack direction="column" gap={1}> !isFederatedRule &&
{queries.map((query) => { queryPreviewData &&
return ( Object.keys(queryPreviewData).length > 0 && (
<QueryPreview <Stack direction="column" gap={1}>
key={query.refId} {queries.map((query) => {
rule={rule} return (
refId={query.refId} <QueryPreview
model={query.model} key={query.refId}
dataSource={Object.values(config.datasources).find((ds) => ds.uid === query.datasourceUid)} rule={rule}
queryData={data[query.refId]} refId={query.refId}
relativeTimeRange={query.relativeTimeRange} model={query.model}
/> dataSource={Object.values(config.datasources).find((ds) => ds.uid === query.datasourceUid)}
); queryData={queryPreviewData[query.refId]}
})} relativeTimeRange={query.relativeTimeRange}
</Stack> />
)} );
})}
</Stack>
)}
{!isFederatedRule && !allDataSourcesAvailable && ( {!isFederatedRule && !allDataSourcesAvailable && (
<Alert title="Query not available" severity="warning"> <Alert title="Query not available" severity="warning">
Cannot display the query preview. Some of the data sources used in the queries are not available. Cannot display the query preview. Some of the data sources used in the queries are not available.
@ -90,12 +85,4 @@ const QueryResults = ({ rule }: Props) => {
); );
}; };
function isLoading(data?: Record<string, PanelData>): boolean {
if (!data) {
return true;
}
return !!Object.values(data).find((d) => d.state === LoadingState.Loading);
}
export { QueryResults }; export { QueryResults };

View File

@ -1,6 +1,6 @@
import { defaultsDeep } from 'lodash'; import { defaultsDeep } from 'lodash';
import { Observable, of, throwError } from 'rxjs'; import { Observable, TimeoutError, lastValueFrom, of, throwError } from 'rxjs';
import { delay, take } from 'rxjs/operators'; import { delay, take, timeout } from 'rxjs/operators';
import { createFetchResponse } from 'test/helpers/createFetchResponse'; import { createFetchResponse } from 'test/helpers/createFetchResponse';
import { import {
@ -200,7 +200,7 @@ describe('AlertingQueryRunner', () => {
}); });
}); });
it('should not execute if all queries fail filterQuery check', async () => { it('should not push any values if all queries fail filterQuery check', async () => {
const runner = new AlertingQueryRunner( const runner = new AlertingQueryRunner(
mockBackendSrv({ mockBackendSrv({
fetch: () => throwError(new Error("shouldn't happen")), fetch: () => throwError(new Error("shouldn't happen")),
@ -211,15 +211,7 @@ describe('AlertingQueryRunner', () => {
const data = runner.get(); const data = runner.get();
runner.run([createQuery('A'), createQuery('B')], 'B'); runner.run([createQuery('A'), createQuery('B')], 'B');
await expect(data.pipe(take(1))).toEmitValuesWith((values) => { await expect(lastValueFrom(data.pipe(timeout(200)))).rejects.toThrow(TimeoutError);
const [data] = values;
expect(data.A.state).toEqual(LoadingState.Done);
expect(data.A.series).toHaveLength(0);
expect(data.B.state).toEqual(LoadingState.Done);
expect(data.B.series).toHaveLength(0);
});
}); });
it('should skip hidden queries and descendant nodes', async () => { it('should skip hidden queries and descendant nodes', async () => {

View File

@ -51,11 +51,10 @@ export class AlertingQueryRunner {
} }
async run(queries: AlertQuery[], condition: string) { async run(queries: AlertQuery[], condition: string) {
const empty = initialState(queries, LoadingState.Done);
const queriesToRun = await this.prepareQueries(queries); const queriesToRun = await this.prepareQueries(queries);
if (queriesToRun.length === 0) { if (queriesToRun.length === 0) {
return this.subject.next(empty); return;
} }
this.subscription = runRequest(this.backendSrv, queriesToRun, condition).subscribe({ this.subscription = runRequest(this.backendSrv, queriesToRun, condition).subscribe({