= (context: ISearc
// src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:385:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:386:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:402:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:403:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:407:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:404:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:405:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromEvent" needs to be exported by the entry point index.d.ts
diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts
index f3d2d99af59989..1687d749f46e28 100644
--- a/src/plugins/data/public/search/index.ts
+++ b/src/plugins/data/public/search/index.ts
@@ -57,5 +57,6 @@ export {
} from './search_source';
export { SearchInterceptor } from './search_interceptor';
+export { RequestTimeoutError } from './request_timeout_error';
export { FetchOptions } from './fetch';
diff --git a/src/plugins/data/public/search/long_query_notification.tsx b/src/plugins/data/public/search/long_query_notification.tsx
new file mode 100644
index 00000000000000..590fee20db690f
--- /dev/null
+++ b/src/plugins/data/public/search/long_query_notification.tsx
@@ -0,0 +1,61 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import React from 'react';
+import { ApplicationStart } from 'kibana/public';
+import { toMountPoint } from '../../../kibana_react/public';
+
+interface Props {
+ application: ApplicationStart;
+}
+
+export function getLongQueryNotification(props: Props) {
+ return toMountPoint();
+}
+
+export function LongQueryNotification(props: Props) {
+ return (
+
+
+
+
+
+ {
+ await props.application.navigateToApp(
+ 'kibana#/management/elasticsearch/license_management'
+ );
+ }}
+ >
+
+
+
+
+
+ );
+}
diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts
index 12cf258759a99c..b70e889066a453 100644
--- a/src/plugins/data/public/search/mocks.ts
+++ b/src/plugins/data/public/search/mocks.ts
@@ -31,10 +31,8 @@ export const searchSetupMock = {
export const searchStartMock: jest.Mocked = {
aggs: searchAggsStartMock(),
+ setInterceptor: jest.fn(),
search: jest.fn(),
- cancel: jest.fn(),
- getPendingCount$: jest.fn(),
- runBeyondTimeout: jest.fn(),
__LEGACY: {
AggConfig: jest.fn() as any,
AggType: jest.fn(),
diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts
index a89d17464b9e0c..bd056271688c1c 100644
--- a/src/plugins/data/public/search/search_interceptor.test.ts
+++ b/src/plugins/data/public/search/search_interceptor.test.ts
@@ -18,27 +18,38 @@
*/
import { Observable, Subject } from 'rxjs';
+import { CoreStart } from '../../../../core/public';
+import { coreMock } from '../../../../core/public/mocks';
import { IKibanaSearchRequest } from '../../common/search';
import { RequestTimeoutError } from './request_timeout_error';
import { SearchInterceptor } from './search_interceptor';
jest.useFakeTimers();
-const flushPromises = () => new Promise(resolve => setImmediate(resolve));
const mockSearch = jest.fn();
let searchInterceptor: SearchInterceptor;
+let mockCoreStart: MockedKeys;
describe('SearchInterceptor', () => {
beforeEach(() => {
+ mockCoreStart = coreMock.createStart();
mockSearch.mockClear();
- searchInterceptor = new SearchInterceptor(1000);
+ searchInterceptor = new SearchInterceptor(
+ mockCoreStart.notifications.toasts,
+ mockCoreStart.application,
+ 1000
+ );
});
describe('search', () => {
test('should invoke `search` with the request', () => {
- mockSearch.mockReturnValue(new Observable());
+ const mockResponse = new Subject();
+ mockSearch.mockReturnValue(mockResponse.asObservable());
const mockRequest: IKibanaSearchRequest = {};
- searchInterceptor.search(mockSearch, mockRequest);
+ const response = searchInterceptor.search(mockSearch, mockRequest);
+ mockResponse.complete();
+
+ response.subscribe();
expect(mockSearch.mock.calls[0][0]).toBe(mockRequest);
});
@@ -92,44 +103,6 @@ describe('SearchInterceptor', () => {
});
});
- describe('cancelPending', () => {
- test('should abort all pending requests', async () => {
- mockSearch.mockReturnValue(new Observable());
-
- searchInterceptor.search(mockSearch, {});
- searchInterceptor.search(mockSearch, {});
- searchInterceptor.cancelPending();
-
- await flushPromises();
-
- const areAllRequestsAborted = mockSearch.mock.calls.every(([, { signal }]) => signal.aborted);
- expect(areAllRequestsAborted).toBe(true);
- });
- });
-
- describe('runBeyondTimeout', () => {
- test('should prevent the request from timing out', () => {
- const mockResponse = new Subject();
- mockSearch.mockReturnValue(mockResponse.asObservable());
- const response = searchInterceptor.search(mockSearch, {});
-
- setTimeout(searchInterceptor.runBeyondTimeout, 500);
- setTimeout(() => mockResponse.next('hi'), 250);
- setTimeout(() => mockResponse.complete(), 2000);
-
- const next = jest.fn();
- const complete = jest.fn();
- const error = jest.fn();
- response.subscribe({ next, error, complete });
-
- jest.advanceTimersByTime(2000);
-
- expect(next).toHaveBeenCalledWith('hi');
- expect(error).not.toHaveBeenCalled();
- expect(complete).toHaveBeenCalled();
- });
- });
-
describe('getPendingCount$', () => {
test('should observe the number of pending requests', () => {
let i = 0;
diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts
index 3f83214f6050c3..d83ddab807bc5a 100644
--- a/src/plugins/data/public/search/search_interceptor.ts
+++ b/src/plugins/data/public/search/search_interceptor.ts
@@ -17,51 +17,59 @@
* under the License.
*/
-import { BehaviorSubject, fromEvent, throwError } from 'rxjs';
-import { mergeMap, takeUntil, finalize } from 'rxjs/operators';
+import { BehaviorSubject, throwError, timer, Subscription, defer, fromEvent } from 'rxjs';
+import { takeUntil, finalize, filter, mergeMapTo } from 'rxjs/operators';
+import { ApplicationStart, Toast, ToastsStart } from 'kibana/public';
import { getCombinedSignal } from '../../common/utils';
import { IKibanaSearchRequest } from '../../common/search';
import { ISearchGeneric, ISearchOptions } from './i_search';
import { RequestTimeoutError } from './request_timeout_error';
+import { getLongQueryNotification } from './long_query_notification';
export class SearchInterceptor {
/**
* `abortController` used to signal all searches to abort.
*/
- private abortController = new AbortController();
+ protected abortController = new AbortController();
/**
- * Observable that emits when the number of pending requests changes.
+ * The number of pending search requests.
*/
- private pendingCount$ = new BehaviorSubject(0);
+ private pendingCount = 0;
/**
- * The IDs from `setTimeout` when scheduling the automatic timeout for each request.
+ * Observable that emits when the number of pending requests changes.
*/
- private timeoutIds: Set = new Set();
+ private pendingCount$ = new BehaviorSubject(this.pendingCount);
/**
- * This class should be instantiated with a `requestTimeout` corresponding with how many ms after
- * requests are initiated that they should automatically cancel.
- * @param requestTimeout Usually config value `elasticsearch.requestTimeout`
+ * The subscriptions from scheduling the automatic timeout for each request.
*/
- constructor(private readonly requestTimeout?: number) {}
+ protected timeoutSubscriptions: Set = new Set();
/**
- * Abort our `AbortController`, which in turn aborts any intercepted searches.
+ * The current long-running toast (if there is one).
*/
- public cancelPending = () => {
- this.abortController.abort();
- this.abortController = new AbortController();
- };
+ protected longRunningToast?: Toast;
/**
- * Un-schedule timing out all of the searches intercepted.
+ * This class should be instantiated with a `requestTimeout` corresponding with how many ms after
+ * requests are initiated that they should automatically cancel.
+ * @param toasts The `core.notifications.toasts` service
+ * @param application The `core.application` service
+ * @param requestTimeout Usually config value `elasticsearch.requestTimeout`
*/
- public runBeyondTimeout = () => {
- this.timeoutIds.forEach(clearTimeout);
- this.timeoutIds.clear();
- };
+ constructor(
+ protected readonly toasts: ToastsStart,
+ protected readonly application: ApplicationStart,
+ protected readonly requestTimeout?: number
+ ) {
+ // When search requests go out, a notification is scheduled allowing users to continue the
+ // request past the timeout. When all search requests complete, we remove the notification.
+ this.getPendingCount$()
+ .pipe(filter(count => count === 0))
+ .subscribe(this.hideToast);
+ }
/**
* Returns an `Observable` over the current number of pending searches. This could mean that one
@@ -81,41 +89,66 @@ export class SearchInterceptor {
request: IKibanaSearchRequest,
options?: ISearchOptions
) => {
- // Schedule this request to automatically timeout after some interval
- const timeoutController = new AbortController();
- const { signal: timeoutSignal } = timeoutController;
- const timeoutId = window.setTimeout(() => {
- timeoutController.abort();
- }, this.requestTimeout);
- this.addTimeoutId(timeoutId);
-
- // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs:
- // 1. The user manually aborts (via `cancelPending`)
- // 2. The request times out
- // 3. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines)
- const signals = [this.abortController.signal, timeoutSignal, options?.signal].filter(
- Boolean
- ) as AbortSignal[];
- const combinedSignal = getCombinedSignal(signals);
-
- // If the request timed out, throw a `RequestTimeoutError`
- const timeoutError$ = fromEvent(timeoutSignal, 'abort').pipe(
- mergeMap(() => throwError(new RequestTimeoutError()))
- );
+ // Defer the following logic until `subscribe` is actually called
+ return defer(() => {
+ this.pendingCount$.next(++this.pendingCount);
- return search(request as any, { ...options, signal: combinedSignal }).pipe(
- takeUntil(timeoutError$),
- finalize(() => this.removeTimeoutId(timeoutId))
- );
+ // Schedule this request to automatically timeout after some interval
+ const timeoutController = new AbortController();
+ const { signal: timeoutSignal } = timeoutController;
+ const timeout$ = timer(this.requestTimeout);
+ const subscription = timeout$.subscribe(() => timeoutController.abort());
+ this.timeoutSubscriptions.add(subscription);
+
+ // If the request timed out, throw a `RequestTimeoutError`
+ const timeoutError$ = fromEvent(timeoutSignal, 'abort').pipe(
+ mergeMapTo(throwError(new RequestTimeoutError()))
+ );
+
+ // Schedule the notification to allow users to cancel or wait beyond the timeout
+ const notificationSubscription = timer(10000).subscribe(this.showToast);
+
+ // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs:
+ // 1. The user manually aborts (via `cancelPending`)
+ // 2. The request times out
+ // 3. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines)
+ const signals = [
+ this.abortController.signal,
+ timeoutSignal,
+ ...(options?.signal ? [options.signal] : []),
+ ];
+ const combinedSignal = getCombinedSignal(signals);
+
+ return search(request as any, { ...options, signal: combinedSignal }).pipe(
+ takeUntil(timeoutError$),
+ finalize(() => {
+ this.pendingCount$.next(--this.pendingCount);
+ this.timeoutSubscriptions.delete(subscription);
+ notificationSubscription.unsubscribe();
+ })
+ );
+ });
};
- private addTimeoutId(id: number) {
- this.timeoutIds.add(id);
- this.pendingCount$.next(this.timeoutIds.size);
- }
+ protected showToast = () => {
+ if (this.longRunningToast) return;
+ this.longRunningToast = this.toasts.addInfo(
+ {
+ title: 'Your query is taking awhile',
+ text: getLongQueryNotification({
+ application: this.application,
+ }),
+ },
+ {
+ toastLifeTimeMs: Infinity,
+ }
+ );
+ };
- private removeTimeoutId(id: number) {
- this.timeoutIds.delete(id);
- this.pendingCount$.next(this.timeoutIds.size);
- }
+ protected hideToast = () => {
+ if (this.longRunningToast) {
+ this.toasts.remove(this.longRunningToast);
+ delete this.longRunningToast;
+ }
+ };
}
diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts
index 62c7e0468bb886..311a8a2fc6f60c 100644
--- a/src/plugins/data/public/search/search_service.ts
+++ b/src/plugins/data/public/search/search_service.ts
@@ -58,6 +58,7 @@ export class SearchService implements Plugin {
private esClient?: LegacyApiCaller;
private readonly aggTypesRegistry = new AggTypesRegistry();
+ private searchInterceptor!: SearchInterceptor;
private registerSearchStrategyProvider = (
name: T,
@@ -98,7 +99,9 @@ export class SearchService implements Plugin {
* TODO: Make this modular so that apps can opt in/out of search collection, or even provide
* their own search collector instances
*/
- const searchInterceptor = new SearchInterceptor(
+ this.searchInterceptor = new SearchInterceptor(
+ core.notifications.toasts,
+ core.application,
core.injectedMetadata.getInjectedVar('esRequestTimeout') as number
);
@@ -114,16 +117,17 @@ export class SearchService implements Plugin {
},
types: aggTypesStart,
},
- cancel: () => searchInterceptor.cancelPending(),
- getPendingCount$: () => searchInterceptor.getPendingCount$(),
- runBeyondTimeout: () => searchInterceptor.runBeyondTimeout(),
search: (request, options, strategyName) => {
const strategyProvider = this.getSearchStrategy(strategyName || DEFAULT_SEARCH_STRATEGY);
const { search } = strategyProvider({
core,
getSearchStrategy: this.getSearchStrategy,
});
- return searchInterceptor.search(search as any, request, options);
+ return this.searchInterceptor.search(search as any, request, options);
+ },
+ setInterceptor: (searchInterceptor: SearchInterceptor) => {
+ // TODO: should an intercepror have a destroy method?
+ this.searchInterceptor = searchInterceptor;
},
__LEGACY: {
esClient: this.esClient!,
diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts
index 1b551f978b9716..03cbfa9f8ed84a 100644
--- a/src/plugins/data/public/search/types.ts
+++ b/src/plugins/data/public/search/types.ts
@@ -17,12 +17,12 @@
* under the License.
*/
-import { Observable } from 'rxjs';
import { CoreStart } from 'kibana/public';
import { SearchAggsSetup, SearchAggsStart, SearchAggsStartLegacy } from './aggs';
import { ISearch, ISearchGeneric } from './i_search';
import { TStrategyTypes } from './strategy_types';
import { LegacyApiCaller } from './es_client';
+import { SearchInterceptor } from './search_interceptor';
export interface ISearchContext {
core: CoreStart;
@@ -87,9 +87,7 @@ export interface ISearchSetup {
export interface ISearchStart {
aggs: SearchAggsStart;
- cancel: () => void;
- getPendingCount$: () => Observable;
- runBeyondTimeout: () => void;
+ setInterceptor: (searchInterceptor: SearchInterceptor) => void;
search: ISearchGeneric;
__LEGACY: ISearchStartLegacy & SearchAggsStartLegacy;
}
diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts
index 6316d87c505193..72e0817eea8df2 100644
--- a/x-pack/plugins/data_enhanced/public/plugin.ts
+++ b/x-pack/plugins/data_enhanced/public/plugin.ts
@@ -17,6 +17,7 @@ import {
asyncSearchStrategyProvider,
enhancedEsSearchStrategyProvider,
} from './search';
+import { EnhancedSearchInterceptor } from './search/search_interceptor';
export interface DataEnhancedSetupDependencies {
data: DataPublicPluginSetup;
@@ -45,5 +46,11 @@ export class DataEnhancedPlugin implements Plugin {
public start(core: CoreStart, plugins: DataEnhancedStartDependencies) {
setAutocompleteService(plugins.data.autocomplete);
+ const enhancedSearchInterceptor = new EnhancedSearchInterceptor(
+ core.notifications.toasts,
+ core.application,
+ core.injectedMetadata.getInjectedVar('esRequestTimeout') as number
+ );
+ plugins.data.search.setInterceptor(enhancedSearchInterceptor);
}
}
diff --git a/x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx b/x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx
new file mode 100644
index 00000000000000..325cf1145fa5f1
--- /dev/null
+++ b/x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx
@@ -0,0 +1,47 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import React from 'react';
+import { toMountPoint } from '../../../../../src/plugins/kibana_react/public';
+
+interface Props {
+ cancel: () => void;
+ runBeyondTimeout: () => void;
+}
+
+export function getLongQueryNotification(props: Props) {
+ return toMountPoint(
+
+ );
+}
+
+export function LongQueryNotification(props: Props) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts
new file mode 100644
index 00000000000000..1e554d3ff2d867
--- /dev/null
+++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Observable, Subject } from 'rxjs';
+import { coreMock } from '../../../../../src/core/public/mocks';
+import { EnhancedSearchInterceptor } from './search_interceptor';
+import { CoreStart } from 'kibana/public';
+
+jest.useFakeTimers();
+
+const flushPromises = () => new Promise(resolve => setImmediate(resolve));
+const mockSearch = jest.fn();
+let searchInterceptor: EnhancedSearchInterceptor;
+let mockCoreStart: MockedKeys;
+
+describe('EnhancedSearchInterceptor', () => {
+ beforeEach(() => {
+ mockCoreStart = coreMock.createStart();
+ mockSearch.mockClear();
+ searchInterceptor = new EnhancedSearchInterceptor(
+ mockCoreStart.notifications.toasts,
+ mockCoreStart.application,
+ 1000
+ );
+ });
+
+ describe('cancelPending', () => {
+ test('should abort all pending requests', async () => {
+ mockSearch.mockReturnValue(new Observable());
+
+ searchInterceptor.search(mockSearch, {});
+ searchInterceptor.search(mockSearch, {});
+ searchInterceptor.cancelPending();
+
+ await flushPromises();
+
+ const areAllRequestsAborted = mockSearch.mock.calls.every(([, { signal }]) => signal.aborted);
+ expect(areAllRequestsAborted).toBe(true);
+ });
+ });
+
+ describe('runBeyondTimeout', () => {
+ test('should prevent the request from timing out', () => {
+ const mockResponse = new Subject();
+ mockSearch.mockReturnValue(mockResponse.asObservable());
+ const response = searchInterceptor.search(mockSearch, {});
+
+ setTimeout(searchInterceptor.runBeyondTimeout, 500);
+ setTimeout(() => mockResponse.next('hi'), 250);
+ setTimeout(() => mockResponse.complete(), 2000);
+
+ const next = jest.fn();
+ const complete = jest.fn();
+ const error = jest.fn();
+ response.subscribe({ next, error, complete });
+
+ jest.advanceTimersByTime(2000);
+
+ expect(next).toHaveBeenCalledWith('hi');
+ expect(error).not.toHaveBeenCalled();
+ expect(complete).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts
new file mode 100644
index 00000000000000..38452dee9a2da9
--- /dev/null
+++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ApplicationStart, ToastsStart } from 'kibana/public';
+import { getLongQueryNotification } from './long_query_notification';
+import { SearchInterceptor } from '../../../../../src/plugins/data/public';
+
+export class EnhancedSearchInterceptor extends SearchInterceptor {
+ /**
+ * This class should be instantiated with a `requestTimeout` corresponding with how many ms after
+ * requests are initiated that they should automatically cancel.
+ * @param toasts The `core.notifications.toasts` service
+ * @param application The `core.application` service
+ * @param requestTimeout Usually config value `elasticsearch.requestTimeout`
+ */
+ constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number) {
+ super(toasts, application, requestTimeout);
+ }
+
+ /**
+ * Abort our `AbortController`, which in turn aborts any intercepted searches.
+ */
+ public cancelPending = () => {
+ this.hideToast();
+ this.abortController.abort();
+ this.abortController = new AbortController();
+ };
+
+ /**
+ * Un-schedule timing out all of the searches intercepted.
+ */
+ public runBeyondTimeout = () => {
+ this.hideToast();
+ this.timeoutSubscriptions.forEach(subscription => subscription.unsubscribe());
+ this.timeoutSubscriptions.clear();
+ };
+
+ protected showToast = () => {
+ if (this.longRunningToast) return;
+ this.longRunningToast = this.toasts.addInfo(
+ {
+ title: 'Your query is taking awhile',
+ text: getLongQueryNotification({
+ cancel: this.cancelPending,
+ runBeyondTimeout: this.runBeyondTimeout,
+ }),
+ },
+ {
+ toastLifeTimeMs: Infinity,
+ }
+ );
+ };
+}