[7.x] Add async search notification (#60706) (#61098)

* notifications ui

* increase timeout to 10s

* trigger notification from search interceptor

* added an enhanced interceptor

* added an enhanced interceptor

* docs

* docs

* fix ts

* Fix jest tests for interceptor

* update docs

* docs

* Fix handling syntax error in discover

* docs and translations

* fix scripted fields err

Co-authored-by: Lukas Olson <olson.lukas@gmail.com>

Co-authored-by: Lukas Olson <olson.lukas@gmail.com>
This commit is contained in:
Liza Katz 2020-03-24 18:16:15 +00:00 committed by GitHub
parent e34cce58dd
commit 5e94e89640
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 620 additions and 123 deletions

View file

@ -18,7 +18,9 @@
| [IndexPatternSelect](./kibana-plugin-plugins-data-public.indexpatternselect.md) | |
| [OptionedParamType](./kibana-plugin-plugins-data-public.optionedparamtype.md) | |
| [Plugin](./kibana-plugin-plugins-data-public.plugin.md) | |
| [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) | Class used to signify that a request timed out. Useful for applications to conditionally handle this type of error differently than other errors. |
| [SearchError](./kibana-plugin-plugins-data-public.searcherror.md) | |
| [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) | |
| [SearchSource](./kibana-plugin-plugins-data-public.searchsource.md) | |
| [TimeHistory](./kibana-plugin-plugins-data-public.timehistory.md) | |

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) &gt; [(constructor)](./kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md)
## RequestTimeoutError.(constructor)
Constructs a new instance of the `RequestTimeoutError` class
<b>Signature:</b>
```typescript
constructor(message?: string);
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| message | <code>string</code> | |

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md)
## RequestTimeoutError class
Class used to signify that a request timed out. Useful for applications to conditionally handle this type of error differently than other errors.
<b>Signature:</b>
```typescript
export declare class RequestTimeoutError extends Error
```
## Constructors
| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(message)](./kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md) | | Constructs a new instance of the <code>RequestTimeoutError</code> class |

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) &gt; [(constructor)](./kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md)
## SearchInterceptor.(constructor)
This class should be instantiated with a `requestTimeout` corresponding with how many ms after requests are initiated that they should automatically cancel.
<b>Signature:</b>
```typescript
constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number | undefined);
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| toasts | <code>ToastsStart</code> | |
| application | <code>ApplicationStart</code> | |
| requestTimeout | <code>number &#124; undefined</code> | |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) &gt; [abortController](./kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md)
## SearchInterceptor.abortController property
`abortController` used to signal all searches to abort.
<b>Signature:</b>
```typescript
protected abortController: AbortController;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) &gt; [application](./kibana-plugin-plugins-data-public.searchinterceptor.application.md)
## SearchInterceptor.application property
<b>Signature:</b>
```typescript
protected readonly application: ApplicationStart;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) &gt; [getPendingCount$](./kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md)
## SearchInterceptor.getPendingCount$ property
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.
<b>Signature:</b>
```typescript
getPendingCount$: () => import("rxjs").Observable<number>;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) &gt; [hideToast](./kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md)
## SearchInterceptor.hideToast property
<b>Signature:</b>
```typescript
protected hideToast: () => void;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) &gt; [longRunningToast](./kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md)
## SearchInterceptor.longRunningToast property
The current long-running toast (if there is one).
<b>Signature:</b>
```typescript
protected longRunningToast?: Toast;
```

View file

