[searchService] Dedupe shard error toasts (#131776)

* dedupe shard error toasts

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Matthew Kime 2022-10-17 19:14:08 -05:00 committed by GitHub
parent fabb3bc415
commit 4d301f7a5d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 94 additions and 25 deletions

View file

@ -45,13 +45,17 @@ const warnings: SearchResponseWarning[] = [
},
];
const sessionId = 'abcd';
describe('Filtering and showing warnings', () => {
const notifications = notificationServiceMock.createStartContract();
jest.useFakeTimers('modern');
describe('handleWarnings', () => {
const request = { body: {} };
beforeEach(() => {
jest.resetAllMocks();
jest.advanceTimersByTime(30000);
setNotifications(notifications);
(notifications.toasts.addWarning as jest.Mock).mockReset();
(extract.extractWarnings as jest.Mock).mockImplementation(() => warnings);
@ -60,10 +64,16 @@ describe('Filtering and showing warnings', () => {
test('should notify if timed out', () => {
(extract.extractWarnings as jest.Mock).mockImplementation(() => [warnings[0]]);
const response = { rawResponse: { timed_out: true } } as unknown as estypes.SearchResponse;
handleWarnings(request, response, theme);
handleWarnings({ request, response, theme });
// test debounce, addWarning should only be called once
handleWarnings({ request, response, theme });
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({ title: 'Something timed out!' });
// test debounce, call addWarning again due to sessionId
handleWarnings({ request, response, theme, sessionId });
expect(notifications.toasts.addWarning).toBeCalledTimes(2);
});
test('should notify if shards failed for unknown type/reason', () => {
@ -71,10 +81,16 @@ describe('Filtering and showing warnings', () => {
const response = {
rawResponse: { _shards: { failed: 1, total: 2, successful: 1, skipped: 1 } },
} as unknown as estypes.SearchResponse;
handleWarnings(request, response, theme);
handleWarnings({ request, response, theme });
// test debounce, addWarning should only be called once
handleWarnings({ request, response, theme });
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({ title: 'Some shards failed!' });
// test debounce, call addWarning again due to sessionId
handleWarnings({ request, response, theme, sessionId });
expect(notifications.toasts.addWarning).toBeCalledTimes(2);
});
test('should add mount point for shard modal failure button if warning.text is provided', () => {
@ -82,13 +98,19 @@ describe('Filtering and showing warnings', () => {
const response = {
rawResponse: { _shards: { failed: 1, total: 2, successful: 1, skipped: 1 } },
} as unknown as estypes.SearchResponse;
handleWarnings(request, response, theme);
handleWarnings({ request, response, theme });
// test debounce, addWarning should only be called once
handleWarnings({ request, response, theme });
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({
title: 'Some shards failed!',
text: expect.any(Function),
});
// test debounce, call addWarning again due to sessionId
handleWarnings({ request, response, theme, sessionId });
expect(notifications.toasts.addWarning).toBeCalledTimes(2);
});
test('should notify once if the response contains multiple failures', () => {
@ -96,7 +118,7 @@ describe('Filtering and showing warnings', () => {
const response = {
rawResponse: { _shards: { failed: 1, total: 2, successful: 1, skipped: 1 } },
} as unknown as estypes.SearchResponse;
handleWarnings(request, response, theme);
handleWarnings({ request, response, theme });
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({
@ -111,7 +133,7 @@ describe('Filtering and showing warnings', () => {
const response = {
rawResponse: { _shards: { failed: 1, total: 2, successful: 1, skipped: 1 } },
} as unknown as estypes.SearchResponse;
handleWarnings(request, response, theme, callback);
handleWarnings({ request, response, theme, callback });
expect(notifications.toasts.addWarning).toBeCalledTimes(1);
expect(notifications.toasts.addWarning).toBeCalledWith({ title: 'Some shards failed!' });
@ -122,7 +144,7 @@ describe('Filtering and showing warnings', () => {
const response = {
rawResponse: { _shards: { failed: 1, total: 2, successful: 1, skipped: 1 } },
} as unknown as estypes.SearchResponse;
handleWarnings(request, response, theme, callback);
handleWarnings({ request, response, theme, callback });
expect(notifications.toasts.addWarning).toBeCalledTimes(0);
});

View file

@ -7,10 +7,12 @@
*/
import { estypes } from '@elastic/elasticsearch';
import { debounce } from 'lodash';
import { EuiSpacer } from '@elastic/eui';
import { ThemeServiceStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import React from 'react';
import type { MountPoint } from '@kbn/core/public';
import { SearchRequest } from '..';
import { getNotifications } from '../../services';
import { ShardFailureOpenModalButton, ShardFailureRequest } from '../../shard_failure_modal';
@ -21,23 +23,53 @@ import {
} from '../types';
import { extractWarnings } from './extract_warnings';
const getDebouncedWarning = () => {
const addWarning = () => {
const { toasts } = getNotifications();
return debounce(toasts.addWarning.bind(toasts), 30000, {
leading: true,
});
};
const memory: Record<string, ReturnType<typeof addWarning>> = {};
return (
debounceKey: string,
title: string,
text?: string | MountPoint<HTMLElement> | undefined
) => {
memory[debounceKey] = memory[debounceKey] || addWarning();
return memory[debounceKey]({ title, text });
};
};
const debouncedWarningWithoutReason = getDebouncedWarning();
const debouncedTimeoutWarning = getDebouncedWarning();
const debouncedWarning = getDebouncedWarning();
/**
* @internal
* All warnings are expected to come from the same response. Therefore all "text" properties, which contain the
* response, will be the same.
*/
export function handleWarnings(
request: SearchRequest,
response: estypes.SearchResponse,
theme: ThemeServiceStart,
cb?: WarningHandlerCallback
) {
export function handleWarnings({
request,
response,
theme,
callback,
sessionId = '',
}: {
request: SearchRequest;
response: estypes.SearchResponse;
theme: ThemeServiceStart;
callback?: WarningHandlerCallback;
sessionId?: string;
}) {
const warnings = extractWarnings(response);
if (warnings.length === 0) {
return;
}
const internal = cb ? filterWarnings(warnings, cb) : warnings;
const internal = callback ? filterWarnings(warnings, callback) : warnings;
if (internal.length === 0) {
return;
}
@ -45,9 +77,7 @@ export function handleWarnings(
// timeout notification
const [timeout] = internal.filter((w) => w.type === 'timed_out');
if (timeout) {
getNotifications().toasts.addWarning({
title: timeout.message,
});
debouncedTimeoutWarning(sessionId + timeout.message, timeout.message);
}
// shard warning failure notification
@ -75,12 +105,12 @@ export function handleWarnings(
{ theme$: theme.theme$ }
);
getNotifications().toasts.addWarning({ title, text });
debouncedWarning(sessionId + warning.text, title, text);
return;
}
// timeout warning, or shard warning with no failure reason
getNotifications().toasts.addWarning({ title });
debouncedWarningWithoutReason(sessionId + title, title);
}
/**

View file

@ -27,6 +27,7 @@ describe('Search service', () => {
let mockCoreSetup: MockedKeys<CoreSetup>;
let mockCoreStart: MockedKeys<CoreStart>;
const initializerContext = coreMock.createPluginInitializerContext();
jest.useFakeTimers('modern');
initializerContext.config.get = jest.fn().mockReturnValue({
search: { aggs: { shardDelay: { enabled: false } }, sessions: { enabled: true } },
});
@ -35,6 +36,7 @@ describe('Search service', () => {
mockCoreSetup = coreMock.createSetup();
mockCoreStart = coreMock.createStart();
searchService = new SearchService(initializerContext);
jest.advanceTimersByTime(30000);
});
describe('setup()', () => {
@ -217,7 +219,13 @@ describe('Search service', () => {
const responder1 = inspector.adapter.start('request1');
const responder2 = inspector.adapter.start('request2');
responder1.ok(getMockResponseWithShards(shards));
responder2.ok(getMockResponseWithShards(shards));
responder2.ok({
json: {
rawResponse: {
timed_out: true,
},
},
});
data.showWarnings(inspector.adapter, callback);
@ -227,8 +235,7 @@ describe('Search service', () => {
text: expect.any(Function),
});
expect(notifications.toasts.addWarning).nthCalledWith(2, {
title: '2 of 4 shards failed',
text: expect.any(Function),
title: 'Data might be incomplete because your request timed out',
});
});
});

View file

@ -240,7 +240,12 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
onResponse: (request, response, options) => {
if (!options.disableShardFailureWarning) {
const { rawResponse } = response;
handleWarnings(request.body, rawResponse, theme);
handleWarnings({
request: request.body,
response: rawResponse,
theme,
sessionId: options.sessionId,
});
}
return response;
},
@ -271,7 +276,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
showError: (e) => {
this.searchInterceptor.showError(e);
},
showWarnings: (adapter, cb) => {
showWarnings: (adapter, callback) => {
adapter?.getRequests().forEach((request) => {
const rawResponse = (
request.response?.json as { rawResponse: estypes.SearchResponse | undefined }
@ -281,7 +286,12 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
return;
}
handleWarnings(request.json as SearchRequest, rawResponse, theme, cb);
handleWarnings({
request: request.json as SearchRequest,
response: rawResponse,
theme,
callback,
});
});
},
session: this.sessionService,

View file

@ -159,7 +159,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
toasts = await find.allByCssSelector(toastsSelector);
expect(toasts.length).to.be(2);
});
const expects = ['2 of 4 shards failed', 'Query result'];
const expects = ['Query result', '2 of 4 shards failed'];
await asyncForEach(toasts, async (t, index) => {
expect(await t.getVisibleText()).to.eql(expects[index]);
});