Don't trigger auto-refresh until previous refresh completes (#93410)

This commit is contained in:
Anton Dosov 2021-04-08 13:00:11 +02:00 committed by GitHub
parent d39701fc97
commit 7984745a9d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 755 additions and 126 deletions

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; [AutoRefreshDoneFn](./kibana-plugin-plugins-data-public.autorefreshdonefn.md)
## AutoRefreshDoneFn type
<b>Signature:</b>
```typescript
export declare type AutoRefreshDoneFn = () => void;
```

View file

@ -47,6 +47,7 @@
| [getSearchParamsFromRequest(searchRequest, dependencies)](./kibana-plugin-plugins-data-public.getsearchparamsfromrequest.md) | |
| [getTime(indexPattern, timeRange, options)](./kibana-plugin-plugins-data-public.gettime.md) | |
| [plugin(initializerContext)](./kibana-plugin-plugins-data-public.plugin.md) | |
| [waitUntilNextSessionCompletes$(sessionService, { waitForIdle })](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md) | Creates an observable that emits when next search session completes. This utility is helpful to use in the application to delay some tasks until next session completes. |
## Interfaces
@ -92,6 +93,7 @@
| [SearchInterceptorDeps](./kibana-plugin-plugins-data-public.searchinterceptordeps.md) | |
| [SearchSessionInfoProvider](./kibana-plugin-plugins-data-public.searchsessioninfoprovider.md) | Provide info about current search session to be stored in the Search Session saved object |
| [SearchSourceFields](./kibana-plugin-plugins-data-public.searchsourcefields.md) | search source fields |
| [WaitUntilNextSessionCompletesOptions](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md) | Options for [waitUntilNextSessionCompletes$()](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md) |
## Variables
@ -141,6 +143,7 @@
| [AggParam](./kibana-plugin-plugins-data-public.aggparam.md) | |
| [AggsStart](./kibana-plugin-plugins-data-public.aggsstart.md) | AggsStart represents the actual external contract as AggsCommonStart is only used internally. The difference is that AggsStart includes the typings for the registry with initialized agg types. |
| [AutocompleteStart](./kibana-plugin-plugins-data-public.autocompletestart.md) | \* |
| [AutoRefreshDoneFn](./kibana-plugin-plugins-data-public.autorefreshdonefn.md) | |
| [CustomFilter](./kibana-plugin-plugins-data-public.customfilter.md) | |
| [EsaggsExpressionFunctionDefinition](./kibana-plugin-plugins-data-public.esaggsexpressionfunctiondefinition.md) | |
| [EsdslExpressionFunctionDefinition](./kibana-plugin-plugins-data-public.esdslexpressionfunctiondefinition.md) | |

View file

@ -0,0 +1,25 @@
<!-- 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; [waitUntilNextSessionCompletes$](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md)
## waitUntilNextSessionCompletes$() function
Creates an observable that emits when next search session completes. This utility is helpful to use in the application to delay some tasks until next session completes.
<b>Signature:</b>
```typescript
export declare function waitUntilNextSessionCompletes$(sessionService: ISessionService, { waitForIdle }?: WaitUntilNextSessionCompletesOptions): import("rxjs").Observable<SearchSessionState>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| sessionService | <code>ISessionService</code> | [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) |
| { waitForIdle } | <code>WaitUntilNextSessionCompletesOptions</code> | |
<b>Returns:</b>
`import("rxjs").Observable<SearchSessionState>`

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; [WaitUntilNextSessionCompletesOptions](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md)
## WaitUntilNextSessionCompletesOptions interface
Options for [waitUntilNextSessionCompletes$()](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md)
<b>Signature:</b>
```typescript
export interface WaitUntilNextSessionCompletesOptions
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [waitForIdle](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md) | <code>number</code> | For how long to wait between session state transitions before considering that session completed |

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; [WaitUntilNextSessionCompletesOptions](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md) &gt; [waitForIdle](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md)
## WaitUntilNextSessionCompletesOptions.waitForIdle property
For how long to wait between session state transitions before considering that session completed
<b>Signature:</b>
```typescript
waitForIdle?: number;
```

View file

