diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md
index 5cdf938a9e47f..74e820d72318a 100644
--- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md
@@ -7,7 +7,7 @@
Signature:
```typescript
-SearchBar: React.ComponentClass, "query" | "isLoading" | "indexPatterns" | "filters" | "onQueryChange" | "customSubmitButton" | "screenTitle" | "dataTestSubj" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "refreshInterval" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "onRefresh" | "timeHistory" | "onFiltersUpdated" | "onRefreshChange">, any> & {
- WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>;
+SearchBar: React.ComponentClass, "query" | "indexPatterns" | "filters" | "onQueryChange" | "customSubmitButton" | "screenTitle" | "dataTestSubj" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "refreshInterval" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "onRefresh" | "timeHistory" | "onFiltersUpdated" | "onRefreshChange">, any> & {
+ WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>;
}
```
diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.icancel.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.icancel.md
deleted file mode 100644
index 27141c68ae1a7..0000000000000
--- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.icancel.md
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ICancel](./kibana-plugin-plugins-data-server.icancel.md)
-
-## ICancel type
-
-Signature:
-
-```typescript
-export declare type ICancel = (id: string) => Promise;
-```
diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchcancel.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchcancel.md
new file mode 100644
index 0000000000000..99c30515e8da6
--- /dev/null
+++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchcancel.md
@@ -0,0 +1,11 @@
+
+
+[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchCancel](./kibana-plugin-plugins-data-server.isearchcancel.md)
+
+## ISearchCancel type
+
+Signature:
+
+```typescript
+export declare type ISearchCancel = (id: string) => Promise;
+```
diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts
index cf8c0bfe3d434..e4a663a1599f1 100644
--- a/src/plugins/data/common/index.ts
+++ b/src/plugins/data/common/index.ts
@@ -26,3 +26,4 @@ export * from './query';
export * from './search';
export * from './search/aggs';
export * from './types';
+export * from './utils';
diff --git a/src/plugins/data/common/utils/abort_utils.test.ts b/src/plugins/data/common/utils/abort_utils.test.ts
new file mode 100644
index 0000000000000..d2a25f2c2dd52
--- /dev/null
+++ b/src/plugins/data/common/utils/abort_utils.test.ts
@@ -0,0 +1,114 @@
+/*
+ * 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 { AbortError, toPromise, getCombinedSignal } from './abort_utils';
+
+jest.useFakeTimers();
+
+const flushPromises = () => new Promise(resolve => setImmediate(resolve));
+
+describe('AbortUtils', () => {
+ describe('AbortError', () => {
+ test('should preserve `message`', () => {
+ const message = 'my error message';
+ const error = new AbortError(message);
+ expect(error.message).toBe(message);
+ });
+
+ test('should have a name of "AbortError"', () => {
+ const error = new AbortError();
+ expect(error.name).toBe('AbortError');
+ });
+ });
+
+ describe('toPromise', () => {
+ describe('resolves', () => {
+ test('should not resolve if the signal does not abort', async () => {
+ const controller = new AbortController();
+ const promise = toPromise(controller.signal);
+ const whenResolved = jest.fn();
+ promise.then(whenResolved);
+ await flushPromises();
+ expect(whenResolved).not.toBeCalled();
+ });
+
+ test('should resolve if the signal does abort', async () => {
+ const controller = new AbortController();
+ const promise = toPromise(controller.signal);
+ const whenResolved = jest.fn();
+ promise.then(whenResolved);
+ controller.abort();
+ await flushPromises();
+ expect(whenResolved).toBeCalled();
+ });
+ });
+
+ describe('rejects', () => {
+ test('should not reject if the signal does not abort', async () => {
+ const controller = new AbortController();
+ const promise = toPromise(controller.signal, true);
+ const whenRejected = jest.fn();
+ promise.catch(whenRejected);
+ await flushPromises();
+ expect(whenRejected).not.toBeCalled();
+ });
+
+ test('should reject if the signal does abort', async () => {
+ const controller = new AbortController();
+ const promise = toPromise(controller.signal, true);
+ const whenRejected = jest.fn();
+ promise.catch(whenRejected);
+ controller.abort();
+ await flushPromises();
+ expect(whenRejected).toBeCalled();
+ });
+ });
+ });
+
+ describe('getCombinedSignal', () => {
+ test('should return an AbortSignal', () => {
+ const signal = getCombinedSignal([]);
+ expect(signal instanceof AbortSignal).toBe(true);
+ });
+
+ test('should not abort if none of the signals abort', async () => {
+ const controller1 = new AbortController();
+ const controller2 = new AbortController();
+ setTimeout(() => controller1.abort(), 2000);
+ setTimeout(() => controller2.abort(), 1000);
+ const signal = getCombinedSignal([controller1.signal, controller2.signal]);
+ expect(signal.aborted).toBe(false);
+ jest.advanceTimersByTime(500);
+ await flushPromises();
+ expect(signal.aborted).toBe(false);
+ });
+
+ test('should abort when the first signal aborts', async () => {
+ const controller1 = new AbortController();
+ const controller2 = new AbortController();
+ setTimeout(() => controller1.abort(), 2000);
+ setTimeout(() => controller2.abort(), 1000);
+ const signal = getCombinedSignal([controller1.signal, controller2.signal]);
+ expect(signal.aborted).toBe(false);
+ jest.advanceTimersByTime(1000);
+ await flushPromises();
+ expect(signal.aborted).toBe(true);
+ });
+ });
+});
diff --git a/src/plugins/data/common/utils/abort_utils.ts b/src/plugins/data/common/utils/abort_utils.ts
new file mode 100644
index 0000000000000..5051515f3a826
--- /dev/null
+++ b/src/plugins/data/common/utils/abort_utils.ts
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ */
+
+/**
+ * Class used to signify that something was aborted. Useful for applications to conditionally handle
+ * this type of error differently than other errors.
+ */
+export class AbortError extends Error {
+ constructor(message = 'Aborted') {
+ super(message);
+ this.message = message;
+ this.name = 'AbortError';
+ }
+}
+
+/**
+ * Returns a `Promise` corresponding with when the given `AbortSignal` is aborted. Useful for
+ * situations when you might need to `Promise.race` multiple `AbortSignal`s, or an `AbortSignal`
+ * with any other expected errors (or completions).
+ * @param signal The `AbortSignal` to generate the `Promise` from
+ * @param shouldReject If `false`, the promise will be resolved, otherwise it will be rejected
+ */
+export function toPromise(signal: AbortSignal, shouldReject = false) {
+ return new Promise((resolve, reject) => {
+ const action = shouldReject ? reject : resolve;
+ if (signal.aborted) action();
+ signal.addEventListener('abort', action);
+ });
+}
+
+/**
+ * Returns an `AbortSignal` that will be aborted when the first of the given signals aborts.
+ * @param signals
+ */
+export function getCombinedSignal(signals: AbortSignal[]) {
+ const promises = signals.map(signal => toPromise(signal));
+ const controller = new AbortController();
+ Promise.race(promises).then(() => controller.abort());
+ return controller.signal;
+}
diff --git a/src/plugins/data/common/utils/index.ts b/src/plugins/data/common/utils/index.ts
index 8b8686c51b9c1..33989f3ad50a7 100644
--- a/src/plugins/data/common/utils/index.ts
+++ b/src/plugins/data/common/utils/index.ts
@@ -19,3 +19,4 @@
/** @internal */
export { shortenDottedString } from './shorten_dotted_string';
+export { AbortError, toPromise, getCombinedSignal } from './abort_utils';
diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts
index c5cff1c5c68d9..454827f8c11b4 100644
--- a/src/plugins/data/public/mocks.ts
+++ b/src/plugins/data/public/mocks.ts
@@ -19,9 +19,7 @@
import { Plugin, DataPublicPluginSetup, DataPublicPluginStart, IndexPatternsContract } from '.';
import { fieldFormatsMock } from '../common/field_formats/mocks';
-import { searchSetupMock } from './search/mocks';
-import { AggTypeFieldFilters } from './search/aggs';
-import { searchAggsStartMock } from './search/aggs/mocks';
+import { searchSetupMock, searchStartMock } from './search/mocks';
import { queryServiceMock } from './query/mocks';
export type Setup = jest.Mocked>;
@@ -35,59 +33,28 @@ const autocompleteMock: any = {
const createSetupContract = (): Setup => {
const querySetupMock = queryServiceMock.createSetupContract();
- const setupContract = {
+ return {
autocomplete: autocompleteMock,
search: searchSetupMock,
fieldFormats: fieldFormatsMock as DataPublicPluginSetup['fieldFormats'],
query: querySetupMock,
- __LEGACY: {
- esClient: {
- search: jest.fn(),
- msearch: jest.fn(),
- },
- },
};
-
- return setupContract;
};
const createStartContract = (): Start => {
const queryStartMock = queryServiceMock.createStartContract();
- const startContract = {
+ return {
actions: {
- createFiltersFromEvent: jest.fn().mockResolvedValue(['yes']),
+ createFiltersFromEvent: jest.fn(),
},
autocomplete: autocompleteMock,
- getSuggestions: jest.fn(),
- search: {
- aggs: searchAggsStartMock(),
- search: jest.fn(),
- __LEGACY: {
- AggConfig: jest.fn() as any,
- AggType: jest.fn(),
- aggTypeFieldFilters: new AggTypeFieldFilters(),
- FieldParamType: jest.fn(),
- MetricAggType: jest.fn(),
- parentPipelineAggHelper: jest.fn() as any,
- siblingPipelineAggHelper: jest.fn() as any,
- esClient: {
- search: jest.fn(),
- msearch: jest.fn(),
- },
- },
- },
+ search: searchStartMock,
fieldFormats: fieldFormatsMock as DataPublicPluginStart['fieldFormats'],
query: queryStartMock,
ui: {
IndexPatternSelect: jest.fn(),
SearchBar: jest.fn(),
},
- __LEGACY: {
- esClient: {
- search: jest.fn(),
- msearch: jest.fn(),
- },
- },
indexPatterns: ({
make: () => ({
fieldsFetcher: {
@@ -97,7 +64,6 @@ const createStartContract = (): Start => {
get: jest.fn().mockReturnValue(Promise.resolve({})),
} as unknown) as IndexPatternsContract,
};
- return startContract;
};
export { searchSourceMock } from './search/mocks';
diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md
index 333b13eedc17a..73d17b09aef27 100644
--- a/src/plugins/data/public/public.api.md
+++ b/src/plugins/data/public/public.api.md
@@ -1531,8 +1531,8 @@ export const search: {
// Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
-export const SearchBar: React.ComponentClass, "query" | "isLoading" | "indexPatterns" | "filters" | "onQueryChange" | "customSubmitButton" | "screenTitle" | "dataTestSubj" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "refreshInterval" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "onRefresh" | "timeHistory" | "onFiltersUpdated" | "onRefreshChange">, any> & {
- WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>;
+export const SearchBar: React.ComponentClass, "query" | "indexPatterns" | "filters" | "onQueryChange" | "customSubmitButton" | "screenTitle" | "dataTestSubj" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "refreshInterval" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "onRefresh" | "timeHistory" | "onFiltersUpdated" | "onRefreshChange">, any> & {
+ WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>;
};
// Warning: (ae-forgotten-export) The symbol "SearchBarOwnProps" 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 ac72cfd6f62ca..f3d2d99af5998 100644
--- a/src/plugins/data/public/search/index.ts
+++ b/src/plugins/data/public/search/index.ts
@@ -56,4 +56,6 @@ export {
SortDirection,
} from './search_source';
+export { SearchInterceptor } from './search_interceptor';
+
export { FetchOptions } from './fetch';
diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts
index 71b4eece91cef..056119ba2a899 100644
--- a/src/plugins/data/public/search/mocks.ts
+++ b/src/plugins/data/public/search/mocks.ts
@@ -17,12 +17,38 @@
* under the License.
*/
-import { searchAggsSetupMock } from './aggs/mocks';
+import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks';
+import { AggTypeFieldFilters } from './aggs/param_types/filter';
+import { ISearchSetup, ISearchStart } from './types';
export * from './search_source/mocks';
-export const searchSetupMock = {
+export const searchSetupMock: jest.Mocked = {
aggs: searchAggsSetupMock(),
- registerSearchStrategyContext: jest.fn(),
registerSearchStrategyProvider: jest.fn(),
};
+
+export const searchStartMock: jest.Mocked = {
+ cancelPendingSearches: jest.fn(),
+ getPendingSearchesCount$: jest.fn(() => {
+ return {
+ subscribe: jest.fn(),
+ } as any;
+ }),
+ runBeyondTimeout: jest.fn(),
+ aggs: searchAggsStartMock(),
+ search: jest.fn(),
+ __LEGACY: {
+ AggConfig: jest.fn() as any,
+ AggType: jest.fn(),
+ aggTypeFieldFilters: new AggTypeFieldFilters(),
+ FieldParamType: jest.fn(),
+ MetricAggType: jest.fn(),
+ parentPipelineAggHelper: jest.fn() as any,
+ siblingPipelineAggHelper: jest.fn() as any,
+ esClient: {
+ search: jest.fn(),
+ msearch: jest.fn(),
+ },
+ },
+};
diff --git a/src/plugins/data/public/search/request_timeout_error.ts b/src/plugins/data/public/search/request_timeout_error.ts
new file mode 100644
index 0000000000000..92894deb4f0ff
--- /dev/null
+++ b/src/plugins/data/public/search/request_timeout_error.ts
@@ -0,0 +1,30 @@
+/*
+ * 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.
+ */
+
+/**
+ * Class used to signify that a request timed out. Useful for applications to conditionally handle
+ * this type of error differently than other errors.
+ */
+export class RequestTimeoutError extends Error {
+ constructor(message = 'Request timed out') {
+ super(message);
+ this.message = message;
+ this.name = 'RequestTimeoutError';
+ }
+}
diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts
new file mode 100644
index 0000000000000..a89d17464b9e0
--- /dev/null
+++ b/src/plugins/data/public/search/search_interceptor.test.ts
@@ -0,0 +1,157 @@
+/*
+ * 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 { Observable, Subject } from 'rxjs';
+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;
+
+describe('SearchInterceptor', () => {
+ beforeEach(() => {
+ mockSearch.mockClear();
+ searchInterceptor = new SearchInterceptor(1000);
+ });
+
+ describe('search', () => {
+ test('should invoke `search` with the request', () => {
+ mockSearch.mockReturnValue(new Observable());
+ const mockRequest: IKibanaSearchRequest = {};
+ searchInterceptor.search(mockSearch, mockRequest);
+ expect(mockSearch.mock.calls[0][0]).toBe(mockRequest);
+ });
+
+ test('should mirror the observable to completion if the request does not time out', () => {
+ const mockResponse = new Subject();
+ mockSearch.mockReturnValue(mockResponse.asObservable());
+ const response = searchInterceptor.search(mockSearch, {});
+
+ setTimeout(() => mockResponse.next('hi'), 250);
+ setTimeout(() => mockResponse.complete(), 500);
+
+ const next = jest.fn();
+ const complete = jest.fn();
+ response.subscribe({ next, complete });
+
+ jest.advanceTimersByTime(1000);
+
+ expect(next).toHaveBeenCalledWith('hi');
+ expect(complete).toHaveBeenCalled();
+ });
+
+ test('should mirror the observable to error if the request does not time out', () => {
+ const mockResponse = new Subject();
+ mockSearch.mockReturnValue(mockResponse.asObservable());
+ const response = searchInterceptor.search(mockSearch, {});
+
+ setTimeout(() => mockResponse.next('hi'), 250);
+ setTimeout(() => mockResponse.error('error'), 500);
+
+ const next = jest.fn();
+ const error = jest.fn();
+ response.subscribe({ next, error });
+
+ jest.advanceTimersByTime(1000);
+
+ expect(next).toHaveBeenCalledWith('hi');
+ expect(error).toHaveBeenCalledWith('error');
+ });
+
+ test('should return a `RequestTimeoutError` if the request times out', () => {
+ mockSearch.mockReturnValue(new Observable());
+ const response = searchInterceptor.search(mockSearch, {});
+
+ const error = jest.fn();
+ response.subscribe({ error });
+
+ jest.advanceTimersByTime(1000);
+
+ expect(error).toHaveBeenCalled();
+ expect(error.mock.calls[0][0] instanceof RequestTimeoutError).toBe(true);
+ });
+ });
+
+ 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;
+ const mockResponses = [new Subject(), new Subject()];
+ mockSearch.mockImplementation(() => mockResponses[i++]);
+
+ const pendingCount$ = searchInterceptor.getPendingCount$();
+
+ const next = jest.fn();
+ pendingCount$.subscribe(next);
+
+ const error = jest.fn();
+ searchInterceptor.search(mockSearch, {}).subscribe({ error });
+ searchInterceptor.search(mockSearch, {}).subscribe({ error });
+
+ setTimeout(() => mockResponses[0].complete(), 250);
+ setTimeout(() => mockResponses[1].error('error'), 500);
+
+ jest.advanceTimersByTime(500);
+
+ expect(next).toHaveBeenCalled();
+ expect(next.mock.calls).toEqual([[0], [1], [2], [1], [0]]);
+ });
+ });
+});
diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts
new file mode 100644
index 0000000000000..3f83214f6050c
--- /dev/null
+++ b/src/plugins/data/public/search/search_interceptor.ts
@@ -0,0 +1,121 @@
+/*
+ * 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 { BehaviorSubject, fromEvent, throwError } from 'rxjs';
+import { mergeMap, takeUntil, finalize } from 'rxjs/operators';
+import { getCombinedSignal } from '../../common/utils';
+import { IKibanaSearchRequest } from '../../common/search';
+import { ISearchGeneric, ISearchOptions } from './i_search';
+import { RequestTimeoutError } from './request_timeout_error';
+
+export class SearchInterceptor {
+ /**
+ * `abortController` used to signal all searches to abort.
+ */
+ private abortController = new AbortController();
+
+ /**
+ * Observable that emits when the number of pending requests changes.
+ */
+ private pendingCount$ = new BehaviorSubject(0);
+
+ /**
+ * The IDs from `setTimeout` when scheduling the automatic timeout for each request.
+ */
+ private timeoutIds: Set = new Set();
+
+ /**
+ * 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`
+ */
+ constructor(private readonly requestTimeout?: number) {}
+
+ /**
+ * Abort our `AbortController`, which in turn aborts any intercepted searches.
+ */
+ public cancelPending = () => {
+ this.abortController.abort();
+ this.abortController = new AbortController();
+ };
+
+ /**
+ * Un-schedule timing out all of the searches intercepted.
+ */
+ public runBeyondTimeout = () => {
+ this.timeoutIds.forEach(clearTimeout);
+ this.timeoutIds.clear();
+ };
+
+ /**
+ * Returns an `Observable` over the current number of pending searches. This could mean that one
+ * of the search requests is still in flight, or that it has only received partial responses.
+ */
+ public getPendingCount$ = () => {
+ return this.pendingCount$.asObservable();
+ };
+
+ /**
+ * Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort
+ * either when `cancelPending` is called, when the request times out, or when the original
+ * `AbortSignal` is aborted. Updates the `pendingCount` when the request is started/finalized.
+ */
+ public search = (
+ search: ISearchGeneric,
+ 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()))
+ );
+
+ return search(request as any, { ...options, signal: combinedSignal }).pipe(
+ takeUntil(timeoutError$),
+ finalize(() => this.removeTimeoutId(timeoutId))
+ );
+ };
+
+ private addTimeoutId(id: number) {
+ this.timeoutIds.add(id);
+ this.pendingCount$.next(this.timeoutIds.size);
+ }
+
+ private removeTimeoutId(id: number) {
+ this.timeoutIds.delete(id);
+ this.pendingCount$.next(this.timeoutIds.size);
+ }
+}
diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts
index 691c8aa0e984d..c145f04404fb5 100644
--- a/src/plugins/data/public/search/search_service.ts
+++ b/src/plugins/data/public/search/search_service.ts
@@ -18,13 +18,13 @@
*/
import { Plugin, CoreSetup, CoreStart, PackageInfo } from '../../../../core/public';
-
import { SYNC_SEARCH_STRATEGY, syncSearchStrategyProvider } from './sync_search_strategy';
import { ISearchSetup, ISearchStart, TSearchStrategyProvider, TSearchStrategiesMap } from './types';
import { TStrategyTypes } from './strategy_types';
import { getEsClient, LegacyApiCaller } from './es_client';
import { ES_SEARCH_STRATEGY, DEFAULT_SEARCH_STRATEGY } from '../../common/search';
-import { esSearchStrategyProvider } from './es_search/es_search_strategy';
+import { SearchInterceptor } from './search_interceptor';
+import { esSearchStrategyProvider } from './es_search';
import {
getAggTypes,
AggType,
@@ -91,6 +91,16 @@ export class SearchService implements Plugin {
}
public start(core: CoreStart): ISearchStart {
+ /**
+ * A global object that intercepts all searches and provides convenience methods for cancelling
+ * all pending search requests, as well as getting the number of pending search requests.
+ * 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(
+ core.injectedMetadata.getInjectedVar('esRequestTimeout') as number
+ );
+
const aggTypesStart = this.aggTypesRegistry.start();
return {
@@ -103,13 +113,16 @@ export class SearchService implements Plugin {
},
types: aggTypesStart,
},
+ cancelPendingSearches: () => searchInterceptor.cancelPending(),
+ getPendingSearchesCount$: () => 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 search(request as any, options);
+ return searchInterceptor.search(search as any, request, options);
},
__LEGACY: {
esClient: this.esClient!,
diff --git a/src/plugins/data/public/search/search_strategy/default_search_strategy.test.ts b/src/plugins/data/public/search/search_strategy/default_search_strategy.test.ts
index e4f492c89e0ef..210a0e5fd1ac7 100644
--- a/src/plugins/data/public/search/search_strategy/default_search_strategy.test.ts
+++ b/src/plugins/data/public/search/search_strategy/default_search_strategy.test.ts
@@ -18,9 +18,9 @@
*/
import { IUiSettingsClient } from '../../../../../core/public';
-import { ISearchStart } from '../types';
import { SearchStrategySearchParams } from './types';
import { defaultSearchStrategy } from './default_search_strategy';
+import { searchStartMock } from '../mocks';
const { search } = defaultSearchStrategy;
@@ -56,6 +56,12 @@ describe('defaultSearchStrategy', function() {
searchMockResponse.abort.mockClear();
searchMock.mockClear();
+ const searchService = searchStartMock;
+ searchService.aggs.calculateAutoTimeExpression = jest.fn().mockReturnValue('1d');
+ searchService.search = newSearchMock;
+ searchService.__LEGACY.esClient.search = searchMock;
+ searchService.__LEGACY.esClient.msearch = msearchMock;
+
searchArgs = {
searchRequests: [
{
@@ -63,15 +69,7 @@ describe('defaultSearchStrategy', function() {
},
],
esShardTimeout: 0,
- searchService: ({
- search: newSearchMock,
- __LEGACY: {
- esClient: {
- search: searchMock,
- msearch: msearchMock,
- },
- },
- } as unknown) as jest.Mocked,
+ searchService,
};
es = searchArgs.searchService.__LEGACY.esClient;
diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts
index 1732c384b1a85..e224c9ef4da8b 100644
--- a/src/plugins/data/public/search/types.ts
+++ b/src/plugins/data/public/search/types.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { Observable } from 'rxjs';
import { CoreStart } from 'kibana/public';
import { SearchAggsSetup, SearchAggsStart, SearchAggsStartLegacy } from './aggs';
import { ISearch, ISearchGeneric } from './i_search';
@@ -86,6 +87,9 @@ export interface ISearchSetup {
export interface ISearchStart {
aggs: SearchAggsStart;
+ cancelPendingSearches: () => void;
+ getPendingSearchesCount$: () => Observable;
+ runBeyondTimeout: () => void;
search: ISearchGeneric;
__LEGACY: ISearchStartLegacy & SearchAggsStartLegacy;
}
diff --git a/src/plugins/data/public/ui/query_string_input/loading_button.tsx b/src/plugins/data/public/ui/query_string_input/loading_button.tsx
new file mode 100644
index 0000000000000..aa9d316506ebb
--- /dev/null
+++ b/src/plugins/data/public/ui/query_string_input/loading_button.tsx
@@ -0,0 +1,115 @@
+/*
+ * 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 React, { useState } from 'react';
+import { i18n } from '@kbn/i18n';
+
+// @ts-ignore
+import { EuiButton, EuiContextMenu, EuiPopover, EuiSuperUpdateButton } from '@elastic/eui';
+
+export enum LoadingButtonAction {
+ ActionAbortAll,
+ ActionRunBeyondTimeout,
+ ActionRefresh,
+ ActionUpdate,
+}
+
+interface Props {
+ isDirty: boolean;
+ isDisabled?: boolean;
+ isLoading?: boolean;
+ onClick: (action: LoadingButtonAction) => void;
+}
+
+export function LoadingButton(props: Props) {
+ const [isOpen, setIsOpen] = useState(false);
+ const closePopover = () => {
+ setIsOpen(false);
+ };
+
+ const panels = {
+ id: 0,
+ items: [
+ {
+ name: i18n.translate('data.searchBar.cancelRequest', {
+ defaultMessage: 'Cancel request',
+ }),
+ icon: 'stop',
+ onClick: () => {
+ props.onClick(LoadingButtonAction.ActionAbortAll);
+ closePopover();
+ },
+ 'data-test-subj': 'searchBarCancelRequest',
+ },
+ {
+ name: i18n.translate('data.searchBar.runBeyondTimeout', {
+ defaultMessage: 'Run beyond timeout',
+ }),
+ icon: 'play',
+ onClick: () => {
+ props.onClick(LoadingButtonAction.ActionRunBeyondTimeout);
+ closePopover();
+ },
+ 'data-test-subj': 'searchBarRunBeyondTimeout',
+ },
+ ],
+ };
+
+ if (props.isLoading) {
+ const button = (
+ {
+ setIsOpen(!isOpen);
+ }}
+ // isLoading={true}
+ >
+ Loading
+
+ );
+
+ return (
+
+ );
+ } else {
+ return (
+ {
+ props.onClick(
+ props.isDirty ? LoadingButtonAction.ActionUpdate : LoadingButtonAction.ActionRefresh
+ );
+ }}
+ data-test-subj="querySubmitButton"
+ />
+ );
+ }
+}
diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx
index 433cb652ee5ce..796575708b791 100644
--- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx
+++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx
@@ -24,22 +24,22 @@ import { i18n } from '@kbn/i18n';
import {
EuiButton,
+ EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiSuperDatePicker,
- EuiFieldText,
+ OnRefreshProps,
prettyDuration,
} from '@elastic/eui';
-// @ts-ignore
-import { EuiSuperUpdateButton, OnRefreshProps } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { Toast } from 'src/core/public';
-import { IDataPluginServices, IIndexPattern, TimeRange, TimeHistoryContract, Query } from '../..';
-import { useKibana, toMountPoint } from '../../../../kibana_react/public';
+import { LoadingButton, LoadingButtonAction } from './loading_button';
+import { IDataPluginServices, IIndexPattern, Query, TimeHistoryContract, TimeRange } from '../..';
+import { toMountPoint, useKibana } from '../../../../kibana_react/public';
import { QueryStringInput } from './query_string_input';
import { doesKueryExpressionHaveLuceneSyntaxError } from '../../../common';
-import { PersistedLog, getQueryLog } from '../../query';
+import { getQueryLog, PersistedLog } from '../../query';
interface Props {
query?: Query;
@@ -50,7 +50,6 @@ interface Props {
disableAutoFocus?: boolean;
screenTitle?: string;
indexPatterns?: Array;
- isLoading?: boolean;
prepend?: React.ComponentProps['prepend'];
showQueryInput?: boolean;
showDatePicker?: boolean;
@@ -67,9 +66,14 @@ interface Props {
export function QueryBarTopRow(props: Props) {
const [isDateRangeInvalid, setIsDateRangeInvalid] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
const kibana = useKibana();
- const { uiSettings, notifications, storage, appName, docLinks } = kibana.services;
+ const { uiSettings, notifications, storage, appName, docLinks, data } = kibana.services;
+
+ data.search.getPendingSearchesCount$().subscribe((count: number) => {
+ setIsLoading(count > 0);
+ });
const kueryQuerySyntaxLink: string = docLinks!.links.query.kueryQuerySyntax;
@@ -82,12 +86,20 @@ export function QueryBarTopRow(props: Props) {
[appName, queryLanguage, uiSettings, storage]
);
- function onClickSubmitButton(event: React.MouseEvent) {
- if (persistedLog && props.query) {
- persistedLog.add(props.query.query);
+ function onLoadingButtonAction(action: LoadingButtonAction) {
+ switch (action) {
+ case LoadingButtonAction.ActionAbortAll:
+ data.search.cancelPendingSearches();
+ break;
+ case LoadingButtonAction.ActionRunBeyondTimeout:
+ // TODO: implement me
+ break;
+ default:
+ if (persistedLog && props.query) {
+ persistedLog.add(props.query.query);
+ }
+ onSubmit({ query: props.query, dateRange: getDateRange() });
}
- event.preventDefault();
- onSubmit({ query: props.query, dateRange: getDateRange() });
}
function getDateRange() {
@@ -214,13 +226,13 @@ export function QueryBarTopRow(props: Props) {
function renderUpdateButton() {
const button = props.customSubmitButton ? (
- React.cloneElement(props.customSubmitButton, { onClick: onClickSubmitButton })
+ React.cloneElement(props.customSubmitButton, { onClick: onLoadingButtonAction })
) : (
-
);
diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx
index 2371ccdde068c..ba8c9c3dfe680 100644
--- a/src/plugins/data/public/ui/search_bar/search_bar.tsx
+++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx
@@ -44,7 +44,6 @@ interface SearchBarInjectedDeps {
export interface SearchBarOwnProps {
indexPatterns?: IIndexPattern[];
- isLoading?: boolean;
customSubmitButton?: React.ReactNode;
screenTitle?: string;
dataTestSubj?: string;
@@ -385,7 +384,6 @@ class SearchBarUI extends Component {
screenTitle={this.props.screenTitle}
onSubmit={this.onQueryBarSubmit}
indexPatterns={this.props.indexPatterns}
- isLoading={this.props.isLoading}
prepend={this.props.showFilterBar ? savedQueryManagement : undefined}
showDatePicker={this.props.showDatePicker}
dateRangeFrom={this.state.dateRangeFrom}
diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts
index 0165486fc2de7..5038b4226fad8 100644
--- a/src/plugins/data/server/index.ts
+++ b/src/plugins/data/server/index.ts
@@ -166,7 +166,7 @@ export { ParsedInterval } from '../common';
export {
ISearch,
- ICancel,
+ ISearchCancel,
ISearchOptions,
IRequestTypesMap,
IResponseTypesMap,
diff --git a/src/plugins/data/server/search/i_route_handler_search_context.ts b/src/plugins/data/server/search/i_route_handler_search_context.ts
index 89862781b826e..9888c774ea104 100644
--- a/src/plugins/data/server/search/i_route_handler_search_context.ts
+++ b/src/plugins/data/server/search/i_route_handler_search_context.ts
@@ -17,9 +17,9 @@
* under the License.
*/
-import { ISearchGeneric, ICancelGeneric } from './i_search';
+import { ISearchGeneric, ISearchCancelGeneric } from './i_search';
export interface IRouteHandlerSearchContext {
search: ISearchGeneric;
- cancel: ICancelGeneric;
+ cancel: ISearchCancelGeneric;
}
diff --git a/src/plugins/data/server/search/i_search.ts b/src/plugins/data/server/search/i_search.ts
index ea014c5e136d9..fa4aa72ac7287 100644
--- a/src/plugins/data/server/search/i_search.ts
+++ b/src/plugins/data/server/search/i_search.ts
@@ -42,7 +42,7 @@ export type ISearchGeneric = Promise;
-export type ICancelGeneric = (
+export type ISearchCancelGeneric = (
id: string,
strategy?: T
) => Promise;
@@ -52,4 +52,4 @@ export type ISearch = (
options?: ISearchOptions
) => Promise;
-export type ICancel = (id: string) => Promise;
+export type ISearchCancel = (id: string) => Promise;
diff --git a/src/plugins/data/server/search/i_search_strategy.ts b/src/plugins/data/server/search/i_search_strategy.ts
index 4cfc9608383a9..9b405034f883f 100644
--- a/src/plugins/data/server/search/i_search_strategy.ts
+++ b/src/plugins/data/server/search/i_search_strategy.ts
@@ -18,7 +18,7 @@
*/
import { APICaller } from 'kibana/server';
-import { ISearch, ICancel, ISearchGeneric } from './i_search';
+import { ISearch, ISearchCancel, ISearchGeneric } from './i_search';
import { TStrategyTypes } from './strategy_types';
import { ISearchContext } from './i_search_context';
@@ -28,7 +28,7 @@ import { ISearchContext } from './i_search_context';
*/
export interface ISearchStrategy {
search: ISearch;
- cancel?: ICancel;
+ cancel?: ISearchCancel;
}
/**
diff --git a/src/plugins/data/server/search/index.ts b/src/plugins/data/server/search/index.ts
index 385e96ee803b6..15738a3befb27 100644
--- a/src/plugins/data/server/search/index.ts
+++ b/src/plugins/data/server/search/index.ts
@@ -21,7 +21,13 @@ export { ISearchSetup } from './i_search_setup';
export { ISearchContext } from './i_search_context';
-export { ISearch, ICancel, ISearchOptions, IRequestTypesMap, IResponseTypesMap } from './i_search';
+export {
+ ISearch,
+ ISearchCancel,
+ ISearchOptions,
+ IRequestTypesMap,
+ IResponseTypesMap,
+} from './i_search';
export { TStrategyTypes } from './strategy_types';
diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md
index 2a2d9bb414c14..178b2949a9456 100644
--- a/src/plugins/data/server/server.api.md
+++ b/src/plugins/data/server/server.api.md
@@ -329,12 +329,6 @@ export function getDefaultSearchParams(config: SharedGlobalConfig): {
restTotalHitsAsInt: boolean;
};
-// Warning: (ae-forgotten-export) The symbol "TStrategyTypes" needs to be exported by the entry point index.d.ts
-// Warning: (ae-missing-release-tag) "ICancel" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
-//
-// @public (undocumented)
-export type ICancel = (id: string) => Promise;
-
// Warning: (ae-missing-release-tag) "IFieldFormatsRegistry" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -507,11 +501,17 @@ export interface IResponseTypesMap {
[ES_SEARCH_STRATEGY]: IEsSearchResponse;
}
+// Warning: (ae-forgotten-export) The symbol "TStrategyTypes" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "ISearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type ISearch = (request: IRequestTypesMap[T], options?: ISearchOptions) => Promise;
+// Warning: (ae-missing-release-tag) "ISearchCancel" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export type ISearchCancel = (id: string) => Promise;
+
// Warning: (ae-missing-release-tag) "ISearchContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts
index f70a32f2f09c1..d0ab178296408 100644
--- a/src/plugins/expressions/common/execution/execution.ts
+++ b/src/plugins/expressions/common/execution/execution.ts
@@ -22,6 +22,7 @@ import { Executor } from '../executor';
import { createExecutionContainer, ExecutionContainer } from './container';
import { createError } from '../util';
import { Defer, now } from '../../../kibana_utils/common';
+import { AbortError } from '../../../data/common';
import { RequestAdapter, DataAdapter } from '../../../inspector/common';
import { isExpressionValueError, ExpressionValueError } from '../expression_types/specs/error';
import {
@@ -190,10 +191,7 @@ export class Execution<
for (const link of chainArr) {
// if execution was aborted return error
if (this.context.abortSignal && this.context.abortSignal.aborted) {
- return createError({
- message: 'The expression was aborted.',
- name: 'AbortError',
- });
+ return createError(new AbortError('The expression was aborted.'));
}
const { function: fnName, arguments: fnArgs } = link;
diff --git a/x-pack/plugins/data_enhanced/public/search/async_search_strategy.ts b/x-pack/plugins/data_enhanced/public/search/async_search_strategy.ts
index fa5d677a53b2a..6271d7fcbeaac 100644
--- a/x-pack/plugins/data_enhanced/public/search/async_search_strategy.ts
+++ b/x-pack/plugins/data_enhanced/public/search/async_search_strategy.ts
@@ -6,6 +6,7 @@
import { EMPTY, fromEvent, NEVER, Observable, throwError, timer } from 'rxjs';
import { mergeMap, expand, takeUntil } from 'rxjs/operators';
+import { AbortError } from '../../../../../src/plugins/data/common';
import {
IKibanaSearchResponse,
ISearchContext,
@@ -45,10 +46,7 @@ export const asyncSearchStrategyProvider: TSearchStrategyProvider = (
context: ISearchContext
) => {
- const syncStrategyProvider = context.getSearchStrategy(SYNC_SEARCH_STRATEGY);
- const { search: syncSearch } = syncStrategyProvider(context);
+ const asyncStrategyProvider = context.getSearchStrategy(ASYNC_SEARCH_STRATEGY);
+ const { search: asyncSearch } = asyncStrategyProvider(context);
const search: ISearch = (
request: IEnhancedEsSearchRequest,
@@ -32,9 +33,12 @@ export const enhancedEsSearchStrategyProvider: TSearchStrategyProvider;
+ const asyncOptions: IAsyncSearchOptions = { pollInterval: 0, ...options };
+
+ return asyncSearch(
+ { ...request, serverStrategy: ES_SEARCH_STRATEGY },
+ asyncOptions
+ ) as Observable;
};
return { search };
diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts
index 69b357196dc32..004e57f71464a 100644
--- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts
+++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts
@@ -14,10 +14,16 @@ import {
TSearchStrategyProvider,
ISearch,
ISearchOptions,
+ ISearchCancel,
getDefaultSearchParams,
} from '../../../../../src/plugins/data/server';
import { IEnhancedEsSearchRequest } from '../../common';
+export interface AsyncSearchResponse {
+ id: string;
+ response: SearchResponse;
+}
+
export const enhancedEsSearchStrategyProvider: TSearchStrategyProvider = (
context: ISearchContext,
caller: APICaller
@@ -30,26 +36,55 @@ export const enhancedEsSearchStrategyProvider: TSearchStrategyProvider;
+ : asyncSearch(caller, { ...request, params }, options));
+ const rawResponse =
+ request.indexType === 'rollup'
+ ? (response as SearchResponse)
+ : (response as AsyncSearchResponse).response;
+
+ const id = (response as AsyncSearchResponse).id;
const { total, failed, successful } = rawResponse._shards;
const loaded = failed + successful;
- return { total, loaded, rawResponse };
+ return { id, total, loaded, rawResponse };
+ };
+
+ const cancel: ISearchCancel = async id => {
+ const method = 'DELETE';
+ const path = `_async_search/${id}`;
+ await caller('transport.request', { method, path });
};
- return { search };
+ return { search, cancel };
};
-function rollupSearch(
+function asyncSearch(
+ caller: APICaller,
+ request: IEnhancedEsSearchRequest,
+ options?: ISearchOptions
+) {
+ const { body = undefined, index = undefined, ...params } = request.id ? {} : request.params;
+
+ // If we have an ID, then just poll for that ID, otherwise send the entire request body
+ const method = request.id ? 'GET' : 'POST';
+ const path = request.id ? `_async_search/${request.id}` : `${index}/_async_search`;
+
+ // Wait up to 1s for the response to return
+ const query = toSnakeCase({ waitForCompletion: '1s', ...params });
+
+ return caller('transport.request', { method, path, body, query }, options);
+}
+
+async function rollupSearch(
caller: APICaller,
request: IEnhancedEsSearchRequest,
options?: ISearchOptions
) {
+ const { body, index, ...params } = request.params;
const method = 'POST';
- const path = `${request.params.index}/_rollup_search`;
- const { body, ...params } = request.params;
+ const path = `${index}/_rollup_search`;
const query = toSnakeCase(params);
return caller('transport.request', { method, path, body, query }, options);
}