@ -0,0 +1,33 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md)
## SearchInterceptor class
<b>Signature:</b>
```typescript
export declare class SearchInterceptor
```
## Constructors
| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(toasts, application, requestTimeout)](./kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md) | | This class should be instantiated with a <code>requestTimeout</code> corresponding with how many ms after requests are initiated that they should automatically cancel. |
## Properties
| Property | Modifiers | Type | Description |
| --- | --- | --- | --- |
| [abortController](./kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md) | | <code>AbortController</code> | <code>abortController</code> used to signal all searches to abort. |
| [application](./kibana-plugin-plugins-data-public.searchinterceptor.application.md) | | <code>ApplicationStart</code> | |
| [getPendingCount$](./kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md) | | <code>() =&gt; import(&quot;rxjs&quot;).Observable&lt;number&gt;</code> | Returns an <code>Observable</code> 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. |
| [hideToast](./kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md) | | <code>() =&gt; void</code> | |
| [longRunningToast](./kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md) | | <code>Toast</code> | The current long-running toast (if there is one). |
| [requestTimeout](./kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md) | | <code>number &#124; undefined</code> | |
| [search](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | <code>(search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions &#124; undefined) =&gt; import(&quot;rxjs&quot;).Observable&lt;import(&quot;../../common/search&quot;).IEsSearchResponse&lt;unknown&gt;&gt;</code> | Searches using the given <code>search</code> method. Overrides the <code>AbortSignal</code> with one that will abort either when <code>cancelPending</code> is called, when the request times out, or when the original <code>AbortSignal</code> is aborted. Updates the <code>pendingCount</code> when the request is started/finalized. |
| [showToast](./kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md) | | <code>() =&gt; void</code> | |
| [timeoutSubscriptions](./kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md) | | <code>Set&lt;Subscription&gt;</code> | The subscriptions from scheduling the automatic timeout for each request. |
| [toasts](./kibana-plugin-plugins-data-public.searchinterceptor.toasts.md) | | <code>ToastsStart</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) &gt; [requestTimeout](./kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md)
## SearchInterceptor.requestTimeout property
<b>Signature:</b>
```typescript
protected readonly requestTimeout?: number | undefined;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) &gt; [search](./kibana-plugin-plugins-data-public.searchinterceptor.search.md)
## SearchInterceptor.search property
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.
<b>Signature:</b>
```typescript
search: (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable<import("../../common/search").IEsSearchResponse<unknown>>;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) &gt; [showToast](./kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md)
## SearchInterceptor.showToast property
<b>Signature:</b>
```typescript
protected showToast: () => void;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) &gt; [timeoutSubscriptions](./kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md)
## SearchInterceptor.timeoutSubscriptions property
The subscriptions from scheduling the automatic timeout for each request.
<b>Signature:</b>
```typescript
protected timeoutSubscriptions: Set<Subscription>;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) &gt; [toasts](./kibana-plugin-plugins-data-public.searchinterceptor.toasts.md)
## SearchInterceptor.toasts property
<b>Signature:</b>
```typescript
protected readonly toasts: ToastsStart;
```

View file

@ -374,6 +374,8 @@ export {
TabbedAggColumn,
TabbedAggRow,
TabbedTable,
SearchInterceptor,
RequestTimeoutError,
} from './search';
// Search namespace

View file

@ -7,6 +7,7 @@
import { $Values } from '@kbn/utility-types';
import _ from 'lodash';
import { Action } from 'history';
import { ApplicationStart } from 'kibana/public';
import { Assign } from '@kbn/utility-types';
import { Breadcrumb } from '@elastic/eui';
import { Component } from 'react';
@ -48,6 +49,9 @@ import { SavedObjectsClientContract } from 'src/core/public';
import { SearchParams } from 'elasticsearch';
import { SearchResponse as SearchResponse_2 } from 'elasticsearch';
import { SimpleSavedObject } from 'src/core/public';
import { Subscription } from 'rxjs';
import { Toast } from 'kibana/public';
import { ToastsStart } from 'kibana/public';
import { UiActionsSetup } from 'src/plugins/ui_actions/public';
import { UiActionsStart } from 'src/plugins/ui_actions/public';
import { Unit } from '@elastic/datemath';
@ -1475,6 +1479,13 @@ export interface RefreshInterval {
value: number;
}
// Warning: (ae-missing-release-tag) "RequestTimeoutError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export class RequestTimeoutError extends Error {
constructor(message?: string);
}
// Warning: (ae-missing-release-tag) "SavedQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@ -1592,6 +1603,28 @@ export class SearchError extends Error {
type: string;
}
// Warning: (ae-missing-release-tag) "SearchInterceptor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export class SearchInterceptor {
constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number | undefined);
protected abortController: AbortController;
// (undocumented)
protected readonly application: ApplicationStart;
getPendingCount$: () => import("rxjs").Observable<number>;
// (undocumented)
protected hideToast: () => void;
protected longRunningToast?: Toast;
// (undocumented)
protected readonly requestTimeout?: number | undefined;
search: (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable<import("../../common/search").IEsSearchResponse<unknown>>;
// (undocumented)
protected showToast: () => void;
protected timeoutSubscriptions: Set<Subscription>;
// (undocumented)
protected readonly toasts: ToastsStart;
}
// Warning: (ae-missing-release-tag) "SearchRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@ -1858,21 +1891,21 @@ export type TSearchStrategyProvider<T extends TStrategyTypes> = (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