@ -10,7 +10,7 @@ import { History } from 'history';
import { merge, Subject, Subscription } from 'rxjs';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { debounceTime, tap } from 'rxjs/operators';
import { debounceTime, finalize, switchMap, tap } from 'rxjs/operators';
import { useKibana } from '../../../kibana_react/public';
import { DashboardConstants } from '../dashboard_constants';
import { DashboardTopNav } from './top_nav/dashboard_top_nav';
@ -30,7 +30,7 @@ import {
useSavedDashboard,
} from './hooks';
import { IndexPattern } from '../services/data';
import { IndexPattern, waitUntilNextSessionCompletes$ } from '../services/data';
import { EmbeddableRenderer } from '../services/embeddable';
import { DashboardContainerInput } from '.';
import { leaveConfirmStrings } from '../dashboard_strings';
@ -209,14 +209,26 @@ export function DashboardApp({
);
subscriptions.add(
merge(
data.query.timefilter.timefilter.getAutoRefreshFetch$(),
searchSessionIdQuery$
).subscribe(() => {
searchSessionIdQuery$.subscribe(() => {
triggerRefresh$.next({ force: true });
})
);
subscriptions.add(
data.query.timefilter.timefilter
.getAutoRefreshFetch$()
.pipe(
tap(() => {
triggerRefresh$.next({ force: true });
}),
switchMap((done) =>
// best way on a dashboard to estimate that panels are updated is to rely on search session service state
waitUntilNextSessionCompletes$(data.search.session).pipe(finalize(done))
)
)
.subscribe()
);
dashboardStateManager.registerChangeListener(() => {
setUnsavedChanges(dashboardStateManager.getIsDirty(data.query.timefilter.timefilter));
// we aren't checking dirty state because there are changes the container needs to know about

View file

@ -5,7 +5,7 @@ title: Data services
image: https://source.unsplash.com/400x175/?Search
summary: The data plugin contains services for searching, querying and filtering.
date: 2020-12-02
tags: ['kibana','dev', 'contributor', 'api docs']
tags: ['kibana', 'dev', 'contributor', 'api docs']
---
# data
@ -149,7 +149,6 @@ Index patterns provide Rest-like HTTP CRUD+ API with the following endpoints:
- Remove a scripted field &mdash; `DELETE /api/index_patterns/index_pattern/{id}/scripted_field/{name}`
- Update a scripted field &mdash; `POST /api/index_patterns/index_pattern/{id}/scripted_field/{name}`
### Index Patterns API
Index Patterns REST API allows you to create, retrieve and delete index patterns. I also
@ -212,11 +211,10 @@ The endpoint returns the created index pattern object.
```json
{
"index_pattern": {}
"index_pattern": {}
}
```
#### Fetch an index pattern by ID
Retrieve an index pattern by its ID.
@ -229,23 +227,22 @@ Returns an index pattern object.
```json
{
"index_pattern": {
"id": "...",
"version": "...",
"title": "...",
"type": "...",
"intervalName": "...",
"timeFieldName": "...",
"sourceFilters": [],
"fields": {},
"typeMeta": {},
"fieldFormats": {},
"fieldAttrs": {}
}
"index_pattern": {
"id": "...",
"version": "...",
"title": "...",
"type": "...",
"intervalName": "...",
"timeFieldName": "...",
"sourceFilters": [],
"fields": {},
"typeMeta": {},
"fieldFormats": {},
"fieldAttrs": {}
}
}
```
#### Delete an index pattern by ID
Delete and index pattern by its ID.
@ -256,21 +253,21 @@ DELETE /api/index_patterns/index_pattern/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Returns an '200 OK` response with empty body on success.
#### Partially update an index pattern by ID
Update part of an index pattern. Only provided fields will be updated on the
index pattern, missing fields will stay as they are persisted.
These fields can be update partially:
- `title`
- `timeFieldName`
- `intervalName`
- `fields` (optionally refresh fields)
- `sourceFilters`
- `fieldFormatMap`
- `type`
- `typeMeta`
- `title`
- `timeFieldName`
- `intervalName`
- `fields` (optionally refresh fields)
- `sourceFilters`
- `fieldFormatMap`
- `type`
- `typeMeta`
Update a title of an index pattern.
@ -318,18 +315,14 @@ This endpoint returns the updated index pattern object.
```json
{
"index_pattern": {
}
"index_pattern": {}
}
```
### Fields API
Fields API allows to change field metadata, such as `count`, `customLabel`, and `format`.
#### Update fields
Update endpoint allows you to update fields presentation metadata, such as `count`,
@ -383,13 +376,10 @@ This endpoint returns the updated index pattern object.
```json
{
"index_pattern": {
}
"index_pattern": {}
}
```
### Scripted Fields API
Scripted Fields API provides CRUD API for scripted fields of an index pattern.
@ -487,7 +477,7 @@ Returns the field object.
```json
{
"field": {}
"field": {}
}
```
@ -529,47 +519,86 @@ POST /api/index_patterns/index_pattern/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/scri
}
```
## Query
The query service is responsible for managing the configuration of a search query (`QueryState`): filters, time range, query string, and settings such as the auto refresh behavior and saved queries.
It contains sub-services for each of those configurations:
- `data.query.filterManager` - Manages the `filters` component of a `QueryState`. The global filter state (filters that are persisted between applications) are owned by this service.
- `data.query.timefilter` - Responsible for the time range filter and the auto refresh behavior settings.
- `data.query.queryString` - Responsible for the query string and query language settings.
- `data.query.savedQueries` - Responsible for persisting a `QueryState` into a `SavedObject`, so it can be restored and used by other applications.
Any changes to the `QueryState` are published on the `data.query.state$`, which is useful when wanting to persist global state or run a search upon data changes.
- `data.query.filterManager` - Manages the `filters` component of a `QueryState`. The global filter state (filters that are persisted between applications) are owned by this service.
- `data.query.timefilter` - Responsible for the time range filter and the auto refresh behavior settings.
- `data.query.queryString` - Responsible for the query string and query language settings.
- `data.query.savedQueries` - Responsible for persisting a `QueryState` into a `SavedObject`, so it can be restored and used by other applications.
A simple use case is:
Any changes to the `QueryState` are published on the `data.query.state$`, which is useful when wanting to persist global state or run a search upon data changes.
```.ts
function searchOnChange(indexPattern: IndexPattern, aggConfigs: AggConfigs) {
data.query.state$.subscribe(() => {
A simple use case is:
// Constuct the query portion of the search request
const query = data.query.getEsQuery(indexPattern);
```.ts
function searchOnChange(indexPattern: IndexPattern, aggConfigs: AggConfigs) {
data.query.state$.subscribe(() => {
// Construct a request
const request = {
params: {
index: indexPattern.title,
body: {
aggs: aggConfigs.toDsl(),
query,
},
},
};
// Constuct the query portion of the search request
const query = data.query.getEsQuery(indexPattern);
// Search with the `data.query` config
const search$ = data.search.search(request);
// Construct a request
const request = {
params: {
index: indexPattern.title,
body: {
aggs: aggConfigs.toDsl(),
query,
},
},
};
...
});
}
// Search with the `data.query` config
const search$ = data.search.search(request);
```
...
});
}
```
### Timefilter
`data.query.timefilter` is responsible for the time range filter and the auto refresh behavior settings.
#### Autorefresh
Timefilter provides an API for setting and getting current auto refresh state:
```ts
const { pause, value } = data.query.timefilter.timefilter.getRefreshInterval();
data.query.timefilter.timefilter.setRefreshInterval({ pause: false, value: 5000 }); // start auto refresh with 5 seconds interval
```
Timefilter API also provides an `autoRefreshFetch$` observables that apps should use to get notified
when it is time to refresh data because of auto refresh.
This API expects apps to confirm when they are done with reloading the data.
The confirmation mechanism is needed to prevent excessive queue of fetches.
```
import { refetchData } from '../my-app'
const autoRefreshFetch$ = data.query.timefilter.timefilter.getAutoRefreshFetch$()
autoRefreshFetch$.subscribe((done) => {
try {
await refetchData();
} finally {
// confirm that data fetching was finished
done();
}
})
function unmount() {
// don't forget to unsubscribe when leaving the app
autoRefreshFetch$.unsubscribe()
}
```
## Search

View file

@ -388,6 +388,8 @@ export {
PainlessError,
noSearchSessionStorageCapabilityMessage,
SEARCH_SESSIONS_MANAGEMENT_ID,
waitUntilNextSessionCompletes$,
WaitUntilNextSessionCompletesOptions,
} from './search';
export type {
@ -467,6 +469,7 @@ export {
TimeHistoryContract,
QueryStateChange,
QueryStart,
AutoRefreshDoneFn,
} from './query';
export { AggsStart } from './search/aggs';

View file

@ -504,6 +504,11 @@ export interface ApplyGlobalFilterActionContext {
// @public (undocumented)
export type AutocompleteStart = ReturnType<AutocompleteService['start']>;
// Warning: (ae-missing-release-tag) "AutoRefreshDoneFn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type AutoRefreshDoneFn = () => void;
// Warning: (ae-forgotten-export) The symbol "DateFormat" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "baseFormattersPublic" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@ -2647,6 +2652,18 @@ export const UI_SETTINGS: {
readonly AUTOCOMPLETE_USE_TIMERANGE: "autocomplete:useTimeRange";
};
// Warning: (ae-missing-release-tag) "waitUntilNextSessionCompletes$" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export function waitUntilNextSessionCompletes$(sessionService: ISessionService, { waitForIdle }?: WaitUntilNextSessionCompletesOptions): import("rxjs").Observable<SearchSessionState>;
// Warning: (ae-missing-release-tag) "WaitUntilNextSessionCompletesOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface WaitUntilNextSessionCompletesOptions {
waitForIdle?: number;
}
// Warnings were encountered during analysis:
//
@ -2694,21 +2711,21 @@ export const UI_SETTINGS: {
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:404:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:404:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:404:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:404:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:407:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:419:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:427:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:428:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:431:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:406:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:406:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:406:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:406:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:419:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:425:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:426:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:429:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:430:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:433: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:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/search/session/session_service.ts:56:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts

View file

@ -9,7 +9,7 @@
export { TimefilterService, TimefilterSetup } from './timefilter_service';
export * from './types';
export { Timefilter, TimefilterContract } from './timefilter';
export { Timefilter, TimefilterContract, AutoRefreshDoneFn } from './timefilter';
export { TimeHistory, TimeHistoryContract } from './time_history';
export { changeTimeFilter, convertRangeFilterToTimeRangeString } from './lib/change_time_filter';
export { extractTimeFilter, extractTimeRange } from './lib/extract_time_filter';

View file

@ -0,0 +1,205 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { createAutoRefreshLoop, AutoRefreshDoneFn } from './auto_refresh_loop';
jest.useFakeTimers();
test('triggers refresh with interval', () => {
const { loop$, start, stop } = createAutoRefreshLoop();
const fn = jest.fn((done) => done());
loop$.subscribe(fn);
jest.advanceTimersByTime(5000);
expect(fn).not.toBeCalled();
start(1000);
jest.advanceTimersByTime(1001);
expect(fn).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(1001);
expect(fn).toHaveBeenCalledTimes(2);
stop();
jest.advanceTimersByTime(5000);
expect(fn).toHaveBeenCalledTimes(2);
});
test('waits for done() to be called', () => {
const { loop$, start } = createAutoRefreshLoop();
let done!: AutoRefreshDoneFn;
const fn = jest.fn((_done) => {
done = _done;
});
loop$.subscribe(fn);
start(1000);
jest.advanceTimersByTime(1001);
expect(fn).toHaveBeenCalledTimes(1);
expect(done).toBeInstanceOf(Function);
jest.advanceTimersByTime(1001);
expect(fn).toHaveBeenCalledTimes(1);
done();
jest.advanceTimersByTime(500);
expect(fn).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(501);
expect(fn).toHaveBeenCalledTimes(2);
});
test('waits for done() from multiple subscribers to be called', () => {
const { loop$, start } = createAutoRefreshLoop();
let done1!: AutoRefreshDoneFn;
const fn1 = jest.fn((_done) => {
done1 = _done;
});
loop$.subscribe(fn1);
let done2!: AutoRefreshDoneFn;
const fn2 = jest.fn((_done) => {
done2 = _done;
});
loop$.subscribe(fn2);
start(1000);
jest.advanceTimersByTime(1001);
expect(fn1).toHaveBeenCalledTimes(1);
expect(done1).toBeInstanceOf(Function);
jest.advanceTimersByTime(1001);
expect(fn1).toHaveBeenCalledTimes(1);
done1();
jest.advanceTimersByTime(500);
expect(fn1).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(501);
expect(fn1).toHaveBeenCalledTimes(1);
done2();
jest.advanceTimersByTime(500);
expect(fn1).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(501);
expect(fn1).toHaveBeenCalledTimes(2);
});
test('unsubscribe() resets the state', () => {
const { loop$, start } = createAutoRefreshLoop();
let done1!: AutoRefreshDoneFn;
const fn1 = jest.fn((_done) => {
done1 = _done;
});
loop$.subscribe(fn1);
const fn2 = jest.fn();
const sub2 = loop$.subscribe(fn2);
start(1000);
jest.advanceTimersByTime(1001);
expect(fn1).toHaveBeenCalledTimes(1);
expect(done1).toBeInstanceOf(Function);
jest.advanceTimersByTime(1001);
expect(fn1).toHaveBeenCalledTimes(1);
done1();
jest.advanceTimersByTime(500);
expect(fn1).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(501);
expect(fn1).toHaveBeenCalledTimes(1);
sub2.unsubscribe();
jest.advanceTimersByTime(500);
expect(fn1).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(501);
expect(fn1).toHaveBeenCalledTimes(2);
});
test('calling done() twice is ignored', () => {
const { loop$, start } = createAutoRefreshLoop();
let done1!: AutoRefreshDoneFn;
const fn1 = jest.fn((_done) => {
done1 = _done;
});
loop$.subscribe(fn1);
const fn2 = jest.fn();
loop$.subscribe(fn2);
start(1000);
jest.advanceTimersByTime(1001);
expect(fn1).toHaveBeenCalledTimes(1);
expect(done1).toBeInstanceOf(Function);
jest.advanceTimersByTime(1001);
expect(fn1).toHaveBeenCalledTimes(1);
done1();
jest.advanceTimersByTime(500);
expect(fn1).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(501);
expect(fn1).toHaveBeenCalledTimes(1);
done1();
jest.advanceTimersByTime(500);
expect(fn1).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(501);
expect(fn1).toHaveBeenCalledTimes(1);
});
test('calling older done() is ignored', () => {
const { loop$, start } = createAutoRefreshLoop();
let done1!: AutoRefreshDoneFn;
const fn1 = jest.fn((_done) => {
// @ts-ignore
if (done1) return;
done1 = _done;
});
loop$.subscribe(fn1);
start(1000);
jest.advanceTimersByTime(1001);
expect(fn1).toHaveBeenCalledTimes(1);
expect(done1).toBeInstanceOf(Function);
jest.advanceTimersByTime(1001);
expect(fn1).toHaveBeenCalledTimes(1);
done1();
jest.advanceTimersByTime(500);
expect(fn1).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(501);
expect(fn1).toHaveBeenCalledTimes(2);
done1();
jest.advanceTimersByTime(500);
expect(fn1).toHaveBeenCalledTimes(2);
jest.advanceTimersByTime(501);
expect(fn1).toHaveBeenCalledTimes(2);
});

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { defer, Subject } from 'rxjs';
import { finalize, map } from 'rxjs/operators';
import { once } from 'lodash';
export type AutoRefreshDoneFn = () => void;
/**
* Creates a loop for timepicker's auto refresh
* It has a "confirmation" mechanism:
* When auto refresh loop emits, it won't continue automatically,
* until each subscriber calls received `done` function.
*
* @internal
*/
export const createAutoRefreshLoop = () => {
let subscribersCount = 0;
const tick = new Subject<AutoRefreshDoneFn>();
let _timeoutHandle: number;
let _timeout: number = 0;
function start() {
stop();
if (_timeout === 0) return;
const timeoutHandle = window.setTimeout(() => {
let pendingDoneCount = subscribersCount;
const done = () => {
if (timeoutHandle !== _timeoutHandle) return;
pendingDoneCount--;
if (pendingDoneCount === 0) {
start();
}
};
tick.next(done);
}, _timeout);
_timeoutHandle = timeoutHandle;
}
function stop() {
window.clearTimeout(_timeoutHandle);
_timeoutHandle = -1;
}
return {
stop: () => {
_timeout = 0;
stop();
},
start: (timeout: number) => {
_timeout = timeout;
if (subscribersCount > 0) {
start();
}
},
loop$: defer(() => {
subscribersCount++;
start(); // restart the loop on a new subscriber
return tick.pipe(map((doneCb) => once(doneCb))); // each subscriber allowed to call done only once
}).pipe(
finalize(() => {
subscribersCount--;
if (subscribersCount === 0) {
stop();
} else {
start(); // restart the loop to potentially unblock the interval
}
})
),
};
};

View file

@ -10,7 +10,7 @@ jest.useFakeTimers();
import sinon from 'sinon';
import moment from 'moment';
import { Timefilter } from './timefilter';
import { AutoRefreshDoneFn, Timefilter } from './timefilter';
import { Subscription } from 'rxjs';
import { TimeRange, RefreshInterval } from '../../../common';
import { createNowProviderMock } from '../../now_provider/mocks';
@ -121,7 +121,7 @@ describe('setRefreshInterval', () => {
beforeEach(() => {
update = sinon.spy();
fetch = sinon.spy();
autoRefreshFetch = sinon.spy();
autoRefreshFetch = sinon.spy((done) => done());
timefilter.setRefreshInterval({
pause: false,
value: 0,
@ -344,3 +344,44 @@ describe('calculateBounds', () => {
expect(() => timefilter.calculateBounds(timeRange)).toThrowError();
});
});
describe('getAutoRefreshFetch$', () => {
test('next auto refresh loop starts after "done" called', () => {
const autoRefreshFetch = jest.fn();
let doneCb: AutoRefreshDoneFn | undefined;
timefilter.getAutoRefreshFetch$().subscribe((done) => {
autoRefreshFetch();
doneCb = done;
});
timefilter.setRefreshInterval({ pause: false, value: 1000 });
expect(autoRefreshFetch).toBeCalledTimes(0);
jest.advanceTimersByTime(5000);
expect(autoRefreshFetch).toBeCalledTimes(1);
if (doneCb) doneCb();
jest.advanceTimersByTime(1005);
expect(autoRefreshFetch).toBeCalledTimes(2);
});
test('new getAutoRefreshFetch$ subscription restarts refresh loop', () => {
const autoRefreshFetch = jest.fn();
const fetch$ = timefilter.getAutoRefreshFetch$();
const sub1 = fetch$.subscribe((done) => {
autoRefreshFetch();
// this done will be never called, but loop will be reset by another subscription
});
timefilter.setRefreshInterval({ pause: false, value: 1000 });
expect(autoRefreshFetch).toBeCalledTimes(0);
jest.advanceTimersByTime(5000);
expect(autoRefreshFetch).toBeCalledTimes(1);
fetch$.subscribe(autoRefreshFetch);
expect(autoRefreshFetch).toBeCalledTimes(1);
sub1.unsubscribe();
jest.advanceTimersByTime(1005);
expect(autoRefreshFetch).toBeCalledTimes(2);
});
});

View file

@ -22,6 +22,9 @@ import {
TimeRange,
} from '../../../common';
import { TimeHistoryContract } from './time_history';
import { createAutoRefreshLoop, AutoRefreshDoneFn } from './lib/auto_refresh_loop';
export { AutoRefreshDoneFn };
// TODO: remove!
@ -32,8 +35,6 @@ export class Timefilter {
private timeUpdate$ = new Subject();
// Fired when a user changes the the autorefresh settings
private refreshIntervalUpdate$ = new Subject();
// Used when an auto refresh is triggered
private autoRefreshFetch$ = new Subject();
private fetch$ = new Subject();
private _time: TimeRange;
@ -45,11 +46,12 @@ export class Timefilter {
private _isTimeRangeSelectorEnabled: boolean = false;
private _isAutoRefreshSelectorEnabled: boolean = false;
private _autoRefreshIntervalId: number = 0;
private readonly timeDefaults: TimeRange;
private readonly refreshIntervalDefaults: RefreshInterval;
// Used when an auto refresh is triggered
private readonly autoRefreshLoop = createAutoRefreshLoop();
constructor(
config: TimefilterConfig,
timeHistory: TimeHistoryContract,
@ -86,9 +88,13 @@ export class Timefilter {
return this.refreshIntervalUpdate$.asObservable();
};
public getAutoRefreshFetch$ = () => {
return this.autoRefreshFetch$.asObservable();
};
/**
* Get an observable that emits when it is time to refetch data due to refresh interval
* Each subscription to this observable resets internal interval
* Emitted value is a callback {@link AutoRefreshDoneFn} that must be called to restart refresh interval loop
* Apps should use this callback to start next auto refresh loop when view finished updating
*/
public getAutoRefreshFetch$ = () => this.autoRefreshLoop.loop$;
public getFetch$ = () => {
return this.fetch$.asObservable();
@ -166,13 +172,9 @@ export class Timefilter {
}
}
// Clear the previous auto refresh interval and start a new one (if not paused)
clearInterval(this._autoRefreshIntervalId);
if (!newRefreshInterval.pause) {
this._autoRefreshIntervalId = window.setInterval(
() => this.autoRefreshFetch$.next(),
newRefreshInterval.value
);
this.autoRefreshLoop.stop();
if (!newRefreshInterval.pause && newRefreshInterval.value !== 0) {
this.autoRefreshLoop.start(newRefreshInterval.value);
}
};

View file

@ -20,7 +20,7 @@ const createSetupContractMock = () => {
getEnabledUpdated$: jest.fn(),
getTimeUpdate$: jest.fn(),
getRefreshIntervalUpdate$: jest.fn(),
getAutoRefreshFetch$: jest.fn(() => new Observable<unknown>()),
getAutoRefreshFetch$: jest.fn(() => new Observable<() => void>()),
getFetch$: jest.fn(),
getTime: jest.fn(),
setTime: jest.fn(),

View file

@ -45,6 +45,8 @@ export {
ISessionsClient,
noSearchSessionStorageCapabilityMessage,
SEARCH_SESSIONS_MANAGEMENT_ID,
waitUntilNextSessionCompletes$,
WaitUntilNextSessionCompletesOptions,
} from './session';
export { getEsPreference } from './es_search';

View file

@ -11,3 +11,7 @@ export { SearchSessionState } from './search_session_state';
export { SessionsClient, ISessionsClient } from './sessions_client';
export { noSearchSessionStorageCapabilityMessage } from './i18n';
export { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants';
export {
waitUntilNextSessionCompletes$,
WaitUntilNextSessionCompletesOptions,
} from './session_helpers';

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { waitUntilNextSessionCompletes$ } from './session_helpers';
import { ISessionService, SessionService } from './session_service';
import { BehaviorSubject } from 'rxjs';
import { SearchSessionState } from './search_session_state';
import { NowProviderInternalContract } from '../../now_provider';
import { coreMock } from '../../../../../core/public/mocks';
import { createNowProviderMock } from '../../now_provider/mocks';
import { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants';
import { getSessionsClientMock } from './mocks';
let sessionService: ISessionService;
let state$: BehaviorSubject<SearchSessionState>;
let nowProvider: jest.Mocked<NowProviderInternalContract>;
let currentAppId$: BehaviorSubject<string>;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext();
const startService = coreMock.createSetup().getStartServices;
nowProvider = createNowProviderMock();
currentAppId$ = new BehaviorSubject('app');
sessionService = new SessionService(
initializerContext,
() =>
startService().then(([coreStart, ...rest]) => [
{
...coreStart,
application: {
...coreStart.application,
currentAppId$,
capabilities: {
...coreStart.application.capabilities,
management: {
kibana: {
[SEARCH_SESSIONS_MANAGEMENT_ID]: true,
},
},
},
},
},
...rest,
]),
getSessionsClientMock(),
nowProvider,
{ freezeState: false } // needed to use mocks inside state container
);
state$ = new BehaviorSubject<SearchSessionState>(SearchSessionState.None);
sessionService.state$.subscribe(state$);
});
describe('waitUntilNextSessionCompletes$', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('emits when next session starts', () => {
sessionService.start();
let untrackSearch = sessionService.trackSearch({ abort: () => {} });
untrackSearch();
const next = jest.fn();
const complete = jest.fn();
waitUntilNextSessionCompletes$(sessionService).subscribe({ next, complete });
expect(next).not.toBeCalled();
sessionService.start();
expect(next).not.toBeCalled();
untrackSearch = sessionService.trackSearch({ abort: () => {} });
untrackSearch();
expect(next).not.toBeCalled();
jest.advanceTimersByTime(500);
expect(next).not.toBeCalled();
jest.advanceTimersByTime(1000);
expect(next).toBeCalledTimes(1);
expect(complete).toBeCalled();
});
});

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { debounceTime, first, skipUntil } from 'rxjs/operators';
import { ISessionService } from './session_service';
import { SearchSessionState } from './search_session_state';
/**
* Options for {@link waitUntilNextSessionCompletes$}
*/
export interface WaitUntilNextSessionCompletesOptions {
/**
* For how long to wait between session state transitions before considering that session completed
*/
waitForIdle?: number;
}
/**
* Creates an observable that emits when next search session completes.
* This utility is helpful to use in the application to delay some tasks until next session completes.
*
* @param sessionService - {@link ISessionService}
* @param opts - {@link WaitUntilNextSessionCompletesOptions}
*/
export function waitUntilNextSessionCompletes$(
sessionService: ISessionService,
{ waitForIdle = 1000 }: WaitUntilNextSessionCompletesOptions = { waitForIdle: 1000 }
) {
return sessionService.state$.pipe(
// wait until new session starts
skipUntil(sessionService.state$.pipe(first((state) => state === SearchSessionState.None))),
// wait until new session starts loading
skipUntil(sessionService.state$.pipe(first((state) => state === SearchSessionState.Loading))),
// debounce to ignore quick switches from loading <-> completed.
// that could happen between sequential search requests inside a single session
debounceTime(waitForIdle),
// then wait until it finishes
first(
(state) =>
state === SearchSessionState.Completed || state === SearchSessionState.BackgroundCompleted
)
);
}

View file

@ -8,7 +8,7 @@
import _ from 'lodash';
import { merge, Subject, Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { debounceTime, tap, filter } from 'rxjs/operators';
import { i18n } from '@kbn/i18n';
import { createSearchSessionRestorationDataProvider, getState, splitState } from './discover_state';
import { RequestAdapter } from '../../../../inspector/public';
@ -393,12 +393,11 @@ function discoverController($route, $scope) {
$scope.state.index = $scope.indexPattern.id;
$scope.state.sort = getSortArray($scope.state.sort, $scope.indexPattern);
$scope.opts.fetch = $scope.fetch = function () {
$scope.opts.fetch = $scope.fetch = async function () {
$scope.fetchCounter++;
$scope.fetchError = undefined;
if (!validateTimeRange(timefilter.getTime(), toastNotifications)) {
$scope.resultState = 'none';
return;
}
// Abort any in-progress requests before fetching again
@ -494,11 +493,19 @@ function discoverController($route, $scope) {
showUnmappedFields,
};
// handler emitted by `timefilter.getAutoRefreshFetch$()`
// to notify when data completed loading and to start a new autorefresh loop
let autoRefreshDoneCb;
const fetch$ = merge(
refetch$,
filterManager.getFetches$(),
timefilter.getFetch$(),
timefilter.getAutoRefreshFetch$(),
timefilter.getAutoRefreshFetch$().pipe(
tap((done) => {
autoRefreshDoneCb = done;
}),
filter(() => $scope.fetchStatus !== fetchStatuses.LOADING)
),
data.query.queryString.getUpdates$(),
searchSessionManager.newSearchSessionIdFromURL$
).pipe(debounceTime(100));
@ -508,7 +515,16 @@ function discoverController($route, $scope) {
$scope,
fetch$,
{
next: $scope.fetch,
next: async () => {
try {
await $scope.fetch();
} finally {
// if there is a saved `autoRefreshDoneCb`, notify auto refresh service that
// the last fetch is completed so it starts the next auto refresh loop if needed
autoRefreshDoneCb?.();
autoRefreshDoneCb = undefined;
}
},
},
(error) => addFatalError(core.fatalErrors, error)
)

View file

@ -118,12 +118,15 @@ export class ExpressionLoader {
return this.execution ? (this.execution.inspect() as Adapters) : undefined;
}
update(expression?: string | ExpressionAstExpression, params?: IExpressionLoaderParams): void {
async update(
expression?: string | ExpressionAstExpression,
params?: IExpressionLoaderParams
): Promise<void> {
this.setParams(params);
this.loadingSubject.next(true);
if (expression) {
this.loadData(expression, this.params);
await this.loadData(expression, this.params);
} else if (this.data) {
this.render(this.data);
}

View file

@ -367,8 +367,8 @@ export class VisualizeEmbeddable
}
}
public reload = () => {
this.handleVisUpdate();
public reload = async () => {
await this.handleVisUpdate();
};
private async updateHandler() {
@ -395,13 +395,13 @@ export class VisualizeEmbeddable
});
if (this.handler && !abortController.signal.aborted) {
this.handler.update(this.expression, expressionParams);
await this.handler.update(this.expression, expressionParams);
}
}
private handleVisUpdate = async () => {
this.handleChanges();
this.updateHandler();
await this.updateHandler();
};
private uiStateChangeHandler = () => {

View file

@ -183,8 +183,12 @@ const TopNav = ({
useEffect(() => {
const autoRefreshFetchSub = services.data.query.timefilter.timefilter
.getAutoRefreshFetch$()
.subscribe(() => {
visInstance.embeddableHandler.reload();
.subscribe(async (done) => {
try {
await visInstance.embeddableHandler.reload();
} finally {
done();
}
});
return () => {
autoRefreshFetchSub.unsubscribe();

View file

@ -155,11 +155,7 @@ function createMockTimefilter() {
getBounds: jest.fn(() => timeFilter),
getRefreshInterval: () => {},
getRefreshIntervalDefaults: () => {},
getAutoRefreshFetch$: () => ({
subscribe: ({ next }: { next: () => void }) => {
return next;
},
}),
getAutoRefreshFetch$: () => new Observable(),
};
}

View file

@ -14,6 +14,7 @@ import { Toast } from 'kibana/public';
import { VisualizeFieldContext } from 'src/plugins/ui_actions/public';
import { Datatable } from 'src/plugins/expressions/public';
import { EuiBreadcrumb } from '@elastic/eui';
import { finalize, switchMap, tap } from 'rxjs/operators';
import { downloadMultipleAs } from '../../../../../src/plugins/share/public';
import {
createKbnUrlStateStorage,
@ -37,6 +38,7 @@ import {
Query,
SavedQuery,
syncQueryStateWithUrl,
waitUntilNextSessionCompletes$,
} from '../../../../../src/plugins/data/public';
import { LENS_EMBEDDABLE_TYPE, getFullPath, APP_ID } from '../../common';
import { LensAppProps, LensAppServices, LensAppState } from './types';
@ -193,14 +195,19 @@ export function App({
const autoRefreshSubscription = data.query.timefilter.timefilter
.getAutoRefreshFetch$()
.subscribe({
next: () => {
.pipe(
tap(() => {
setState((s) => ({
...s,
searchSessionId: data.search.session.start(),
}));
},
});
}),
switchMap((done) =>
// best way in lens to estimate that all panels are updated is to rely on search session service state
waitUntilNextSessionCompletes$(data.search.session).pipe(finalize(done))
)
)
.subscribe();
const kbnUrlStateStorage = createKbnUrlStateStorage({
history,