View file

@ -57,5 +57,6 @@ export {
} from './search_source';
export { SearchInterceptor } from './search_interceptor';
export { RequestTimeoutError } from './request_timeout_error';
export { FetchOptions } from './fetch';

View file

@ -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(<LongQueryNotification application={props.application} />);
}
export function LongQueryNotification(props: Props) {
return (
<div>
<FormattedMessage
id="data.query.queryBar.longQueryMessage"
defaultMessage="With an upgraded license, you can ensure requests have enough time to complete."
/>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton
size="s"
onClick={async () => {
await props.application.navigateToApp(
'kibana#/management/elasticsearch/license_management'
);
}}
>
<FormattedMessage
id="data.query.queryBar.licenseOptions"
defaultMessage="Go to license options"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
}

View file

@ -31,10 +31,8 @@ export const searchSetupMock = {
export const searchStartMock: jest.Mocked<ISearchStart> = {
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(),

View file

@ -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<CoreStart>;
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;

View file

@ -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();
/**
* The number of pending search requests.
*/
private pendingCount = 0;
/**
* Observable that emits when the number of pending requests changes.
*/
private pendingCount$ = new BehaviorSubject(0);
private pendingCount$ = new BehaviorSubject(this.pendingCount);
/**
* The IDs from `setTimeout` when scheduling the automatic timeout for each request.
* The subscriptions from scheduling the automatic timeout for each request.
*/
private timeoutIds: Set<number> = new Set();
protected timeoutSubscriptions: Set<Subscription> = new Set();
/**
* The current long-running toast (if there is one).
*/
protected longRunningToast?: Toast;
/**
* 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(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();
};
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);
// Defer the following logic until `subscribe` is actually called
return defer(() => {
this.pendingCount$.next(++this.pendingCount);
// 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);
// 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(
mergeMap(() => throwError(new RequestTimeoutError()))
);
// If the request timed out, throw a `RequestTimeoutError`
const timeoutError$ = fromEvent(timeoutSignal, 'abort').pipe(
mergeMapTo(throwError(new RequestTimeoutError()))
);
return search(request as any, { ...options, signal: combinedSignal }).pipe(
takeUntil(timeoutError$),
finalize(() => this.removeTimeoutId(timeoutId))
// 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();
})
);
});
};
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 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);
}
protected hideToast = () => {
if (this.longRunningToast) {
this.toasts.remove(this.longRunningToast);
delete this.longRunningToast;
}
};
}

View file

@ -58,6 +58,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
private esClient?: LegacyApiCaller;
private readonly aggTypesRegistry = new AggTypesRegistry();
private searchInterceptor!: SearchInterceptor;
private registerSearchStrategyProvider = <T extends TStrategyTypes>(
name: T,
@ -98,7 +99,9 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
* 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<ISearchSetup, ISearchStart> {
},
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!,

View file

@ -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<number>;
runBeyondTimeout: () => void;
setInterceptor: (searchInterceptor: SearchInterceptor) => void;
search: ISearchGeneric;
__LEGACY: ISearchStartLegacy & SearchAggsStartLegacy;
}

View file

@ -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);
}
}

View file

@ -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(
<LongQueryNotification cancel={props.cancel} runBeyondTimeout={props.runBeyondTimeout} />
);
}
export function LongQueryNotification(props: Props) {
return (
<div>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="s" onClick={props.cancel}>
<FormattedMessage
id="xpack.data.query.queryBar.cancelLongQuery"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton size="s" fill onClick={props.runBeyondTimeout}>
<FormattedMessage
id="xpack.data.query.queryBar.runBeyond"
defaultMessage="Run beyond timeout"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
}

View file

@ -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<CoreStart>;
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();
});
});
});

View file

@ -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,
}
);
};
}