mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Search Sessions Stabilization Stage I (#134983)
Changes search service/search session infrastructure to improve performance, stability, and resiliency by ensuring that search sessions don’t add additional load on a cluster when the feature is not used
This commit is contained in:
parent
640592a56a
commit
5f3d439b50
74 changed files with 2175 additions and 2886 deletions
|
@ -10,9 +10,6 @@ Configure the search session settings in your `kibana.yml` configuration file.
|
|||
`data.search.sessions.enabled` {ess-icon}::
|
||||
Set to `true` (default) to enable search sessions.
|
||||
|
||||
`data.search.sessions.trackingInterval` {ess-icon}::
|
||||
The frequency for updating the state of a search session. The default is `10s`.
|
||||
|
||||
`data.search.sessions.pageSize` {ess-icon}::
|
||||
How many search sessions {kib} processes at once while monitoring
|
||||
session progress. The default is `100`.
|
||||
|
@ -21,9 +18,6 @@ session progress. The default is `100`.
|
|||
How long {kib} stores search results from unsaved sessions,
|
||||
after the last search in the session completes. The default is `5m`.
|
||||
|
||||
`data.search.sessions.notTouchedInProgressTimeout` {ess-icon}::
|
||||
How long a search session can run after a user navigates away without saving a session. The default is `1m`.
|
||||
|
||||
`data.search.sessions.maxUpdateRetries` {ess-icon}::
|
||||
How many retries {kib} can perform while attempting to save a search session. The default is `3`.
|
||||
|
||||
|
|
|
@ -13,3 +13,9 @@ export enum SearchSessionStatus {
|
|||
CANCELLED = 'cancelled',
|
||||
EXPIRED = 'expired',
|
||||
}
|
||||
|
||||
export enum SearchStatus {
|
||||
IN_PROGRESS = 'in_progress',
|
||||
ERROR = 'error',
|
||||
COMPLETE = 'complete',
|
||||
}
|
||||
|
|
|
@ -6,8 +6,9 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { SavedObjectsFindResponse } from '@kbn/core/server';
|
||||
import { SerializableRecord } from '@kbn/utility-types';
|
||||
import { SearchSessionStatus } from './status';
|
||||
import type { SearchSessionStatus, SearchStatus } from './status';
|
||||
|
||||
export const SEARCH_SESSION_TYPE = 'search-session';
|
||||
export interface SearchSessionSavedObjectAttributes {
|
||||
|
@ -24,25 +25,12 @@ export interface SearchSessionSavedObjectAttributes {
|
|||
* Creation time of the session
|
||||
*/
|
||||
created: string;
|
||||
/**
|
||||
* Last touch time of the session
|
||||
*/
|
||||
touched: string;
|
||||
|
||||
/**
|
||||
* Expiration time of the session. Expiration itself is managed by Elasticsearch.
|
||||
*/
|
||||
expires: string;
|
||||
/**
|
||||
* Time of transition into completed state,
|
||||
*
|
||||
* Can be "null" in case already completed session
|
||||
* transitioned into in-progress session
|
||||
*/
|
||||
completed?: string | null;
|
||||
/**
|
||||
* status
|
||||
*/
|
||||
status: SearchSessionStatus;
|
||||
|
||||
/**
|
||||
* locatorId (see share.url.locators service)
|
||||
*/
|
||||
|
@ -62,10 +50,6 @@ export interface SearchSessionSavedObjectAttributes {
|
|||
*/
|
||||
idMapping: Record<string, SearchSessionRequestInfo>;
|
||||
|
||||
/**
|
||||
* This value is true if the session was actively stored by the user. If it is false, the session may be purged by the system.
|
||||
*/
|
||||
persisted: boolean;
|
||||
/**
|
||||
* The realm type/name & username uniquely identifies the user who created this search session
|
||||
*/
|
||||
|
@ -76,6 +60,11 @@ export interface SearchSessionSavedObjectAttributes {
|
|||
* Version information to display warnings when trying to restore a session from a different version
|
||||
*/
|
||||
version: string;
|
||||
|
||||
/**
|
||||
* `true` if session was cancelled
|
||||
*/
|
||||
isCanceled?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchSessionRequestInfo {
|
||||
|
@ -87,20 +76,30 @@ export interface SearchSessionRequestInfo {
|
|||
* Search strategy used to submit the search request
|
||||
*/
|
||||
strategy: string;
|
||||
/**
|
||||
* status
|
||||
*/
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface SearchSessionRequestStatus {
|
||||
status: SearchStatus;
|
||||
/**
|
||||
* An optional error. Set if status is set to error.
|
||||
*/
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SearchSessionFindOptions {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
sortField?: string;
|
||||
sortOrder?: string;
|
||||
filter?: string;
|
||||
/**
|
||||
* On-the-fly calculated search session status
|
||||
*/
|
||||
export interface SearchSessionStatusResponse {
|
||||
status: SearchSessionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* List of search session objects with on-the-fly calculated search session statuses
|
||||
*/
|
||||
export interface SearchSessionsFindResponse
|
||||
extends SavedObjectsFindResponse<SearchSessionSavedObjectAttributes> {
|
||||
/**
|
||||
* Map containing calculated statuses of search sessions from the find response
|
||||
*/
|
||||
statuses: Record<string, SearchSessionStatusResponse>;
|
||||
}
|
||||
|
|
|
@ -72,6 +72,11 @@ export interface IKibanaSearchResponse<RawResponse = any> {
|
|||
*/
|
||||
isRestored?: boolean;
|
||||
|
||||
/**
|
||||
* Indicates whether the search has been saved to a search-session object and long keepAlive was set
|
||||
*/
|
||||
isStored?: boolean;
|
||||
|
||||
/**
|
||||
* Optional warnings returned from Elasticsearch (for example, deprecation warnings)
|
||||
*/
|
||||
|
@ -119,6 +124,11 @@ export interface ISearchOptions {
|
|||
*/
|
||||
isStored?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the search was successfully polled after session was saved. Search was added to a session saved object and keepAlive extended.
|
||||
*/
|
||||
isSearchStored?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the session is restored (i.e. search requests should re-use the stored search IDs,
|
||||
* rather than starting from scratch)
|
||||
|
@ -148,5 +158,11 @@ export interface ISearchOptions {
|
|||
*/
|
||||
export type ISearchOptionsSerializable = Pick<
|
||||
ISearchOptions,
|
||||
'strategy' | 'legacyHitsTotal' | 'sessionId' | 'isStored' | 'isRestore' | 'executionContext'
|
||||
| 'strategy'
|
||||
| 'legacyHitsTotal'
|
||||
| 'sessionId'
|
||||
| 'isStored'
|
||||
| 'isSearchStored'
|
||||
| 'isRestore'
|
||||
| 'executionContext'
|
||||
>;
|
||||
|
|
|
@ -13,42 +13,13 @@ export const searchSessionsConfigSchema = schema.object({
|
|||
* Turns the feature on \ off (incl. removing indicator and management screens)
|
||||
*/
|
||||
enabled: schema.boolean({ defaultValue: true }),
|
||||
/**
|
||||
* pageSize controls how many search session objects we load at once while monitoring
|
||||
* session completion
|
||||
*/
|
||||
pageSize: schema.number({ defaultValue: 100 }),
|
||||
/**
|
||||
* trackingInterval controls how often we track persisted search session objects progress
|
||||
*/
|
||||
trackingInterval: schema.duration({ defaultValue: '10s' }),
|
||||
|
||||
/**
|
||||
* cleanupInterval controls how often we track non-persisted search session objects for cleanup
|
||||
*/
|
||||
cleanupInterval: schema.duration({ defaultValue: '60s' }),
|
||||
|
||||
/**
|
||||
* expireInterval controls how often we track persisted search session objects for expiration
|
||||
*/
|
||||
expireInterval: schema.duration({ defaultValue: '60m' }),
|
||||
|
||||
/**
|
||||
* monitoringTaskTimeout controls for how long task manager waits for search session monitoring task to complete before considering it timed out,
|
||||
* If tasks timeouts it receives cancel signal and next task starts in "trackingInterval" time
|
||||
*/
|
||||
monitoringTaskTimeout: schema.duration({ defaultValue: '5m' }),
|
||||
|
||||
/**
|
||||
* notTouchedTimeout controls how long do we store unpersisted search session results,
|
||||
* after the last search in the session has completed
|
||||
* notTouchedTimeout controls how long user can save a session after all searches completed.
|
||||
* The client continues to poll searches to keep the alive until this timeout hits
|
||||
*/
|
||||
notTouchedTimeout: schema.duration({ defaultValue: '5m' }),
|
||||
/**
|
||||
* notTouchedInProgressTimeout controls how long do allow a search session to run after
|
||||
* a user has navigated away without persisting
|
||||
*/
|
||||
notTouchedInProgressTimeout: schema.duration({ defaultValue: '1m' }),
|
||||
|
||||
/**
|
||||
* maxUpdateRetries controls how many retries we perform while attempting to save a search session
|
||||
*/
|
||||
|
@ -60,15 +31,15 @@ export const searchSessionsConfigSchema = schema.object({
|
|||
defaultExpiration: schema.duration({ defaultValue: '7d' }),
|
||||
management: schema.object({
|
||||
/**
|
||||
* maxSessions controls how many saved search sessions we display per page on the management screen.
|
||||
* maxSessions controls how many saved search sessions we load on the management screen.
|
||||
*/
|
||||
maxSessions: schema.number({ defaultValue: 10000 }),
|
||||
maxSessions: schema.number({ defaultValue: 100 }),
|
||||
/**
|
||||
* refreshInterval controls how often we refresh the management screen.
|
||||
* refreshInterval controls how often we refresh the management screen. 0s as duration means that auto-refresh is turned off.
|
||||
*/
|
||||
refreshInterval: schema.duration({ defaultValue: '10s' }),
|
||||
refreshInterval: schema.duration({ defaultValue: '0s' }),
|
||||
/**
|
||||
* refreshTimeout controls how often we refresh the management screen.
|
||||
* refreshTimeout controls the timeout for loading search sessions on mgmt screen
|
||||
*/
|
||||
refreshTimeout: schema.duration({ defaultValue: '1m' }),
|
||||
expiresSoonWarning: schema.duration({ defaultValue: '1d' }),
|
||||
|
|
|
@ -548,6 +548,7 @@ describe('SearchInterceptor', () => {
|
|||
sessionId,
|
||||
isStored: true,
|
||||
isRestore: true,
|
||||
isSearchStored: false,
|
||||
strategy: 'ese',
|
||||
},
|
||||
})
|
||||
|
@ -732,17 +733,21 @@ describe('SearchInterceptor', () => {
|
|||
);
|
||||
sessionService.getSessionId.mockImplementation(() => sessionId);
|
||||
|
||||
const untrack = jest.fn();
|
||||
sessionService.trackSearch.mockImplementation(() => untrack);
|
||||
const trackSearchComplete = jest.fn();
|
||||
sessionService.trackSearch.mockImplementation(() => ({
|
||||
complete: trackSearchComplete,
|
||||
error: () => {},
|
||||
beforePoll: () => [{ isSearchStored: false }, () => {}],
|
||||
}));
|
||||
|
||||
const response = searchInterceptor.search({}, { pollInterval: 0, sessionId });
|
||||
response.subscribe({ next, error });
|
||||
await timeTravel(10);
|
||||
expect(sessionService.trackSearch).toBeCalledTimes(1);
|
||||
expect(untrack).not.toBeCalled();
|
||||
expect(trackSearchComplete).not.toBeCalled();
|
||||
await timeTravel(300);
|
||||
expect(sessionService.trackSearch).toBeCalledTimes(1);
|
||||
expect(untrack).toBeCalledTimes(1);
|
||||
expect(trackSearchComplete).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('session service should be able to cancel search', async () => {
|
||||
|
@ -752,9 +757,6 @@ describe('SearchInterceptor', () => {
|
|||
);
|
||||
sessionService.getSessionId.mockImplementation(() => sessionId);
|
||||
|
||||
const untrack = jest.fn();
|
||||
sessionService.trackSearch.mockImplementation(() => untrack);
|
||||
|
||||
const response = searchInterceptor.search({}, { pollInterval: 0, sessionId });
|
||||
response.subscribe({ next, error });
|
||||
await timeTravel(10);
|
||||
|
@ -778,9 +780,6 @@ describe('SearchInterceptor', () => {
|
|||
);
|
||||
sessionService.getSessionId.mockImplementation(() => sessionId);
|
||||
|
||||
const untrack = jest.fn();
|
||||
sessionService.trackSearch.mockImplementation(() => untrack);
|
||||
|
||||
const response1 = searchInterceptor.search(
|
||||
{},
|
||||
{ pollInterval: 0, sessionId: 'something different' }
|
||||
|
@ -798,9 +797,6 @@ describe('SearchInterceptor', () => {
|
|||
sessionService.getSessionId.mockImplementation(() => undefined);
|
||||
sessionService.isCurrentSession.mockImplementation((_sessionId) => false);
|
||||
|
||||
const untrack = jest.fn();
|
||||
sessionService.trackSearch.mockImplementation(() => untrack);
|
||||
|
||||
const response1 = searchInterceptor.search(
|
||||
{},
|
||||
{ pollInterval: 0, sessionId: 'something different' }
|
||||
|
@ -911,15 +907,22 @@ describe('SearchInterceptor', () => {
|
|||
expect(fetchMock).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should track searches that come from cache', async () => {
|
||||
test('should not track searches that come from cache', async () => {
|
||||
mockFetchImplementation(partialCompleteResponse);
|
||||
sessionService.isCurrentSession.mockImplementation(
|
||||
(_sessionId) => _sessionId === sessionId
|
||||
);
|
||||
sessionService.getSessionId.mockImplementation(() => sessionId);
|
||||
|
||||
const untrack = jest.fn();
|
||||
sessionService.trackSearch.mockImplementation(() => untrack);
|
||||
const completeSearch = jest.fn();
|
||||
|
||||
sessionService.trackSearch.mockImplementation((params) => ({
|
||||
complete: completeSearch,
|
||||
error: jest.fn(),
|
||||
beforePoll: jest.fn(() => {
|
||||
return [{ isSearchStored: false }, () => {}];
|
||||
}),
|
||||
}));
|
||||
|
||||
const req = {
|
||||
params: {
|
||||
|
@ -932,14 +935,15 @@ describe('SearchInterceptor', () => {
|
|||
response.subscribe({ next, error, complete });
|
||||
response2.subscribe({ next, error, complete });
|
||||
await timeTravel(10);
|
||||
|
||||
expect(fetchMock).toBeCalledTimes(1);
|
||||
expect(sessionService.trackSearch).toBeCalledTimes(2);
|
||||
expect(untrack).not.toBeCalled();
|
||||
expect(sessionService.trackSearch).toBeCalledTimes(1);
|
||||
expect(completeSearch).not.toBeCalled();
|
||||
await timeTravel(300);
|
||||
// Should be called only 2 times (once per partial response)
|
||||
expect(fetchMock).toBeCalledTimes(2);
|
||||
expect(sessionService.trackSearch).toBeCalledTimes(2);
|
||||
expect(untrack).toBeCalledTimes(2);
|
||||
expect(sessionService.trackSearch).toBeCalledTimes(1);
|
||||
expect(completeSearch).toBeCalledTimes(1);
|
||||
|
||||
expect(next).toBeCalledTimes(4);
|
||||
expect(error).toBeCalledTimes(0);
|
||||
|
@ -1125,8 +1129,15 @@ describe('SearchInterceptor', () => {
|
|||
);
|
||||
sessionService.getSessionId.mockImplementation(() => sessionId);
|
||||
|
||||
const untrack = jest.fn();
|
||||
sessionService.trackSearch.mockImplementation(() => untrack);
|
||||
const completeSearch = jest.fn();
|
||||
|
||||
sessionService.trackSearch.mockImplementation((params) => ({
|
||||
complete: completeSearch,
|
||||
error: jest.fn(),
|
||||
beforePoll: jest.fn(() => {
|
||||
return [{ isSearchStored: false }, () => {}];
|
||||
}),
|
||||
}));
|
||||
|
||||
const req = {
|
||||
params: {
|
||||
|
@ -1149,7 +1160,6 @@ describe('SearchInterceptor', () => {
|
|||
expect(error).toBeCalledTimes(0);
|
||||
expect(complete).toBeCalledTimes(0);
|
||||
expect(sessionService.trackSearch).toBeCalledTimes(1);
|
||||
expect(untrack).not.toBeCalled();
|
||||
|
||||
const next2 = jest.fn();
|
||||
const error2 = jest.fn();
|
||||
|
@ -1161,9 +1171,9 @@ describe('SearchInterceptor', () => {
|
|||
abortController.abort();
|
||||
|
||||
await timeTravel(300);
|
||||
// Both searches should be tracked and untracked
|
||||
expect(sessionService.trackSearch).toBeCalledTimes(2);
|
||||
expect(untrack).toBeCalledTimes(2);
|
||||
// Only first searches should be tracked and untracked
|
||||
expect(sessionService.trackSearch).toBeCalledTimes(1);
|
||||
expect(completeSearch).toBeCalledTimes(1);
|
||||
|
||||
// First search should error
|
||||
expect(next).toBeCalledTimes(1);
|
||||
|
@ -1186,8 +1196,15 @@ describe('SearchInterceptor', () => {
|
|||
);
|
||||
sessionService.getSessionId.mockImplementation(() => sessionId);
|
||||
|
||||
const untrack = jest.fn();
|
||||
sessionService.trackSearch.mockImplementation(() => untrack);
|
||||
const completeSearch = jest.fn();
|
||||
|
||||
sessionService.trackSearch.mockImplementation((params) => ({
|
||||
complete: completeSearch,
|
||||
error: jest.fn(),
|
||||
beforePoll: jest.fn(() => {
|
||||
return [{ isSearchStored: false }, () => {}];
|
||||
}),
|
||||
}));
|
||||
|
||||
const req = {
|
||||
params: {
|
||||
|
@ -1206,7 +1223,7 @@ describe('SearchInterceptor', () => {
|
|||
expect(error).toBeCalledTimes(0);
|
||||
expect(complete).toBeCalledTimes(0);
|
||||
expect(sessionService.trackSearch).toBeCalledTimes(1);
|
||||
expect(untrack).not.toBeCalled();
|
||||
expect(completeSearch).not.toBeCalled();
|
||||
|
||||
const next2 = jest.fn();
|
||||
const error2 = jest.fn();
|
||||
|
@ -1222,8 +1239,8 @@ describe('SearchInterceptor', () => {
|
|||
abortController.abort();
|
||||
|
||||
await timeTravel(300);
|
||||
expect(sessionService.trackSearch).toBeCalledTimes(2);
|
||||
expect(untrack).toBeCalledTimes(2);
|
||||
expect(sessionService.trackSearch).toBeCalledTimes(1);
|
||||
expect(completeSearch).toBeCalledTimes(1);
|
||||
|
||||
expect(next).toBeCalledTimes(2);
|
||||
expect(error).toBeCalledTimes(0);
|
||||
|
@ -1243,7 +1260,6 @@ describe('SearchInterceptor', () => {
|
|||
(_sessionId) => _sessionId === sessionId
|
||||
);
|
||||
sessionService.getSessionId.mockImplementation(() => sessionId);
|
||||
sessionService.trackSearch.mockImplementation(() => jest.fn());
|
||||
|
||||
const req = {
|
||||
params: {
|
||||
|
@ -1282,8 +1298,15 @@ describe('SearchInterceptor', () => {
|
|||
);
|
||||
sessionService.getSessionId.mockImplementation(() => sessionId);
|
||||
|
||||
const untrack = jest.fn();
|
||||
sessionService.trackSearch.mockImplementation(() => untrack);
|
||||
const completeSearch = jest.fn();
|
||||
|
||||
sessionService.trackSearch.mockImplementation((params) => ({
|
||||
complete: completeSearch,
|
||||
error: jest.fn(),
|
||||
beforePoll: jest.fn(() => {
|
||||
return [{ isSearchStored: false }, () => {}];
|
||||
}),
|
||||
}));
|
||||
|
||||
const req = {
|
||||
params: {
|
||||
|
|
|
@ -7,7 +7,16 @@
|
|||
*/
|
||||
|
||||
import { memoize, once } from 'lodash';
|
||||
import { BehaviorSubject, EMPTY, from, fromEvent, of, Subscription, throwError } from 'rxjs';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
EMPTY,
|
||||
from,
|
||||
fromEvent,
|
||||
Observable,
|
||||
of,
|
||||
Subscription,
|
||||
throwError,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
filter,
|
||||
|
@ -42,6 +51,7 @@ import {
|
|||
IAsyncSearchOptions,
|
||||
IKibanaSearchRequest,
|
||||
IKibanaSearchResponse,
|
||||
isCompleteResponse,
|
||||
ISearchOptions,
|
||||
ISearchOptionsSerializable,
|
||||
pollSearch,
|
||||
|
@ -146,18 +156,24 @@ export class SearchInterceptor {
|
|||
: TimeoutErrorMode.CONTACT;
|
||||
}
|
||||
|
||||
private createRequestHash$(request: IKibanaSearchRequest, options: IAsyncSearchOptions) {
|
||||
const { sessionId, isRestore } = options;
|
||||
private createRequestHash$(
|
||||
request: IKibanaSearchRequest,
|
||||
options: IAsyncSearchOptions
|
||||
): Observable<string | undefined> {
|
||||
const { sessionId } = options;
|
||||
// Preference is used to ensure all queries go to the same set of shards and it doesn't need to be hashed
|
||||
// https://www.elastic.co/guide/en/elasticsearch/reference/current/search-shard-routing.html#shard-and-node-preference
|
||||
const { preference, ...params } = request.params || {};
|
||||
const hashOptions = {
|
||||
...params,
|
||||
sessionId,
|
||||
isRestore,
|
||||
};
|
||||
|
||||
return from(sessionId ? createRequestHash(hashOptions) : of(undefined));
|
||||
if (!sessionId) return of(undefined); // don't use cache if doesn't belong to a session
|
||||
const sessionOptions = this.deps.session.getSearchOptions(options.sessionId);
|
||||
if (sessionOptions?.isRestore) return of(undefined); // don't use cache if restoring a session
|
||||
|
||||
return from(createRequestHash(hashOptions));
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -206,6 +222,8 @@ export class SearchInterceptor {
|
|||
serializableOptions.legacyHitsTotal = combined.legacyHitsTotal;
|
||||
if (combined.strategy !== undefined) serializableOptions.strategy = combined.strategy;
|
||||
if (combined.isStored !== undefined) serializableOptions.isStored = combined.isStored;
|
||||
if (combined.isSearchStored !== undefined)
|
||||
serializableOptions.isSearchStored = combined.isSearchStored;
|
||||
if (combined.executionContext !== undefined) {
|
||||
serializableOptions.executionContext = combined.executionContext;
|
||||
}
|
||||
|
@ -222,16 +240,47 @@ export class SearchInterceptor {
|
|||
options: IAsyncSearchOptions,
|
||||
searchAbortController: SearchAbortController
|
||||
) {
|
||||
const search = () =>
|
||||
this.runSearch(
|
||||
{ id, ...request },
|
||||
{ ...options, abortSignal: searchAbortController.getSignal() }
|
||||
);
|
||||
const { sessionId, strategy } = options;
|
||||
|
||||
const search = () => {
|
||||
const [{ isSearchStored }, afterPoll] = searchTracker?.beforePoll() ?? [
|
||||
{ isSearchStored: false },
|
||||
({ isSearchStored: boolean }) => {},
|
||||
];
|
||||
return this.runSearch(
|
||||
{ id, ...request },
|
||||
{
|
||||
...options,
|
||||
...this.deps.session.getSearchOptions(sessionId),
|
||||
abortSignal: searchAbortController.getSignal(),
|
||||
isSearchStored,
|
||||
}
|
||||
)
|
||||
.then((result) => {
|
||||
afterPoll({ isSearchStored: result.isStored ?? false });
|
||||
return result;
|
||||
})
|
||||
.catch((err) => {
|
||||
afterPoll({ isSearchStored: false });
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
const searchTracker = this.deps.session.isCurrentSession(sessionId)
|
||||
? this.deps.session.trackSearch({
|
||||
abort: () => searchAbortController.abort(),
|
||||
poll: async () => {
|
||||
if (id) {
|
||||
await search();
|
||||
}
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// track if this search's session will be send to background
|
||||
// if yes, then we don't need to cancel this search when it is aborted
|
||||
let isSavedToBackground = false;
|
||||
let isSavedToBackground =
|
||||
this.deps.session.isCurrentSession(sessionId) && this.deps.session.isStored();
|
||||
const savedToBackgroundSub =
|
||||
this.deps.session.isCurrentSession(sessionId) &&
|
||||
this.deps.session.state$
|
||||
|
@ -256,8 +305,15 @@ export class SearchInterceptor {
|
|||
...options,
|
||||
abortSignal: searchAbortController.getSignal(),
|
||||
}).pipe(
|
||||
tap((response) => (id = response.id)),
|
||||
tap((response) => {
|
||||
id = response.id;
|
||||
|
||||
if (isCompleteResponse(response)) {
|
||||
searchTracker?.complete();
|
||||
}
|
||||
}),
|
||||
catchError((e: Error) => {
|
||||
searchTracker?.error();
|
||||
cancel();
|
||||
return throwError(e);
|
||||
}),
|
||||
|
@ -378,9 +434,6 @@ export class SearchInterceptor {
|
|||
);
|
||||
|
||||
this.pendingCount$.next(this.pendingCount$.getValue() + 1);
|
||||
const untrackSearch = this.deps.session.isCurrentSession(sessionId)
|
||||
? this.deps.session.trackSearch({ abort: () => searchAbortController.abort() })
|
||||
: undefined;
|
||||
|
||||
// Abort the replay if the abortSignal is aborted.
|
||||
// The underlaying search will not abort unless searchAbortController fires.
|
||||
|
@ -410,10 +463,6 @@ export class SearchInterceptor {
|
|||
}),
|
||||
finalize(() => {
|
||||
this.pendingCount$.next(this.pendingCount$.getValue() - 1);
|
||||
if (untrackSearch && this.deps.session.isCurrentSession(sessionId)) {
|
||||
// untrack if this search still belongs to current session
|
||||
untrackSearch();
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
|
|
|
@ -23,7 +23,6 @@ import { Storage } from '@kbn/kibana-utils-plugin/public';
|
|||
import { ManagementSetup } from '@kbn/management-plugin/public';
|
||||
import { ScreenshotModePluginStart } from '@kbn/screenshot-mode-plugin/public';
|
||||
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
|
||||
import moment from 'moment';
|
||||
import React from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import {
|
||||
|
@ -118,7 +117,8 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
|
|||
this.initializerContext,
|
||||
getStartServices,
|
||||
this.sessionsClient,
|
||||
nowProvider
|
||||
nowProvider,
|
||||
this.usageCollector
|
||||
);
|
||||
/**
|
||||
* A global object that intercepts all searches and provides convenience methods for cancelling
|
||||
|
@ -256,9 +256,6 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
|
|||
application,
|
||||
basePath: http.basePath,
|
||||
storage: new Storage(window.localStorage),
|
||||
disableSaveAfterSessionCompletesTimeout: moment
|
||||
.duration(config.search.sessions.notTouchedTimeout)
|
||||
.asMilliseconds(),
|
||||
usageCollector: this.usageCollector,
|
||||
tourDisabled: screenshotMode.isScreenshotMode(),
|
||||
})
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import { ISessionsClient } from './sessions_client';
|
||||
import { ISessionService } from './session_service';
|
||||
import { SearchSessionState } from './search_session_state';
|
||||
|
@ -34,9 +34,17 @@ export function getSessionServiceMock(): jest.Mocked<ISessionService> {
|
|||
state$: new BehaviorSubject<SearchSessionState>(SearchSessionState.None).asObservable(),
|
||||
sessionMeta$: new BehaviorSubject<SessionMeta>({
|
||||
state: SearchSessionState.None,
|
||||
isContinued: false,
|
||||
}).asObservable(),
|
||||
disableSaveAfterSearchesExpire$: of(false),
|
||||
renameCurrentSession: jest.fn(),
|
||||
trackSearch: jest.fn((searchDescriptor) => () => {}),
|
||||
trackSearch: jest.fn((searchDescriptor) => ({
|
||||
complete: jest.fn(),
|
||||
error: jest.fn(),
|
||||
beforePoll: jest.fn(() => {
|
||||
return [{ isSearchStored: false }, () => {}];
|
||||
}),
|
||||
})),
|
||||
destroy: jest.fn(),
|
||||
cancel: jest.fn(),
|
||||
isStored: jest.fn(),
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import { createSessionStateContainer, SearchSessionState } from './search_session_state';
|
||||
import type { SearchSessionSavedObject } from './sessions_client';
|
||||
import { SearchSessionStatus } from '../../../common';
|
||||
|
||||
const mockSavedObject: SearchSessionSavedObject = {
|
||||
id: 'd7170a35-7e2c-48d6-8dec-9a056721b489',
|
||||
|
@ -19,11 +18,8 @@ const mockSavedObject: SearchSessionSavedObject = {
|
|||
locatorId: 'my_url_generator_id',
|
||||
idMapping: {},
|
||||
sessionId: 'session_id',
|
||||
touched: new Date().toISOString(),
|
||||
created: new Date().toISOString(),
|
||||
expires: new Date().toISOString(),
|
||||
status: SearchSessionStatus.COMPLETE,
|
||||
persisted: true,
|
||||
version: '8.0.0',
|
||||
},
|
||||
references: [],
|
||||
|
@ -46,7 +42,7 @@ describe('Session state container', () => {
|
|||
expect(state.get().appName).toBe(appName);
|
||||
});
|
||||
|
||||
test('track', () => {
|
||||
test('trackSearch', () => {
|
||||
expect(() => state.transitions.trackSearch({})).toThrowError();
|
||||
|
||||
state.transitions.start({ appName });
|
||||
|
@ -55,12 +51,12 @@ describe('Session state container', () => {
|
|||
expect(state.selectors.getState()).toBe(SearchSessionState.Loading);
|
||||
});
|
||||
|
||||
test('untrack', () => {
|
||||
test('removeSearch', () => {
|
||||
state.transitions.start({ appName });
|
||||
const search = {};
|
||||
state.transitions.trackSearch(search);
|
||||
expect(state.selectors.getState()).toBe(SearchSessionState.Loading);
|
||||
state.transitions.unTrackSearch(search);
|
||||
state.transitions.removeSearch(search);
|
||||
expect(state.selectors.getState()).toBe(SearchSessionState.Completed);
|
||||
});
|
||||
|
||||
|
@ -95,7 +91,7 @@ describe('Session state container', () => {
|
|||
expect(state.selectors.getState()).toBe(SearchSessionState.Loading);
|
||||
state.transitions.store(mockSavedObject);
|
||||
expect(state.selectors.getState()).toBe(SearchSessionState.BackgroundLoading);
|
||||
state.transitions.unTrackSearch(search);
|
||||
state.transitions.removeSearch(search);
|
||||
expect(state.selectors.getState()).toBe(SearchSessionState.BackgroundCompleted);
|
||||
state.transitions.clear();
|
||||
expect(state.selectors.getState()).toBe(SearchSessionState.None);
|
||||
|
@ -124,7 +120,7 @@ describe('Session state container', () => {
|
|||
const search = {};
|
||||
state.transitions.trackSearch(search);
|
||||
expect(state.selectors.getState()).toBe(SearchSessionState.BackgroundLoading);
|
||||
state.transitions.unTrackSearch(search);
|
||||
state.transitions.removeSearch(search);
|
||||
|
||||
expect(state.selectors.getState()).toBe(SearchSessionState.Restored);
|
||||
expect(() => state.transitions.store(mockSavedObject)).toThrowError();
|
||||
|
|
|
@ -57,13 +57,28 @@ export enum SearchSessionState {
|
|||
Canceled = 'canceled',
|
||||
}
|
||||
|
||||
/**
|
||||
* State of the tracked search
|
||||
*/
|
||||
export enum TrackedSearchState {
|
||||
InProgress = 'inProgress',
|
||||
Completed = 'completed',
|
||||
Errored = 'errored',
|
||||
}
|
||||
|
||||
export interface TrackedSearch<SearchDescriptor = unknown, SearchMeta extends {} = {}> {
|
||||
state: TrackedSearchState;
|
||||
searchDescriptor: SearchDescriptor;
|
||||
searchMeta: SearchMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state of SessionService
|
||||
* {@link SearchSessionState} is inferred from this state
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export interface SessionStateInternal<SearchDescriptor = unknown> {
|
||||
export interface SessionStateInternal<SearchDescriptor = unknown, SearchMeta extends {} = {}> {
|
||||
/**
|
||||
* Current session Id
|
||||
* Empty means there is no current active session.
|
||||
|
@ -92,10 +107,9 @@ export interface SessionStateInternal<SearchDescriptor = unknown> {
|
|||
isRestore: boolean;
|
||||
|
||||
/**
|
||||
* Set of currently running searches
|
||||
* within a session and any info associated with them
|
||||
* Set of all searches within a session and any info associated with them
|
||||
*/
|
||||
pendingSearches: SearchDescriptor[];
|
||||
trackedSearches: Array<TrackedSearch<SearchDescriptor, SearchMeta>>;
|
||||
|
||||
/**
|
||||
* There was at least a single search in this session
|
||||
|
@ -107,6 +121,17 @@ export interface SessionStateInternal<SearchDescriptor = unknown> {
|
|||
*/
|
||||
isCanceled: boolean;
|
||||
|
||||
/**
|
||||
* If session was continued from a different app,
|
||||
* If session continued from a different app, then it is very likely that `trackedSearches`
|
||||
* doesn't have all the search that were included into the session.
|
||||
* Session that was continued can't be saved because we can't guarantee all the searches saved.
|
||||
* This limitation should be fixed in https://github.com/elastic/kibana/issues/121543
|
||||
*
|
||||
* @deprecated - https://github.com/elastic/kibana/issues/121543
|
||||
*/
|
||||
isContinued: boolean;
|
||||
|
||||
/**
|
||||
* Start time of the current session (from browser perspective)
|
||||
*/
|
||||
|
@ -124,27 +149,34 @@ export interface SessionStateInternal<SearchDescriptor = unknown> {
|
|||
}
|
||||
|
||||
const createSessionDefaultState: <
|
||||
SearchDescriptor = unknown
|
||||
>() => SessionStateInternal<SearchDescriptor> = () => ({
|
||||
SearchDescriptor = unknown,
|
||||
SearchMeta extends {} = {}
|
||||
>() => SessionStateInternal<SearchDescriptor, SearchMeta> = () => ({
|
||||
sessionId: undefined,
|
||||
appName: undefined,
|
||||
isStored: false,
|
||||
isRestore: false,
|
||||
isCanceled: false,
|
||||
isContinued: false,
|
||||
isStarted: false,
|
||||
pendingSearches: [],
|
||||
trackedSearches: [],
|
||||
});
|
||||
|
||||
export interface SessionPureTransitions<
|
||||
SearchDescriptor = unknown,
|
||||
S = SessionStateInternal<SearchDescriptor>
|
||||
SearchMeta extends {} = {},
|
||||
S = SessionStateInternal<SearchDescriptor, SearchMeta>
|
||||
> {
|
||||
start: (state: S) => ({ appName }: { appName: string }) => S;
|
||||
restore: (state: S) => (sessionId: string) => S;
|
||||
clear: (state: S) => () => S;
|
||||
store: (state: S) => (searchSessionSavedObject: SearchSessionSavedObject) => S;
|
||||
trackSearch: (state: S) => (search: SearchDescriptor) => S;
|
||||
unTrackSearch: (state: S) => (search: SearchDescriptor) => S;
|
||||
trackSearch: (state: S) => (search: SearchDescriptor, meta?: SearchMeta) => S;
|
||||
removeSearch: (state: S) => (search: SearchDescriptor) => S;
|
||||
completeSearch: (state: S) => (search: SearchDescriptor) => S;
|
||||
errorSearch: (state: S) => (search: SearchDescriptor) => S;
|
||||
updateSearchMeta: (state: S) => (search: SearchDescriptor, meta: Partial<SearchMeta>) => S;
|
||||
|
||||
cancel: (state: S) => () => S;
|
||||
setSearchSessionSavedObject: (
|
||||
state: S
|
||||
|
@ -177,21 +209,78 @@ export const sessionPureTransitions: SessionPureTransitions = {
|
|||
searchSessionSavedObject,
|
||||
};
|
||||
},
|
||||
trackSearch: (state) => (search) => {
|
||||
if (!state.sessionId) throw new Error("Can't track search. Missing sessionId");
|
||||
trackSearch:
|
||||
(state) =>
|
||||
(search, meta = {}) => {
|
||||
if (!state.sessionId) throw new Error("Can't track search. Missing sessionId");
|
||||
return {
|
||||
...state,
|
||||
isStarted: true,
|
||||
trackedSearches: state.trackedSearches.concat({
|
||||
state: TrackedSearchState.InProgress,
|
||||
searchDescriptor: search,
|
||||
searchMeta: meta,
|
||||
}),
|
||||
completedTime: undefined,
|
||||
};
|
||||
},
|
||||
removeSearch: (state) => (search) => {
|
||||
const trackedSearches = state.trackedSearches.filter((s) => s.searchDescriptor !== search);
|
||||
return {
|
||||
...state,
|
||||
isStarted: true,
|
||||
pendingSearches: state.pendingSearches.concat(search),
|
||||
completedTime: undefined,
|
||||
trackedSearches,
|
||||
completedTime:
|
||||
trackedSearches.filter((s) => s.state !== TrackedSearchState.InProgress).length === 0
|
||||
? new Date()
|
||||
: state.completedTime,
|
||||
};
|
||||
},
|
||||
unTrackSearch: (state) => (search) => {
|
||||
const pendingSearches = state.pendingSearches.filter((s) => s !== search);
|
||||
completeSearch: (state) => (search) => {
|
||||
return {
|
||||
...state,
|
||||
pendingSearches,
|
||||
completedTime: pendingSearches.length === 0 ? new Date() : state.completedTime,
|
||||
trackedSearches: state.trackedSearches.map((s) => {
|
||||
if (s.searchDescriptor === search) {
|
||||
return {
|
||||
...s,
|
||||
state: TrackedSearchState.Completed,
|
||||
};
|
||||
}
|
||||
|
||||
return s;
|
||||
}),
|
||||
};
|
||||
},
|
||||
errorSearch: (state) => (search) => {
|
||||
return {
|
||||
...state,
|
||||
trackedSearches: state.trackedSearches.map((s) => {
|
||||
if (s.searchDescriptor === search) {
|
||||
return {
|
||||
...s,
|
||||
state: TrackedSearchState.Errored,
|
||||
};
|
||||
}
|
||||
|
||||
return s;
|
||||
}),
|
||||
};
|
||||
},
|
||||
updateSearchMeta: (state) => (search, meta) => {
|
||||
return {
|
||||
...state,
|
||||
trackedSearches: state.trackedSearches.map((s) => {
|
||||
if (s.searchDescriptor === search) {
|
||||
return {
|
||||
...s,
|
||||
searchMeta: {
|
||||
...s.searchMeta,
|
||||
...meta,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return s;
|
||||
}),
|
||||
};
|
||||
},
|
||||
cancel: (state) => () => {
|
||||
|
@ -232,14 +321,23 @@ export interface SessionMeta {
|
|||
startTime?: Date;
|
||||
canceledTime?: Date;
|
||||
completedTime?: Date;
|
||||
|
||||
/**
|
||||
* @deprecated - see remarks in {@link SessionStateInternal}
|
||||
*/
|
||||
isContinued: boolean;
|
||||
}
|
||||
|
||||
export interface SessionPureSelectors<
|
||||
SearchDescriptor = unknown,
|
||||
S = SessionStateInternal<SearchDescriptor>
|
||||
SearchMeta extends {} = {},
|
||||
S = SessionStateInternal<SearchDescriptor, SearchMeta>
|
||||
> {
|
||||
getState: (state: S) => () => SearchSessionState;
|
||||
getMeta: (state: S) => () => SessionMeta;
|
||||
getSearch: (
|
||||
state: S
|
||||
) => (search: SearchDescriptor) => TrackedSearch<SearchDescriptor, SearchMeta> | null;
|
||||
}
|
||||
|
||||
export const sessionPureSelectors: SessionPureSelectors = {
|
||||
|
@ -247,17 +345,22 @@ export const sessionPureSelectors: SessionPureSelectors = {
|
|||
if (!state.sessionId) return SearchSessionState.None;
|
||||
if (!state.isStarted) return SearchSessionState.None;
|
||||
if (state.isCanceled) return SearchSessionState.Canceled;
|
||||
|
||||
const pendingSearches = state.trackedSearches.filter(
|
||||
(s) => s.state === TrackedSearchState.InProgress
|
||||
);
|
||||
|
||||
switch (true) {
|
||||
case state.isRestore:
|
||||
return state.pendingSearches.length > 0
|
||||
return pendingSearches.length > 0
|
||||
? SearchSessionState.BackgroundLoading
|
||||
: SearchSessionState.Restored;
|
||||
case state.isStored:
|
||||
return state.pendingSearches.length > 0
|
||||
return pendingSearches.length > 0
|
||||
? SearchSessionState.BackgroundLoading
|
||||
: SearchSessionState.BackgroundCompleted;
|
||||
default:
|
||||
return state.pendingSearches.length > 0
|
||||
return pendingSearches.length > 0
|
||||
? SearchSessionState.Loading
|
||||
: SearchSessionState.Completed;
|
||||
}
|
||||
|
@ -272,24 +375,31 @@ export const sessionPureSelectors: SessionPureSelectors = {
|
|||
startTime: state.searchSessionSavedObject?.attributes.created
|
||||
? new Date(state.searchSessionSavedObject?.attributes.created)
|
||||
: state.startTime,
|
||||
completedTime: state.searchSessionSavedObject?.attributes.completed
|
||||
? new Date(state.searchSessionSavedObject?.attributes.completed)
|
||||
: state.completedTime,
|
||||
completedTime: state.completedTime,
|
||||
canceledTime: state.canceledTime,
|
||||
isContinued: state.isContinued,
|
||||
});
|
||||
},
|
||||
getSearch(state) {
|
||||
return (search) => {
|
||||
return state.trackedSearches.find((s) => s.searchDescriptor === search) ?? null;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export type SessionStateContainer<SearchDescriptor = unknown> = StateContainer<
|
||||
SessionStateInternal<SearchDescriptor>,
|
||||
SessionPureTransitions<SearchDescriptor>,
|
||||
SessionPureSelectors<SearchDescriptor>
|
||||
export type SessionStateContainer<
|
||||
SearchDescriptor = unknown,
|
||||
SearchMeta extends {} = {}
|
||||
> = StateContainer<
|
||||
SessionStateInternal<SearchDescriptor, SearchMeta>,
|
||||
SessionPureTransitions<SearchDescriptor, SearchMeta>,
|
||||
SessionPureSelectors<SearchDescriptor, SearchMeta>
|
||||
>;
|
||||
|
||||
export const createSessionStateContainer = <SearchDescriptor = unknown>(
|
||||
export const createSessionStateContainer = <SearchDescriptor = unknown, SearchMeta extends {} = {}>(
|
||||
{ freeze = true }: { freeze: boolean } = { freeze: true }
|
||||
): {
|
||||
stateContainer: SessionStateContainer<SearchDescriptor>;
|
||||
stateContainer: SessionStateContainer<SearchDescriptor, SearchMeta>;
|
||||
sessionState$: Observable<SearchSessionState>;
|
||||
sessionMeta$: Observable<SessionMeta>;
|
||||
} => {
|
||||
|
@ -298,7 +408,7 @@ export const createSessionStateContainer = <SearchDescriptor = unknown>(
|
|||
sessionPureTransitions,
|
||||
sessionPureSelectors,
|
||||
freeze ? undefined : { freeze: (s) => s }
|
||||
) as SessionStateContainer<SearchDescriptor>;
|
||||
) as unknown as SessionStateContainer<SearchDescriptor, SearchMeta>;
|
||||
|
||||
const sessionMeta$: Observable<SessionMeta> = stateContainer.state$.pipe(
|
||||
map(() => stateContainer.selectors.getMeta()),
|
||||
|
|
|
@ -23,7 +23,13 @@ let nowProvider: jest.Mocked<NowProviderInternalContract>;
|
|||
let currentAppId$: BehaviorSubject<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
const initializerContext = coreMock.createPluginInitializerContext();
|
||||
const initializerContext = coreMock.createPluginInitializerContext({
|
||||
search: {
|
||||
sessions: {
|
||||
notTouchedTimeout: '5m',
|
||||
},
|
||||
},
|
||||
});
|
||||
const startService = coreMock.createSetup().getStartServices;
|
||||
nowProvider = createNowProviderMock();
|
||||
currentAppId$ = new BehaviorSubject('app');
|
||||
|
@ -50,6 +56,7 @@ beforeEach(() => {
|
|||
]),
|
||||
getSessionsClientMock(),
|
||||
nowProvider,
|
||||
undefined,
|
||||
{ freezeState: false } // needed to use mocks inside state container
|
||||
);
|
||||
state$ = new BehaviorSubject<SearchSessionState>(SearchSessionState.None);
|
||||
|
@ -67,8 +74,13 @@ describe('waitUntilNextSessionCompletes$', () => {
|
|||
'emits when next session starts',
|
||||
fakeSchedulers((advance) => {
|
||||
sessionService.start();
|
||||
let untrackSearch = sessionService.trackSearch({ abort: () => {} });
|
||||
untrackSearch();
|
||||
|
||||
let { complete: completeSearch } = sessionService.trackSearch({
|
||||
abort: () => {},
|
||||
poll: async () => {},
|
||||
});
|
||||
|
||||
completeSearch();
|
||||
|
||||
const next = jest.fn();
|
||||
const complete = jest.fn();
|
||||
|
@ -78,8 +90,12 @@ describe('waitUntilNextSessionCompletes$', () => {
|
|||
sessionService.start();
|
||||
expect(next).not.toBeCalled();
|
||||
|
||||
untrackSearch = sessionService.trackSearch({ abort: () => {} });
|
||||
untrackSearch();
|
||||
completeSearch = sessionService.trackSearch({
|
||||
abort: () => {},
|
||||
poll: async () => {},
|
||||
}).complete;
|
||||
|
||||
completeSearch();
|
||||
|
||||
expect(next).not.toBeCalled();
|
||||
advance(500);
|
||||
|
|
|
@ -40,7 +40,6 @@ const timeFilter = dataStart.query.timefilter.timefilter as jest.Mocked<Timefilt
|
|||
timeFilter.getRefreshIntervalUpdate$.mockImplementation(() => refreshInterval$.pipe(map(() => {})));
|
||||
timeFilter.getRefreshInterval.mockImplementation(() => refreshInterval$.getValue());
|
||||
|
||||
const disableSaveAfterSessionCompletesTimeout = 5 * 60 * 1000;
|
||||
const tourDisabled = false;
|
||||
|
||||
function Container({ children }: { children?: ReactNode }) {
|
||||
|
@ -64,7 +63,6 @@ test("shouldn't show indicator in case no active search session", async () => {
|
|||
sessionService,
|
||||
application,
|
||||
storage,
|
||||
disableSaveAfterSessionCompletesTimeout,
|
||||
usageCollector,
|
||||
basePath,
|
||||
tourDisabled,
|
||||
|
@ -93,7 +91,6 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => {
|
|||
sessionService,
|
||||
application,
|
||||
storage,
|
||||
disableSaveAfterSessionCompletesTimeout,
|
||||
usageCollector,
|
||||
basePath,
|
||||
tourDisabled,
|
||||
|
@ -124,7 +121,6 @@ test('should show indicator in case there is an active search session', async ()
|
|||
sessionService: { ...sessionService, state$ },
|
||||
application,
|
||||
storage,
|
||||
disableSaveAfterSessionCompletesTimeout,
|
||||
usageCollector,
|
||||
basePath,
|
||||
tourDisabled,
|
||||
|
@ -150,7 +146,6 @@ test('should be disabled in case uiConfig says so ', async () => {
|
|||
sessionService: { ...sessionService, state$ },
|
||||
application,
|
||||
storage,
|
||||
disableSaveAfterSessionCompletesTimeout,
|
||||
usageCollector,
|
||||
basePath,
|
||||
tourDisabled,
|
||||
|
@ -175,7 +170,6 @@ test('should be disabled in case not enough permissions', async () => {
|
|||
sessionService: { ...sessionService, state$, hasAccess: () => false },
|
||||
application,
|
||||
storage,
|
||||
disableSaveAfterSessionCompletesTimeout,
|
||||
basePath,
|
||||
tourDisabled,
|
||||
});
|
||||
|
@ -195,20 +189,15 @@ test('should be disabled in case not enough permissions', async () => {
|
|||
});
|
||||
|
||||
describe('Completed inactivity', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
test('save should be disabled after completed and timeout', async () => {
|
||||
const state$ = new BehaviorSubject(SearchSessionState.Loading);
|
||||
|
||||
const disableSaveAfterSearchesExpire$ = new BehaviorSubject(false);
|
||||
|
||||
const SearchSessionIndicator = createConnectedSearchSessionIndicator({
|
||||
sessionService: { ...sessionService, state$ },
|
||||
sessionService: { ...sessionService, state$, disableSaveAfterSearchesExpire$ },
|
||||
application,
|
||||
storage,
|
||||
disableSaveAfterSessionCompletesTimeout,
|
||||
usageCollector,
|
||||
basePath,
|
||||
tourDisabled,
|
||||
|
@ -227,30 +216,10 @@ describe('Completed inactivity', () => {
|
|||
expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled();
|
||||
|
||||
act(() => {
|
||||
state$.next(SearchSessionState.Completed);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2.5 * 60 * 1000);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled();
|
||||
expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(0);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2.5 * 60 * 1000);
|
||||
disableSaveAfterSearchesExpire$.next(true);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled();
|
||||
expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -270,7 +239,6 @@ describe('tour steps', () => {
|
|||
sessionService: { ...sessionService, state$ },
|
||||
application,
|
||||
storage,
|
||||
disableSaveAfterSessionCompletesTimeout,
|
||||
usageCollector,
|
||||
basePath,
|
||||
tourDisabled,
|
||||
|
@ -312,7 +280,6 @@ describe('tour steps', () => {
|
|||
sessionService: { ...sessionService, state$ },
|
||||
application,
|
||||
storage,
|
||||
disableSaveAfterSessionCompletesTimeout,
|
||||
usageCollector,
|
||||
basePath,
|
||||
tourDisabled,
|
||||
|
@ -347,7 +314,6 @@ describe('tour steps', () => {
|
|||
sessionService: { ...sessionService, state$ },
|
||||
application,
|
||||
storage,
|
||||
disableSaveAfterSessionCompletesTimeout,
|
||||
usageCollector,
|
||||
basePath,
|
||||
tourDisabled: true,
|
||||
|
@ -393,7 +359,6 @@ describe('tour steps', () => {
|
|||
sessionService: { ...sessionService, state$ },
|
||||
application,
|
||||
storage,
|
||||
disableSaveAfterSessionCompletesTimeout,
|
||||
usageCollector,
|
||||
basePath,
|
||||
tourDisabled,
|
||||
|
@ -421,7 +386,6 @@ describe('tour steps', () => {
|
|||
sessionService: { ...sessionService, state$ },
|
||||
application,
|
||||
storage,
|
||||
disableSaveAfterSessionCompletesTimeout,
|
||||
usageCollector,
|
||||
basePath,
|
||||
tourDisabled,
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { debounce, distinctUntilChanged, mapTo, switchMap, tap } from 'rxjs/operators';
|
||||
import { merge, of, timer } from 'rxjs';
|
||||
import { debounce } from 'rxjs/operators';
|
||||
import { timer } from 'rxjs';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { RedirectAppLinks } from '@kbn/kibana-react-plugin/public';
|
||||
|
@ -25,11 +25,6 @@ export interface SearchSessionIndicatorDeps {
|
|||
application: ApplicationStart;
|
||||
basePath: IBasePath;
|
||||
storage: IStorageWrapper;
|
||||
/**
|
||||
* Controls for how long we allow to save a session,
|
||||
* after the last search in the session has completed
|
||||
*/
|
||||
disableSaveAfterSessionCompletesTimeout: number;
|
||||
tourDisabled: boolean;
|
||||
usageCollector?: SearchUsageCollector;
|
||||
}
|
||||
|
@ -38,7 +33,6 @@ export const createConnectedSearchSessionIndicator = ({
|
|||
sessionService,
|
||||
application,
|
||||
storage,
|
||||
disableSaveAfterSessionCompletesTimeout,
|
||||
usageCollector,
|
||||
basePath,
|
||||
tourDisabled,
|
||||
|
@ -49,25 +43,14 @@ export const createConnectedSearchSessionIndicator = ({
|
|||
debounce((_state) => timer(_state === SearchSessionState.None ? 50 : 300)) // switch to None faster to quickly remove indicator when navigating away
|
||||
);
|
||||
|
||||
const disableSaveAfterSessionCompleteTimedOut$ = sessionService.state$.pipe(
|
||||
switchMap((_state) =>
|
||||
_state === SearchSessionState.Completed
|
||||
? merge(of(false), timer(disableSaveAfterSessionCompletesTimeout).pipe(mapTo(true)))
|
||||
: of(false)
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
tap((value) => {
|
||||
if (value) usageCollector?.trackSessionIndicatorSaveDisabled();
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
const state = useObservable(debouncedSessionServiceState$, SearchSessionState.None);
|
||||
const isSaveDisabledByApp = sessionService.getSearchSessionIndicatorUiConfig().isDisabled();
|
||||
const disableSaveAfterSessionCompleteTimedOut = useObservable(
|
||||
disableSaveAfterSessionCompleteTimedOut$,
|
||||
const disableSaveAfterSearchesExpire = useObservable(
|
||||
sessionService.disableSaveAfterSearchesExpire$,
|
||||
false
|
||||
);
|
||||
|
||||
const [searchSessionIndicator, setSearchSessionIndicator] =
|
||||
useState<SearchSessionIndicatorRef | null>(null);
|
||||
const searchSessionIndicatorRef = useCallback((ref: SearchSessionIndicatorRef) => {
|
||||
|
@ -82,7 +65,7 @@ export const createConnectedSearchSessionIndicator = ({
|
|||
let managementDisabled = false;
|
||||
let managementDisabledReasonText: string = '';
|
||||
|
||||
if (disableSaveAfterSessionCompleteTimedOut) {
|
||||
if (disableSaveAfterSearchesExpire) {
|
||||
saveDisabled = true;
|
||||
saveDisabledReasonText = i18n.translate(
|
||||
'data.searchSessionIndicator.disabledDueToTimeoutMessage',
|
||||
|
@ -160,7 +143,7 @@ export const createConnectedSearchSessionIndicator = ({
|
|||
startTime,
|
||||
completedTime,
|
||||
canceledTime,
|
||||
} = useObservable(sessionService.sessionMeta$, { state });
|
||||
} = useObservable(sessionService.sessionMeta$, { state, isContinued: false });
|
||||
const saveSearchSessionNameFn = useCallback(async (newName: string) => {
|
||||
await sessionService.renameCurrentSession(newName);
|
||||
}, []);
|
||||
|
|
|
@ -75,7 +75,7 @@ const ContinueInBackgroundButton = ({
|
|||
<EuiToolTip content={saveDisabledReasonText}>
|
||||
<EuiButtonEmpty
|
||||
onClick={onContinueInBackground}
|
||||
data-test-subj={'searchSessionIndicatorContinueInBackgroundBtn'}
|
||||
data-test-subj={'searchSessionIndicatorSaveBtn'}
|
||||
isDisabled={saveDisabled}
|
||||
{...buttonProps}
|
||||
>
|
||||
|
|
|
@ -6,18 +6,19 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { SessionService, ISessionService } from './session_service';
|
||||
import { ISessionService, SessionService } from './session_service';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { take, toArray } from 'rxjs/operators';
|
||||
import { first, take, toArray } from 'rxjs/operators';
|
||||
import { getSessionsClientMock } from './mocks';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { SearchSessionState } from './search_session_state';
|
||||
import { createNowProviderMock } from '../../now_provider/mocks';
|
||||
import { NowProviderInternalContract } from '../../now_provider';
|
||||
import { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants';
|
||||
import type { SearchSessionSavedObject, ISessionsClient } from './sessions_client';
|
||||
import { SearchSessionStatus } from '../../../common';
|
||||
import type { ISessionsClient, SearchSessionSavedObject } from './sessions_client';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { SearchUsageCollector } from '../..';
|
||||
import { createSearchUsageCollectorMock } from '../collectors/mocks';
|
||||
|
||||
const mockSavedObject: SearchSessionSavedObject = {
|
||||
id: 'd7170a35-7e2c-48d6-8dec-9a056721b489',
|
||||
|
@ -28,11 +29,8 @@ const mockSavedObject: SearchSessionSavedObject = {
|
|||
locatorId: 'my_locator_id',
|
||||
idMapping: {},
|
||||
sessionId: 'session_id',
|
||||
touched: new Date().toISOString(),
|
||||
created: new Date().toISOString(),
|
||||
expires: new Date().toISOString(),
|
||||
status: SearchSessionStatus.COMPLETE,
|
||||
persisted: true,
|
||||
version: '8.0.0',
|
||||
},
|
||||
references: [],
|
||||
|
@ -46,9 +44,16 @@ describe('Session service', () => {
|
|||
let currentAppId$: BehaviorSubject<string>;
|
||||
let toastService: jest.Mocked<CoreStart['notifications']['toasts']>;
|
||||
let sessionsClient: jest.Mocked<ISessionsClient>;
|
||||
let usageCollector: jest.Mocked<SearchUsageCollector>;
|
||||
|
||||
beforeEach(() => {
|
||||
const initializerContext = coreMock.createPluginInitializerContext();
|
||||
const initializerContext = coreMock.createPluginInitializerContext({
|
||||
search: {
|
||||
sessions: {
|
||||
notTouchedTimeout: 5 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
const startService = coreMock.createSetup().getStartServices;
|
||||
const startServicesMock = coreMock.createStart();
|
||||
toastService = startServicesMock.notifications.toasts;
|
||||
|
@ -60,6 +65,7 @@ describe('Session service', () => {
|
|||
id,
|
||||
attributes: { ...mockSavedObject.attributes, sessionId: id },
|
||||
}));
|
||||
usageCollector = createSearchUsageCollectorMock();
|
||||
sessionService = new SessionService(
|
||||
initializerContext,
|
||||
() =>
|
||||
|
@ -83,6 +89,7 @@ describe('Session service', () => {
|
|||
]),
|
||||
sessionsClient,
|
||||
nowProvider,
|
||||
usageCollector,
|
||||
{ freezeState: false } // needed to use mocks inside state container
|
||||
);
|
||||
state$ = new BehaviorSubject<SearchSessionState>(SearchSessionState.None);
|
||||
|
@ -132,34 +139,86 @@ describe('Session service', () => {
|
|||
});
|
||||
|
||||
it('Tracks searches for current session', () => {
|
||||
expect(() => sessionService.trackSearch({ abort: () => {} })).toThrowError();
|
||||
expect(() =>
|
||||
sessionService.trackSearch({ abort: () => {}, poll: async () => {} })
|
||||
).toThrowError();
|
||||
expect(state$.getValue()).toBe(SearchSessionState.None);
|
||||
|
||||
sessionService.start();
|
||||
const untrack1 = sessionService.trackSearch({ abort: () => {} });
|
||||
const complete1 = sessionService.trackSearch({
|
||||
abort: () => {},
|
||||
poll: async () => {},
|
||||
}).complete;
|
||||
expect(state$.getValue()).toBe(SearchSessionState.Loading);
|
||||
const untrack2 = sessionService.trackSearch({ abort: () => {} });
|
||||
const complete2 = sessionService.trackSearch({
|
||||
abort: () => {},
|
||||
poll: async () => {},
|
||||
}).complete;
|
||||
|
||||
expect(state$.getValue()).toBe(SearchSessionState.Loading);
|
||||
untrack1();
|
||||
complete1();
|
||||
expect(state$.getValue()).toBe(SearchSessionState.Loading);
|
||||
untrack2();
|
||||
complete2();
|
||||
expect(state$.getValue()).toBe(SearchSessionState.Completed);
|
||||
});
|
||||
|
||||
it('Cancels all tracked searches within current session', async () => {
|
||||
const abort = jest.fn();
|
||||
const poll = jest.fn();
|
||||
|
||||
sessionService.start();
|
||||
sessionService.trackSearch({ abort });
|
||||
sessionService.trackSearch({ abort });
|
||||
sessionService.trackSearch({ abort });
|
||||
const untrack = sessionService.trackSearch({ abort });
|
||||
sessionService.trackSearch({ abort, poll });
|
||||
sessionService.trackSearch({ abort, poll });
|
||||
sessionService.trackSearch({ abort, poll });
|
||||
const complete = sessionService.trackSearch({ abort, poll }).complete;
|
||||
complete();
|
||||
|
||||
untrack();
|
||||
await sessionService.cancel();
|
||||
|
||||
expect(abort).toBeCalledTimes(3);
|
||||
});
|
||||
|
||||
describe('Keeping searches alive', () => {
|
||||
let dateNowSpy: jest.SpyInstance;
|
||||
let now = Date.now();
|
||||
const advanceTimersBy = (by: number) => {
|
||||
now = now + by;
|
||||
jest.advanceTimersByTime(by);
|
||||
};
|
||||
beforeEach(() => {
|
||||
dateNowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
now = Date.now();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
dateNowSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Polls all completed searches to keep them alive', async () => {
|
||||
const abort = jest.fn();
|
||||
const poll = jest.fn(() => Promise.resolve());
|
||||
|
||||
sessionService.enableStorage({
|
||||
getName: async () => 'Name',
|
||||
getLocatorData: async () => ({
|
||||
id: 'id',
|
||||
initialState: {},
|
||||
restoreState: {},
|
||||
}),
|
||||
});
|
||||
sessionService.start();
|
||||
|
||||
const searchTracker = sessionService.trackSearch({ abort, poll });
|
||||
searchTracker.complete();
|
||||
|
||||
expect(poll).toHaveBeenCalledTimes(0);
|
||||
|
||||
advanceTimersBy(30000);
|
||||
|
||||
expect(poll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Can continue previous session from another app', async () => {
|
||||
|
@ -171,6 +230,7 @@ describe('Session service', () => {
|
|||
sessionService.continue(sessionId!);
|
||||
|
||||
expect(sessionService.getSessionId()).toBe(sessionId);
|
||||
expect((await sessionService.sessionMeta$.pipe(first()).toPromise())!.isContinued).toBe(true);
|
||||
});
|
||||
|
||||
it('Calling clear() more than once still allows previous session from another app to continue', async () => {
|
||||
|
@ -213,7 +273,7 @@ describe('Session service', () => {
|
|||
it('Continue drops client side loading state', async () => {
|
||||
const sessionId = sessionService.start();
|
||||
|
||||
sessionService.trackSearch({ abort: () => {} });
|
||||
sessionService.trackSearch({ abort: () => {}, poll: async () => {} });
|
||||
expect(state$.getValue()).toBe(SearchSessionState.Loading);
|
||||
|
||||
sessionService.clear(); // even allow to call clear multiple times
|
||||
|
@ -389,4 +449,96 @@ describe('Session service', () => {
|
|||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('disableSaveAfterSearchesExpire$', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('disables save after session completes on timeout', async () => {
|
||||
const emitResult: boolean[] = [];
|
||||
sessionService.disableSaveAfterSearchesExpire$.subscribe((result) => {
|
||||
emitResult.push(result);
|
||||
});
|
||||
|
||||
sessionService.start();
|
||||
const complete = sessionService.trackSearch({
|
||||
abort: () => {},
|
||||
poll: async () => {},
|
||||
}).complete;
|
||||
|
||||
complete();
|
||||
|
||||
expect(emitResult).toEqual([false]);
|
||||
|
||||
jest.advanceTimersByTime(2 * 60 * 1000); // 2 minutes
|
||||
|
||||
expect(emitResult).toEqual([false]);
|
||||
|
||||
jest.advanceTimersByTime(3 * 60 * 1000); // 3 minutes
|
||||
|
||||
expect(emitResult).toEqual([false, true]);
|
||||
|
||||
sessionService.start();
|
||||
|
||||
expect(emitResult).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
test('disables save for continued from different app sessions', async () => {
|
||||
const emitResult: boolean[] = [];
|
||||
sessionService.disableSaveAfterSearchesExpire$.subscribe((result) => {
|
||||
emitResult.push(result);
|
||||
});
|
||||
|
||||
const sessionId = sessionService.start();
|
||||
|
||||
const complete = sessionService.trackSearch({
|
||||
abort: () => {},
|
||||
poll: async () => {},
|
||||
}).complete;
|
||||
|
||||
complete();
|
||||
|
||||
expect(emitResult).toEqual([false]);
|
||||
|
||||
sessionService.clear();
|
||||
|
||||
sessionService.continue(sessionId);
|
||||
|
||||
expect(emitResult).toEqual([false, true]);
|
||||
|
||||
sessionService.start();
|
||||
|
||||
expect(emitResult).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
test('emits usage once', async () => {
|
||||
const emitResult: boolean[] = [];
|
||||
sessionService.disableSaveAfterSearchesExpire$.subscribe((result) => {
|
||||
emitResult.push(result);
|
||||
});
|
||||
sessionService.disableSaveAfterSearchesExpire$.subscribe(); // testing that source is shared
|
||||
|
||||
sessionService.start();
|
||||
const complete = sessionService.trackSearch({
|
||||
abort: () => {},
|
||||
poll: async () => {},
|
||||
}).complete;
|
||||
|
||||
expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(0);
|
||||
|
||||
complete();
|
||||
|
||||
jest.advanceTimersByTime(5 * 60 * 1000); // 5 minutes
|
||||
|
||||
expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(1);
|
||||
|
||||
sessionService.start();
|
||||
|
||||
expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,32 +7,116 @@
|
|||
*/
|
||||
|
||||
import { PublicContract, SerializableRecord } from '@kbn/utility-types';
|
||||
import { distinctUntilChanged, map, startWith } from 'rxjs/operators';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
mapTo,
|
||||
mergeMap,
|
||||
repeat,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs/operators';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
EMPTY,
|
||||
from,
|
||||
merge,
|
||||
Observable,
|
||||
of,
|
||||
Subscription,
|
||||
timer,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
PluginInitializerContext,
|
||||
StartServicesAccessor,
|
||||
ToastsStart as ToastService,
|
||||
} from '@kbn/core/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import moment from 'moment';
|
||||
import { SearchUsageCollector } from '../..';
|
||||
import { ConfigSchema } from '../../../config';
|
||||
import { createSessionStateContainer } from './search_session_state';
|
||||
import type {
|
||||
SearchSessionState,
|
||||
SessionMeta,
|
||||
SessionStateContainer,
|
||||
SessionStateInternal,
|
||||
} from './search_session_state';
|
||||
import {
|
||||
createSessionStateContainer,
|
||||
SearchSessionState,
|
||||
TrackedSearchState,
|
||||
} from './search_session_state';
|
||||
import { ISessionsClient } from './sessions_client';
|
||||
import { ISearchOptions } from '../../../common';
|
||||
import { NowProviderInternalContract } from '../../now_provider';
|
||||
import { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants';
|
||||
import { formatSessionName } from './lib/session_name_formatter';
|
||||
|
||||
/**
|
||||
* Polling interval for keeping completed searches alive
|
||||
* until the user saves the session
|
||||
*/
|
||||
const KEEP_ALIVE_COMPLETED_SEARCHES_INTERVAL = 30000;
|
||||
|
||||
export type ISessionService = PublicContract<SessionService>;
|
||||
|
||||
interface TrackSearchDescriptor {
|
||||
/**
|
||||
* Cancel the search
|
||||
*/
|
||||
abort: () => void;
|
||||
|
||||
/**
|
||||
* Keep polling the search to keep it alive
|
||||
*/
|
||||
poll: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Notify search that session is being saved, could be used to restart the search with different params
|
||||
* @deprecated - this is used as an escape hatch for TSVB/Timelion to restart a search with different params
|
||||
*/
|
||||
onSavingSession?: (
|
||||
options: Required<Pick<ISearchOptions, 'sessionId' | 'isRestore' | 'isStored'>>
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
interface TrackSearchMeta {
|
||||
/**
|
||||
* Time that indicates when last time this search was polled
|
||||
*/
|
||||
lastPollingTime: Date;
|
||||
|
||||
/**
|
||||
* If the keep_alive of this search was extended up to saved session keep_alive
|
||||
*/
|
||||
isStored: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Api to manage tracked search
|
||||
*/
|
||||
interface TrackSearchHandler {
|
||||
/**
|
||||
* Transition search into "complete" status
|
||||
*/
|
||||
complete(): void;
|
||||
|
||||
/**
|
||||
* Transition search into "error" status
|
||||
*/
|
||||
error(): void;
|
||||
|
||||
/**
|
||||
* Call to notify when search is about to be polled to get current search state to build `searchOptions` from (mainly isSearchStored),
|
||||
* When poll completes or errors, call `afterPoll` callback and confirm is search was successfully stored
|
||||
*/
|
||||
beforePoll(): [
|
||||
currentSearchState: { isSearchStored: boolean },
|
||||
afterPoll: (newSearchState: { isSearchStored: boolean }) => void
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -82,9 +166,26 @@ interface SearchSessionIndicatorUiConfig {
|
|||
*/
|
||||
export class SessionService {
|
||||
public readonly state$: Observable<SearchSessionState>;
|
||||
private readonly state: SessionStateContainer<TrackSearchDescriptor>;
|
||||
private readonly state: SessionStateContainer<TrackSearchDescriptor, TrackSearchMeta>;
|
||||
|
||||
public readonly sessionMeta$: Observable<SessionMeta>;
|
||||
|
||||
/**
|
||||
* Emits `true` when session completes and `config.search.sessions.notTouchedTimeout` duration has passed.
|
||||
* Used to stop keeping searches alive after some times and disabled "save session" button
|
||||
*
|
||||
* or when failed to extend searches after session completes
|
||||
*/
|
||||
private readonly _disableSaveAfterSearchesExpire$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
/**
|
||||
* Emits `true` when it is no longer possible to save a session:
|
||||
* - Failed to keep searches alive after they completed
|
||||
* - `config.search.sessions.notTouchedTimeout` after searches completed hit
|
||||
* - Continued session from a different app and lost information about previous searches (https://github.com/elastic/kibana/issues/121543)
|
||||
*/
|
||||
public readonly disableSaveAfterSearchesExpire$: Observable<boolean>;
|
||||
|
||||
private searchSessionInfoProvider?: SearchSessionInfoProvider;
|
||||
private searchSessionIndicatorUiConfig?: Partial<SearchSessionIndicatorUiConfig>;
|
||||
private subscription = new Subscription();
|
||||
|
@ -105,16 +206,50 @@ export class SessionService {
|
|||
getStartServices: StartServicesAccessor,
|
||||
private readonly sessionsClient: ISessionsClient,
|
||||
private readonly nowProvider: NowProviderInternalContract,
|
||||
private readonly usageCollector?: SearchUsageCollector,
|
||||
{ freezeState = true }: { freezeState: boolean } = { freezeState: true }
|
||||
) {
|
||||
const { stateContainer, sessionState$, sessionMeta$ } =
|
||||
createSessionStateContainer<TrackSearchDescriptor>({
|
||||
freeze: freezeState,
|
||||
});
|
||||
const { stateContainer, sessionState$, sessionMeta$ } = createSessionStateContainer<
|
||||
TrackSearchDescriptor,
|
||||
TrackSearchMeta
|
||||
>({
|
||||
freeze: freezeState,
|
||||
});
|
||||
this.state$ = sessionState$;
|
||||
this.state = stateContainer;
|
||||
this.sessionMeta$ = sessionMeta$;
|
||||
|
||||
this.disableSaveAfterSearchesExpire$ = combineLatest([
|
||||
this._disableSaveAfterSearchesExpire$,
|
||||
this.sessionMeta$.pipe(map((meta) => meta.isContinued)),
|
||||
]).pipe(
|
||||
map(
|
||||
([_disableSaveAfterSearchesExpire, isSessionContinued]) =>
|
||||
_disableSaveAfterSearchesExpire || isSessionContinued
|
||||
),
|
||||
distinctUntilChanged()
|
||||
);
|
||||
|
||||
const notTouchedTimeout = moment
|
||||
.duration(initializerContext.config.get().search.sessions.notTouchedTimeout)
|
||||
.asMilliseconds();
|
||||
|
||||
this.subscription.add(
|
||||
this.state$
|
||||
.pipe(
|
||||
switchMap((_state) =>
|
||||
_state === SearchSessionState.Completed
|
||||
? merge(of(false), timer(notTouchedTimeout).pipe(mapTo(true)))
|
||||
: of(false)
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
tap((value) => {
|
||||
if (value) this.usageCollector?.trackSessionIndicatorSaveDisabled();
|
||||
})
|
||||
)
|
||||
.subscribe(this._disableSaveAfterSearchesExpire$)
|
||||
);
|
||||
|
||||
this.subscription.add(
|
||||
sessionMeta$
|
||||
.pipe(
|
||||
|
@ -155,6 +290,54 @@ export class SessionService {
|
|||
})
|
||||
);
|
||||
});
|
||||
|
||||
// keep completed searches alive until user explicitly saves the session
|
||||
this.subscription.add(
|
||||
this.getSession$()
|
||||
.pipe(
|
||||
switchMap((sessionId) => {
|
||||
if (!sessionId) return EMPTY;
|
||||
if (this.isStored()) return EMPTY; // no need to keep searches alive because session and searches are already stored
|
||||
if (!this.hasAccess()) return EMPTY; // don't need to keep searches alive if the user can't save session
|
||||
if (!this.isSessionStorageReady()) return EMPTY; // don't need to keep searches alive if app doesn't allow saving session
|
||||
|
||||
const schedulePollSearches = () => {
|
||||
return timer(KEEP_ALIVE_COMPLETED_SEARCHES_INTERVAL).pipe(
|
||||
mergeMap(() => {
|
||||
const searchesToKeepAlive = this.state.get().trackedSearches.filter(
|
||||
(s) =>
|
||||
!s.searchMeta.isStored &&
|
||||
s.state === TrackedSearchState.Completed &&
|
||||
s.searchMeta.lastPollingTime.getTime() < Date.now() - 5000 // don't poll if was very recently polled
|
||||
);
|
||||
|
||||
return from(
|
||||
Promise.all(
|
||||
searchesToKeepAlive.map((s) =>
|
||||
s.searchDescriptor.poll().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Error while polling search to keep it alive. Considering that it is no longer possible to extend a session.`,
|
||||
e
|
||||
);
|
||||
if (this.isCurrentSession(sessionId)) {
|
||||
this._disableSaveAfterSearchesExpire$.next(true);
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}),
|
||||
repeat(),
|
||||
takeUntil(this.disableSaveAfterSearchesExpire$.pipe(filter((disable) => disable)))
|
||||
);
|
||||
};
|
||||
|
||||
return schedulePollSearches();
|
||||
})
|
||||
)
|
||||
.subscribe(() => {})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -167,15 +350,51 @@ export class SessionService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Used to track pending searches within current session
|
||||
* Used to track searches within current session
|
||||
*
|
||||
* @param searchDescriptor - uniq object that will be used to untrack the search
|
||||
* @returns untrack function
|
||||
* @param searchDescriptor - uniq object that will be used to as search identifier
|
||||
* @returns {@link TrackSearchHandler}
|
||||
*/
|
||||
public trackSearch(searchDescriptor: TrackSearchDescriptor): () => void {
|
||||
this.state.transitions.trackSearch(searchDescriptor);
|
||||
return () => {
|
||||
this.state.transitions.unTrackSearch(searchDescriptor);
|
||||
public trackSearch(searchDescriptor: TrackSearchDescriptor): TrackSearchHandler {
|
||||
this.state.transitions.trackSearch(searchDescriptor, {
|
||||
lastPollingTime: new Date(),
|
||||
isStored: false,
|
||||
});
|
||||
|
||||
return {
|
||||
complete: () => {
|
||||
this.state.transitions.completeSearch(searchDescriptor);
|
||||
|
||||
// when search completes and session has just been saved,
|
||||
// trigger polling once again to save search into a session and extend its keep_alive
|
||||
if (this.isStored()) {
|
||||
const search = this.state.selectors.getSearch(searchDescriptor);
|
||||
if (search && !search.searchMeta.isStored) {
|
||||
search.searchDescriptor.poll().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Failed to extend search after it was completed`, e);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.state.transitions.errorSearch(searchDescriptor);
|
||||
},
|
||||
beforePoll: () => {
|
||||
const search = this.state.selectors.getSearch(searchDescriptor);
|
||||
this.state.transitions.updateSearchMeta(searchDescriptor, {
|
||||
lastPollingTime: new Date(),
|
||||
});
|
||||
|
||||
return [
|
||||
{ isSearchStored: search?.searchMeta?.isStored ?? false },
|
||||
({ isSearchStored }) => {
|
||||
this.state.transitions.updateSearchMeta(searchDescriptor, {
|
||||
isStored: isSearchStored,
|
||||
});
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -244,6 +463,12 @@ export class SessionService {
|
|||
*
|
||||
* This is different from {@link restore} as it reuses search session state and search results held in client memory instead of restoring search results from elasticsearch
|
||||
* @param sessionId
|
||||
*
|
||||
* TODO: remove this functionality in favor of separate architecture for client side search cache
|
||||
* that won't interfere with saving search sessions
|
||||
* https://github.com/elastic/kibana/issues/121543
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
public continue(sessionId: string) {
|
||||
if (this.lastSessionSnapshot?.sessionId === sessionId) {
|
||||
|
@ -254,7 +479,8 @@ export class SessionService {
|
|||
// also have to drop all pending searches which are used to derive client side state of search session indicator,
|
||||
// if we weren't dropping this searches, then we would get into "infinite loading" state when continuing a session that was cleared with pending searches
|
||||
// possible solution to this problem is to refactor session service to support multiple sessions
|
||||
pendingSearches: [],
|
||||
trackedSearches: [],
|
||||
isContinued: true,
|
||||
});
|
||||
this.lastSessionSnapshot = undefined;
|
||||
} else {
|
||||
|
@ -293,10 +519,13 @@ export class SessionService {
|
|||
* Request a cancellation of on-going search requests within current session
|
||||
*/
|
||||
public async cancel(): Promise<void> {
|
||||
const isStoredSession = this.state.get().isStored;
|
||||
this.state.get().pendingSearches.forEach((s) => {
|
||||
s.abort();
|
||||
});
|
||||
const isStoredSession = this.isStored();
|
||||
this.state
|
||||
.get()
|
||||
.trackedSearches.filter((s) => s.state === TrackedSearchState.InProgress)
|
||||
.forEach((s) => {
|
||||
s.searchDescriptor.abort();
|
||||
});
|
||||
this.state.transitions.cancel();
|
||||
if (isStoredSession) {
|
||||
await this.sessionsClient.delete(this.state.get().sessionId!);
|
||||
|
@ -335,8 +564,41 @@ export class SessionService {
|
|||
});
|
||||
|
||||
// if we are still interested in this result
|
||||
if (this.getSessionId() === sessionId) {
|
||||
if (this.isCurrentSession(sessionId)) {
|
||||
this.state.transitions.store(searchSessionSavedObject);
|
||||
|
||||
// trigger new poll for all completed searches that are not stored to propogate them into newly creates search session saved object and extend their keepAlive
|
||||
const completedSearches = this.state
|
||||
.get()
|
||||
.trackedSearches.filter(
|
||||
(s) => s.state === TrackedSearchState.Completed && !s.searchMeta.isStored
|
||||
);
|
||||
const pollCompletedSearchesPromise = Promise.all(
|
||||
completedSearches.map((s) =>
|
||||
s.searchDescriptor.poll().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Failed to extend search after session was saved', e);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// notify all the searches with onSavingSession that session has been saved and saved object has been created
|
||||
// don't wait for the result
|
||||
const searchesWithSavingHandler = this.state
|
||||
.get()
|
||||
.trackedSearches.filter((s) => s.searchDescriptor.onSavingSession);
|
||||
searchesWithSavingHandler.forEach((s) =>
|
||||
s.searchDescriptor.onSavingSession!({
|
||||
sessionId,
|
||||
isRestore: this.isRestore(),
|
||||
isStored: this.isStored(),
|
||||
}).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Failed to execute "onSavingSession" handler after session was saved', e);
|
||||
})
|
||||
);
|
||||
|
||||
await pollCompletedSearchesPromise;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,10 @@ import type {
|
|||
SavedObjectsUpdateResponse,
|
||||
SavedObjectsFindOptions,
|
||||
} from '@kbn/core/server';
|
||||
import type { SearchSessionSavedObjectAttributes } from '../../../common';
|
||||
import type {
|
||||
SearchSessionSavedObjectAttributes,
|
||||
SearchSessionsFindResponse,
|
||||
} from '../../../common';
|
||||
export type SearchSessionSavedObject = SavedObject<SearchSessionSavedObjectAttributes>;
|
||||
export type ISessionsClient = PublicContract<SessionsClient>;
|
||||
export interface SessionsClientDeps {
|
||||
|
@ -62,7 +65,7 @@ export class SessionsClient {
|
|||
});
|
||||
}
|
||||
|
||||
public find(options: Omit<SavedObjectsFindOptions, 'type'>): Promise<SavedObjectsFindResponse> {
|
||||
public find(options: Omit<SavedObjectsFindOptions, 'type'>): Promise<SearchSessionsFindResponse> {
|
||||
return this.http!.post(`/internal/session/_find`, {
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { FieldValueOptionType, SearchFilterConfig } from '@elastic/eui';
|
||||
import { SearchFilterConfig } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { capitalize } from 'lodash';
|
||||
import { UISession } from '../../types';
|
||||
|
@ -18,12 +18,7 @@ export const getAppFilter: (tableData: UISession[]) => SearchFilterConfig = (tab
|
|||
}),
|
||||
field: 'appId',
|
||||
multiSelect: 'or',
|
||||
options: tableData.reduce((options: FieldValueOptionType[], { appId }) => {
|
||||
const existingOption = options.find((o) => o.value === appId);
|
||||
if (!existingOption) {
|
||||
return [...options, { value: appId, view: capitalize(appId) }];
|
||||
}
|
||||
|
||||
return options;
|
||||
}, []),
|
||||
options: [...new Set(tableData.map((data) => data.appId ?? 'unknown'))]
|
||||
.sort()
|
||||
.map((appId) => ({ value: appId, view: capitalize(appId) })),
|
||||
});
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { FieldValueOptionType, SearchFilterConfig } from '@elastic/eui';
|
||||
import { SearchFilterConfig } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { TableText } from '..';
|
||||
|
@ -20,14 +20,7 @@ export const getStatusFilter: (tableData: UISession[]) => SearchFilterConfig = (
|
|||
}),
|
||||
field: 'status',
|
||||
multiSelect: 'or',
|
||||
options: tableData.reduce((options: FieldValueOptionType[], session) => {
|
||||
const { status: statusType } = session;
|
||||
const existingOption = options.find((o) => o.value === statusType);
|
||||
if (!existingOption) {
|
||||
const view = <TableText>{getStatusText(session.status)}</TableText>;
|
||||
return [...options, { value: statusType, view }];
|
||||
}
|
||||
|
||||
return options;
|
||||
}, []),
|
||||
options: [...new Set(tableData.map((data) => data.status ?? 'unknown'))]
|
||||
.sort()
|
||||
.map((status) => ({ value: status, view: <TableText>{getStatusText(status)}</TableText> })),
|
||||
});
|
||||
|
|
|
@ -68,13 +68,15 @@ describe('Background Search Session Management Table', () => {
|
|||
id: 'wtywp9u2802hahgp-flps',
|
||||
url: '/app/great-app-url/#48',
|
||||
appId: 'canvas',
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
created: '2020-12-02T00:19:32Z',
|
||||
expires: '2020-12-07T00:19:32Z',
|
||||
idMapping: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
statuses: {
|
||||
'wtywp9u2802hahgp-flps': { status: SearchSessionStatus.EXPIRED },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@ import { CoreStart } from '@kbn/core/public';
|
|||
import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import useInterval from 'react-use/lib/useInterval';
|
||||
import { TableText } from '..';
|
||||
import { SEARCH_SESSIONS_TABLE_ID } from '../../../../../../common';
|
||||
import { SearchSessionsMgmtAPI } from '../../lib/api';
|
||||
|
@ -47,6 +46,7 @@ export function SearchSessionsMgmtTable({
|
|||
const [debouncedIsLoading, setDebouncedIsLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({ pageIndex: 0 });
|
||||
const showLatestResultsHandler = useRef<Function>();
|
||||
const refreshTimeoutRef = useRef<number | null>(null);
|
||||
const refreshInterval = useMemo(
|
||||
() => moment.duration(config.management.refreshInterval).asMilliseconds(),
|
||||
[config.management.refreshInterval]
|
||||
|
@ -63,30 +63,44 @@ export function SearchSessionsMgmtTable({
|
|||
|
||||
// refresh behavior
|
||||
const doRefresh = useCallback(async () => {
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
refreshTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const renderResults = (results: UISession[]) => {
|
||||
setTableData(results);
|
||||
};
|
||||
showLatestResultsHandler.current = renderResults;
|
||||
let results: UISession[] = [];
|
||||
try {
|
||||
results = await api.fetchTableData();
|
||||
} catch (e) {} // eslint-disable-line no-empty
|
||||
|
||||
if (showLatestResultsHandler.current === renderResults) {
|
||||
renderResults(results);
|
||||
setIsLoading(false);
|
||||
if (document.visibilityState !== 'hidden') {
|
||||
let results: UISession[] = [];
|
||||
try {
|
||||
results = await api.fetchTableData();
|
||||
} catch (e) {} // eslint-disable-line no-empty
|
||||
|
||||
if (showLatestResultsHandler.current === renderResults) {
|
||||
renderResults(results);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
if (showLatestResultsHandler.current === renderResults && refreshInterval > 0) {
|
||||
if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current);
|
||||
refreshTimeoutRef.current = window.setTimeout(doRefresh, refreshInterval);
|
||||
}
|
||||
}, [api, refreshInterval]);
|
||||
|
||||
// initial data load
|
||||
useEffect(() => {
|
||||
doRefresh();
|
||||
searchUsageCollector.trackSessionsListLoaded();
|
||||
return () => {
|
||||
if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current);
|
||||
};
|
||||
}, [doRefresh, searchUsageCollector]);
|
||||
|
||||
useInterval(doRefresh, refreshInterval);
|
||||
|
||||
const onActionComplete: OnActionComplete = () => {
|
||||
doRefresh();
|
||||
};
|
||||
|
|
|
@ -52,14 +52,16 @@ describe('Search Sessions Management API', () => {
|
|||
attributes: {
|
||||
name: 'Veggie',
|
||||
appId: 'pizza',
|
||||
status: 'complete',
|
||||
initialState: {},
|
||||
restoreState: {},
|
||||
idMapping: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as SavedObjectsFindResponse;
|
||||
statuses: {
|
||||
'hello-pizza-123': { status: 'complete' },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const api = new SearchSessionsMgmtAPI(sessionsClient, mockConfig, {
|
||||
|
@ -93,7 +95,7 @@ describe('Search Sessions Management API', () => {
|
|||
`);
|
||||
});
|
||||
|
||||
test('completed session with expired time is showed as expired', async () => {
|
||||
test('expired session is showed as expired', async () => {
|
||||
sessionsClient.find = jest.fn().mockImplementation(async () => {
|
||||
return {
|
||||
saved_objects: [
|
||||
|
@ -102,7 +104,6 @@ describe('Search Sessions Management API', () => {
|
|||
attributes: {
|
||||
name: 'Veggie',
|
||||
appId: 'pizza',
|
||||
status: 'complete',
|
||||
expires: moment().subtract(3, 'days'),
|
||||
initialState: {},
|
||||
restoreState: {},
|
||||
|
@ -110,7 +111,10 @@ describe('Search Sessions Management API', () => {
|
|||
},
|
||||
},
|
||||
],
|
||||
} as SavedObjectsFindResponse;
|
||||
statuses: {
|
||||
'hello-pizza-123': { status: 'expired' },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const api = new SearchSessionsMgmtAPI(sessionsClient, mockConfig, {
|
||||
|
|
|
@ -21,7 +21,7 @@ import {
|
|||
} from '../types';
|
||||
import { ISessionsClient } from '../../sessions_client';
|
||||
import { SearchUsageCollector } from '../../../collectors';
|
||||
import { SearchSessionStatus } from '../../../../../common';
|
||||
import { SearchSessionsFindResponse, SearchSessionStatus } from '../../../../../common';
|
||||
import { SearchSessionsConfigSchema } from '../../../../../config';
|
||||
|
||||
type LocatorsStart = SharePluginStart['url']['locators'];
|
||||
|
@ -42,25 +42,6 @@ function getActions(status: UISearchSessionState) {
|
|||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status we display on mgtm UI might be different from the one inside the saved object
|
||||
* @param status
|
||||
*/
|
||||
function getUIStatus(session: PersistedSearchSessionSavedObjectAttributes): UISearchSessionState {
|
||||
const isSessionExpired = () => {
|
||||
const curTime = moment();
|
||||
return curTime.diff(moment(session.expires), 'ms') > 0;
|
||||
};
|
||||
|
||||
switch (session.status) {
|
||||
case SearchSessionStatus.COMPLETE:
|
||||
case SearchSessionStatus.IN_PROGRESS:
|
||||
return isSessionExpired() ? SearchSessionStatus.EXPIRED : session.status;
|
||||
}
|
||||
|
||||
return session.status;
|
||||
}
|
||||
|
||||
function getUrlFromState(locators: LocatorsStart, locatorId: string, state: SerializableRecord) {
|
||||
try {
|
||||
const locator = locators.get(locatorId);
|
||||
|
@ -75,7 +56,11 @@ function getUrlFromState(locators: LocatorsStart, locatorId: string, state: Seri
|
|||
|
||||
// Helper: factory for a function to map server objects to UI objects
|
||||
const mapToUISession =
|
||||
(locators: LocatorsStart, config: SearchSessionsConfigSchema) =>
|
||||
(
|
||||
locators: LocatorsStart,
|
||||
config: SearchSessionsConfigSchema,
|
||||
sessionStatuses: SearchSessionsFindResponse['statuses']
|
||||
) =>
|
||||
async (
|
||||
savedObject: SavedObject<PersistedSearchSessionSavedObjectAttributes>
|
||||
): Promise<UISession> => {
|
||||
|
@ -91,7 +76,7 @@ const mapToUISession =
|
|||
version,
|
||||
} = savedObject.attributes;
|
||||
|
||||
const status = getUIStatus(savedObject.attributes);
|
||||
const status = sessionStatuses[savedObject.id]?.status;
|
||||
const actions = getActions(status);
|
||||
|
||||
// TODO: initialState should be saved without the searchSessionID
|
||||
|
@ -141,9 +126,7 @@ export class SearchSessionsMgmtAPI {
|
|||
page: 1,
|
||||
perPage: mgmtConfig.maxSessions,
|
||||
sortField: 'created',
|
||||
sortOrder: 'asc',
|
||||
searchFields: ['persisted'],
|
||||
search: 'true',
|
||||
sortOrder: 'desc',
|
||||
})
|
||||
);
|
||||
const timeout$ = timer(refreshTimeout.asMilliseconds()).pipe(
|
||||
|
@ -165,7 +148,9 @@ export class SearchSessionsMgmtAPI {
|
|||
const savedObjects = result.saved_objects as Array<
|
||||
SavedObject<PersistedSearchSessionSavedObjectAttributes>
|
||||
>;
|
||||
return await Promise.all(savedObjects.map(mapToUISession(this.deps.locators, this.config)));
|
||||
return await Promise.all(
|
||||
savedObjects.map(mapToUISession(this.deps.locators, this.config, result.statuses))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
|
|
@ -64,4 +64,87 @@ describe('Config Deprecations', () => {
|
|||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('reports about old, no longer used configs', () => {
|
||||
const config = {
|
||||
data: {
|
||||
search: {
|
||||
sessions: {
|
||||
enabled: false,
|
||||
pageSize: 1000,
|
||||
trackingInterval: '30s',
|
||||
cleanupInterval: '30s',
|
||||
expireInterval: '30s',
|
||||
monitoringTaskTimeout: '30s',
|
||||
notTouchedInProgressTimeout: '30s',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { messages, migrated } = applyConfigDeprecations(cloneDeep(config));
|
||||
expect(migrated).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"data": Object {
|
||||
"search": Object {
|
||||
"sessions": Object {
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(messages).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"You no longer need to configure \\"data.search.sessions.pageSize\\".",
|
||||
"You no longer need to configure \\"data.search.sessions.trackingInterval\\".",
|
||||
"You no longer need to configure \\"data.search.sessions.cleanupInterval\\".",
|
||||
"You no longer need to configure \\"data.search.sessions.expireInterval\\".",
|
||||
"You no longer need to configure \\"data.search.sessions.monitoringTaskTimeout\\".",
|
||||
"You no longer need to configure \\"data.search.sessions.notTouchedInProgressTimeout\\".",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('reports about old, no longer used configs from xpack.data_enhanced', () => {
|
||||
const config = {
|
||||
xpack: {
|
||||
data_enhanced: {
|
||||
search: {
|
||||
sessions: {
|
||||
enabled: false,
|
||||
pageSize: 1000,
|
||||
trackingInterval: '30s',
|
||||
cleanupInterval: '30s',
|
||||
expireInterval: '30s',
|
||||
monitoringTaskTimeout: '30s',
|
||||
notTouchedInProgressTimeout: '30s',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { messages, migrated } = applyConfigDeprecations(cloneDeep(config));
|
||||
expect(migrated).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"data": Object {
|
||||
"search": Object {
|
||||
"sessions": Object {
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(messages).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"Setting \\"xpack.data_enhanced.search.sessions\\" has been replaced by \\"data.search.sessions\\"",
|
||||
"You no longer need to configure \\"data.search.sessions.pageSize\\".",
|
||||
"You no longer need to configure \\"data.search.sessions.trackingInterval\\".",
|
||||
"You no longer need to configure \\"data.search.sessions.cleanupInterval\\".",
|
||||
"You no longer need to configure \\"data.search.sessions.expireInterval\\".",
|
||||
"You no longer need to configure \\"data.search.sessions.monitoringTaskTimeout\\".",
|
||||
"You no longer need to configure \\"data.search.sessions.notTouchedInProgressTimeout\\".",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,8 +8,17 @@
|
|||
|
||||
import type { ConfigDeprecationProvider } from '@kbn/core/server';
|
||||
|
||||
export const configDeprecationProvider: ConfigDeprecationProvider = ({ renameFromRoot }) => [
|
||||
export const configDeprecationProvider: ConfigDeprecationProvider = ({
|
||||
renameFromRoot,
|
||||
unusedFromRoot,
|
||||
}) => [
|
||||
renameFromRoot('xpack.data_enhanced.search.sessions', 'data.search.sessions', {
|
||||
level: 'warning',
|
||||
}),
|
||||
unusedFromRoot('data.search.sessions.pageSize', { level: 'warning' }),
|
||||
unusedFromRoot('data.search.sessions.trackingInterval', { level: 'warning' }),
|
||||
unusedFromRoot('data.search.sessions.cleanupInterval', { level: 'warning' }),
|
||||
unusedFromRoot('data.search.sessions.expireInterval', { level: 'warning' }),
|
||||
unusedFromRoot('data.search.sessions.monitoringTaskTimeout', { level: 'warning' }),
|
||||
unusedFromRoot('data.search.sessions.notTouchedInProgressTimeout', { level: 'warning' }),
|
||||
];
|
||||
|
|
|
@ -42,5 +42,6 @@ export function createSearchRequestHandlerContext() {
|
|||
extendSession: jest.fn(),
|
||||
cancelSession: jest.fn(),
|
||||
deleteSession: jest.fn(),
|
||||
getSessionStatus: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -65,6 +65,21 @@ describe('registerSessionRoutes', () => {
|
|||
expect(mockContext.search!.getSession).toHaveBeenCalledWith(id);
|
||||
});
|
||||
|
||||
it('status calls getSessionStatus with sessionId', async () => {
|
||||
const id = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
|
||||
const params = { id };
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest({ params });
|
||||
const mockResponse = httpServerMock.createResponseFactory();
|
||||
|
||||
const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value;
|
||||
const [[], [, statusHandler]] = mockRouter.get.mock.calls;
|
||||
|
||||
await statusHandler(mockContext, mockRequest, mockResponse);
|
||||
|
||||
expect(mockContext.search!.getSessionStatus).toHaveBeenCalledWith(id);
|
||||
});
|
||||
|
||||
it('find calls findSession with options', async () => {
|
||||
const page = 1;
|
||||
const perPage = 5;
|
||||
|
|
|
@ -86,6 +86,35 @@ export function registerSessionRoutes(router: DataPluginRouter, logger: Logger):
|
|||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
{
|
||||
path: '/internal/session/{id}/status',
|
||||
validate: {
|
||||
params: schema.object({
|
||||
id: schema.string(),
|
||||
}),
|
||||
},
|
||||
options: {
|
||||
tags: [STORE_SEARCH_SESSIONS_ROLE_TAG],
|
||||
},
|
||||
},
|
||||
async (context, request, res) => {
|
||||
const { id } = request.params;
|
||||
try {
|
||||
const searchContext = await context.search;
|
||||
const response = await searchContext!.getSessionStatus(id);
|
||||
|
||||
return res.ok({
|
||||
body: response,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e.output?.payload || e;
|
||||
logger.error(err);
|
||||
return reportServerError(res, err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
{
|
||||
path: '/internal/session/_find',
|
||||
|
|
|
@ -16,9 +16,6 @@ export const searchSessionSavedObjectType: SavedObjectsType = {
|
|||
hidden: true,
|
||||
mappings: {
|
||||
properties: {
|
||||
persisted: {
|
||||
type: 'boolean',
|
||||
},
|
||||
sessionId: {
|
||||
type: 'keyword',
|
||||
},
|
||||
|
@ -31,15 +28,6 @@ export const searchSessionSavedObjectType: SavedObjectsType = {
|
|||
expires: {
|
||||
type: 'date',
|
||||
},
|
||||
touched: {
|
||||
type: 'date',
|
||||
},
|
||||
completed: {
|
||||
type: 'date',
|
||||
},
|
||||
status: {
|
||||
type: 'keyword',
|
||||
},
|
||||
appId: {
|
||||
type: 'keyword',
|
||||
},
|
||||
|
@ -70,6 +58,9 @@ export const searchSessionSavedObjectType: SavedObjectsType = {
|
|||
version: {
|
||||
type: 'keyword',
|
||||
},
|
||||
isCanceled: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
migrations: searchSessionSavedObjectMigrations,
|
||||
|
|
|
@ -11,9 +11,10 @@ import {
|
|||
SearchSessionSavedObjectAttributesPre$7$13$0,
|
||||
SearchSessionSavedObjectAttributesPre$7$14$0,
|
||||
SearchSessionSavedObjectAttributesPre$8$0$0,
|
||||
SearchSessionSavedObjectAttributesPre$8$6$0,
|
||||
} from './search_session_migration';
|
||||
import { SavedObject } from '@kbn/core/types';
|
||||
import { SEARCH_SESSION_TYPE, SearchSessionStatus } from '../../../common';
|
||||
import { SEARCH_SESSION_TYPE, SearchSessionStatus, SearchStatus } from '../../../common';
|
||||
import { SavedObjectMigrationContext } from '@kbn/core/server';
|
||||
|
||||
describe('7.12.0 -> 7.13.0', () => {
|
||||
|
@ -356,3 +357,98 @@ describe('7.14.0 -> 8.0.0', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('8.0.0 -> 8.6.0', () => {
|
||||
const migration = searchSessionSavedObjectMigrations['8.6.0'];
|
||||
|
||||
const mockSessionSavedObject: SavedObject<SearchSessionSavedObjectAttributesPre$8$6$0> = {
|
||||
id: 'id',
|
||||
type: SEARCH_SESSION_TYPE,
|
||||
attributes: {
|
||||
appId: 'my_app_id',
|
||||
completed: '2021-03-29T00:00:00.000Z',
|
||||
created: '2021-03-26T00:00:00.000Z',
|
||||
expires: '2021-03-30T00:00:00.000Z',
|
||||
idMapping: {
|
||||
search1: { id: 'id1', strategy: 'ese', status: SearchStatus.COMPLETE },
|
||||
search2: {
|
||||
id: 'id2',
|
||||
strategy: 'sql',
|
||||
status: SearchStatus.ERROR,
|
||||
error: 'error',
|
||||
},
|
||||
search3: { id: 'id3', strategy: 'es', status: SearchStatus.COMPLETE },
|
||||
},
|
||||
initialState: {},
|
||||
locatorId: undefined,
|
||||
name: 'my_name',
|
||||
persisted: true,
|
||||
realmName: 'realmName',
|
||||
realmType: 'realmType',
|
||||
restoreState: {},
|
||||
sessionId: 'sessionId',
|
||||
status: SearchSessionStatus.COMPLETE,
|
||||
touched: '2021-03-29T00:00:00.000Z',
|
||||
username: 'username',
|
||||
version: '7.14.0',
|
||||
},
|
||||
references: [],
|
||||
};
|
||||
|
||||
test('migrates object', () => {
|
||||
const migratedSession = migration(mockSessionSavedObject, {} as SavedObjectMigrationContext);
|
||||
|
||||
expect(migratedSession.attributes).not.toHaveProperty('status');
|
||||
expect(migratedSession.attributes).not.toHaveProperty('touched');
|
||||
expect(migratedSession.attributes).not.toHaveProperty('completed');
|
||||
expect(migratedSession.attributes).not.toHaveProperty('persisted');
|
||||
expect(migratedSession.attributes.idMapping.search1).not.toHaveProperty('status');
|
||||
expect(migratedSession.attributes.idMapping.search2).not.toHaveProperty('error');
|
||||
|
||||
expect(migratedSession.attributes).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"appId": "my_app_id",
|
||||
"created": "2021-03-26T00:00:00.000Z",
|
||||
"expires": "2021-03-30T00:00:00.000Z",
|
||||
"idMapping": Object {
|
||||
"search1": Object {
|
||||
"id": "id1",
|
||||
"strategy": "ese",
|
||||
},
|
||||
"search2": Object {
|
||||
"id": "id2",
|
||||
"strategy": "sql",
|
||||
},
|
||||
"search3": Object {
|
||||
"id": "id3",
|
||||
"strategy": "es",
|
||||
},
|
||||
},
|
||||
"initialState": Object {},
|
||||
"locatorId": undefined,
|
||||
"name": "my_name",
|
||||
"realmName": "realmName",
|
||||
"realmType": "realmType",
|
||||
"restoreState": Object {},
|
||||
"sessionId": "sessionId",
|
||||
"username": "username",
|
||||
"version": "7.14.0",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('status:canceled -> isCanceled', () => {
|
||||
const migratedSession = migration(
|
||||
{
|
||||
...mockSessionSavedObject,
|
||||
attributes: {
|
||||
...mockSessionSavedObject.attributes,
|
||||
status: SearchSessionStatus.CANCELLED,
|
||||
},
|
||||
},
|
||||
{} as SavedObjectMigrationContext
|
||||
);
|
||||
|
||||
expect(migratedSession.attributes.isCanceled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -39,12 +39,39 @@ export type SearchSessionSavedObjectAttributesPre$7$14$0 = Omit<
|
|||
* from using `urlGeneratorId` to `locatorId`.
|
||||
*/
|
||||
export type SearchSessionSavedObjectAttributesPre$8$0$0 = Omit<
|
||||
SearchSessionSavedObjectAttributesLatest,
|
||||
SearchSessionSavedObjectAttributesPre$8$6$0,
|
||||
'locatorId'
|
||||
> & {
|
||||
urlGeneratorId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* In 8.6.0 with search session refactoring and moving away from using task manager we are no longer track of:
|
||||
* - `completed` - when session was completed
|
||||
* - `persisted` - if session was saved
|
||||
* - `touched` - when session was last updated (touched by the user)
|
||||
* - `status` - status is no longer persisted. Except 'canceled' which was moved to `isCanceled`
|
||||
* - `status` and `error` in idMapping (search info)
|
||||
*/
|
||||
export type SearchSessionSavedObjectAttributesPre$8$6$0 = Omit<
|
||||
SearchSessionSavedObjectAttributesLatest,
|
||||
'idMapping' | 'isCanceled'
|
||||
> & {
|
||||
completed?: string | null;
|
||||
persisted: boolean;
|
||||
touched: string;
|
||||
status: SearchSessionStatus;
|
||||
idMapping: Record<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
strategy: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
function getLocatorId(urlGeneratorId?: string) {
|
||||
if (!urlGeneratorId) return;
|
||||
if (urlGeneratorId === 'DISCOVER_APP_URL_GENERATOR') return 'DISCOVER_APP_LOCATOR';
|
||||
|
@ -89,4 +116,27 @@ export const searchSessionSavedObjectMigrations: SavedObjectMigrationMap = {
|
|||
const attributes = { ...otherAttrs, locatorId };
|
||||
return { ...doc, attributes };
|
||||
},
|
||||
'8.6.0': (
|
||||
doc: SavedObjectUnsanitizedDoc<SearchSessionSavedObjectAttributesPre$8$6$0>
|
||||
): SavedObjectUnsanitizedDoc<SearchSessionSavedObjectAttributesLatest> => {
|
||||
const {
|
||||
attributes: { touched, completed, persisted, idMapping, status, ...otherAttrs },
|
||||
} = doc;
|
||||
|
||||
const attributes: SearchSessionSavedObjectAttributesLatest = {
|
||||
...otherAttrs,
|
||||
idMapping: Object.entries(idMapping).reduce<
|
||||
SearchSessionSavedObjectAttributesLatest['idMapping']
|
||||
>((res, [searchHash, { status: searchStatus, error, ...otherSearchAttrs }]) => {
|
||||
res[searchHash] = otherSearchAttrs;
|
||||
return res;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
if (status === SearchSessionStatus.CANCELLED) {
|
||||
attributes.isCanceled = true;
|
||||
}
|
||||
|
||||
return { ...doc, attributes };
|
||||
},
|
||||
};
|
||||
|
|
|
@ -33,6 +33,8 @@ import { ENHANCED_ES_SEARCH_STRATEGY } from '../../common';
|
|||
let mockSessionClient: jest.Mocked<IScopedSearchSessionsClient>;
|
||||
jest.mock('./session', () => {
|
||||
class SearchSessionService {
|
||||
setup() {}
|
||||
start() {}
|
||||
asScopedProvider = () => (request: any) => mockSessionClient;
|
||||
}
|
||||
return {
|
||||
|
@ -193,7 +195,7 @@ describe('Search service', () => {
|
|||
|
||||
it('does not fail if `trackId` throws', async () => {
|
||||
const searchRequest = { params: {} };
|
||||
const options = { sessionId, isStored: false, isRestore: false };
|
||||
const options = { sessionId, isStored: true, isRestore: false };
|
||||
mockSessionClient.trackId = jest.fn().mockRejectedValue(undefined);
|
||||
|
||||
mockStrategy.search.mockReturnValue(
|
||||
|
@ -203,14 +205,32 @@ describe('Search service', () => {
|
|||
})
|
||||
);
|
||||
|
||||
const result = await mockScopedClient.search(searchRequest, options).toPromise();
|
||||
|
||||
expect(mockSessionClient.trackId).toBeCalledTimes(1);
|
||||
expect(result?.isStored).toBeUndefined();
|
||||
});
|
||||
|
||||
it("doesn't call trackId if session is not stored", async () => {
|
||||
const searchRequest = { params: {} };
|
||||
const options = { sessionId };
|
||||
mockSessionClient.trackId = jest.fn();
|
||||
|
||||
mockStrategy.search.mockReturnValue(
|
||||
of({
|
||||
id: 'my_id',
|
||||
rawResponse: {} as any,
|
||||
})
|
||||
);
|
||||
|
||||
await mockScopedClient.search(searchRequest, options).toPromise();
|
||||
|
||||
expect(mockSessionClient.trackId).toBeCalledTimes(1);
|
||||
expect(mockSessionClient.trackId).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('calls `trackId` for every response, if the response contains an `id` and not restoring', async () => {
|
||||
it('calls `trackId` once, if the response contains an `id`, session is stored and not restoring', async () => {
|
||||
const searchRequest = { params: {} };
|
||||
const options = { sessionId, isStored: false, isRestore: false };
|
||||
const options = { sessionId, isStored: true, isRestore: false };
|
||||
mockSessionClient.trackId = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockStrategy.search.mockReturnValue(
|
||||
|
@ -228,10 +248,20 @@ describe('Search service', () => {
|
|||
|
||||
await mockScopedClient.search(searchRequest, options).toPromise();
|
||||
|
||||
expect(mockSessionClient.trackId).toBeCalledTimes(2);
|
||||
expect(mockSessionClient.trackId).toBeCalledTimes(1);
|
||||
|
||||
expect(mockSessionClient.trackId.mock.calls[0]).toEqual([searchRequest, 'my_id', options]);
|
||||
expect(mockSessionClient.trackId.mock.calls[1]).toEqual([searchRequest, 'my_id', options]);
|
||||
});
|
||||
|
||||
it('does not call `trackId` if search is already tracked', async () => {
|
||||
const searchRequest = { params: {} };
|
||||
const options = { sessionId, isStored: true, isRestore: false, isSearchStored: true };
|
||||
mockSessionClient.getId = jest.fn().mockResolvedValueOnce('my_id');
|
||||
mockSessionClient.trackId = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await mockScopedClient.search(searchRequest, options).toPromise();
|
||||
|
||||
expect(mockSessionClient.trackId).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('does not call `trackId` if restoring', async () => {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { firstValueFrom, from, Observable, throwError } from 'rxjs';
|
||||
import { concatMap, firstValueFrom, from, Observable, of, throwError } from 'rxjs';
|
||||
import { pick } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
|
@ -19,7 +19,7 @@ import {
|
|||
SharedGlobalConfig,
|
||||
StartServicesAccessor,
|
||||
} from '@kbn/core/server';
|
||||
import { map, switchMap, tap, withLatestFrom } from 'rxjs/operators';
|
||||
import { catchError, map, switchMap, tap } from 'rxjs/operators';
|
||||
import { BfetchServerSetup } from '@kbn/bfetch-plugin/server';
|
||||
import { ExpressionsServerSetup } from '@kbn/expressions-plugin/server';
|
||||
import { FieldFormatsStart } from '@kbn/field-formats-plugin/server';
|
||||
|
@ -156,13 +156,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
|
|||
registerSearchRoute(router);
|
||||
registerSessionRoutes(router, this.logger);
|
||||
|
||||
if (taskManager) {
|
||||
this.sessionService.setup(core, { taskManager, security });
|
||||
} else {
|
||||
// this should never happen in real world, but
|
||||
// taskManager and security are optional deps because they are in x-pack
|
||||
this.logger.debug('Skipping sessionService setup because taskManager is not available');
|
||||
}
|
||||
this.sessionService.setup(core, { security });
|
||||
|
||||
core.http.registerRouteHandlerContext<DataRequestHandlerContext, 'search'>(
|
||||
'search',
|
||||
|
@ -273,9 +267,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
|
|||
): ISearchStart {
|
||||
const { elasticsearch, savedObjects, uiSettings } = core;
|
||||
|
||||
if (taskManager) {
|
||||
this.sessionService.start(core, { taskManager });
|
||||
}
|
||||
this.sessionService.start(core, {});
|
||||
|
||||
const aggs = this.aggsService.start({
|
||||
fieldFormats,
|
||||
|
@ -384,22 +376,55 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
|
|||
};
|
||||
|
||||
const searchRequest$ = from(getSearchRequest());
|
||||
let isInternalSearchStored = false; // used to prevent tracking current search more than once
|
||||
const search$ = searchRequest$.pipe(
|
||||
switchMap((searchRequest) => strategy.search(searchRequest, options, deps)),
|
||||
withLatestFrom(searchRequest$),
|
||||
tap(([response, requestWithId]) => {
|
||||
if (!options.sessionId || !response.id || (options.isRestore && requestWithId.id)) return;
|
||||
// intentionally swallow tracking error, as it shouldn't fail the search
|
||||
deps.searchSessionsClient.trackId(request, response.id, options).catch((trackErr) => {
|
||||
this.logger.error(trackErr);
|
||||
});
|
||||
}),
|
||||
map(([response, requestWithId]) => {
|
||||
return {
|
||||
...response,
|
||||
isRestored: !!requestWithId.id,
|
||||
};
|
||||
})
|
||||
switchMap((searchRequest) =>
|
||||
strategy.search(searchRequest, options, deps).pipe(
|
||||
concatMap((response) => {
|
||||
response = {
|
||||
...response,
|
||||
isRestored: !!searchRequest.id,
|
||||
};
|
||||
|
||||
if (
|
||||
options.sessionId && // if within search session
|
||||
options.isStored && // and search session was saved (saved object exists)
|
||||
response.id && // and async search has started
|
||||
!(options.isRestore && searchRequest.id) // and not restoring already tracked search
|
||||
) {
|
||||
// then track this search inside the search-session saved object
|
||||
|
||||
// check if search was already tracked and extended, don't track again in this case
|
||||
if (options.isSearchStored || isInternalSearchStored) {
|
||||
return of({
|
||||
...response,
|
||||
isStored: true,
|
||||
});
|
||||
} else {
|
||||
return from(
|
||||
deps.searchSessionsClient.trackId(request, response.id, options)
|
||||
).pipe(
|
||||
tap(() => {
|
||||
isInternalSearchStored = true;
|
||||
}),
|
||||
map(() => ({
|
||||
...response,
|
||||
isStored: true,
|
||||
})),
|
||||
catchError((e) => {
|
||||
this.logger.error(
|
||||
`Error while trying to track search id: ${e?.message}. This might lead to untracked long-running search.`
|
||||
);
|
||||
return of(response);
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return of(response);
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return search$;
|
||||
|
@ -521,6 +546,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
|
|||
extendSession: this.extendSession.bind(this, deps),
|
||||
cancelSession: this.cancelSession.bind(this, deps),
|
||||
deleteSession: this.deleteSession.bind(this, deps),
|
||||
getSessionStatus: searchSessionsClient.status,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,595 +0,0 @@
|
|||
/*
|
||||
* 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 { checkNonPersistedSessions as checkNonPersistedSessions$ } from './check_non_persisted_sessions';
|
||||
import {
|
||||
SearchSessionStatus,
|
||||
SearchSessionSavedObjectAttributes,
|
||||
ENHANCED_ES_SEARCH_STRATEGY,
|
||||
EQL_SEARCH_STRATEGY,
|
||||
} from '../../../common';
|
||||
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import { CheckSearchSessionsDeps, SearchStatus } from './types';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
SavedObjectsBulkUpdateObject,
|
||||
SavedObjectsDeleteOptions,
|
||||
SavedObjectsClientContract,
|
||||
} from '@kbn/core/server';
|
||||
import { SearchSessionsConfigSchema } from '../../../config';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
const checkNonPersistedSessions = (
|
||||
deps: CheckSearchSessionsDeps,
|
||||
config: SearchSessionsConfigSchema
|
||||
) => checkNonPersistedSessions$(deps, config).toPromise();
|
||||
|
||||
describe('checkNonPersistedSessions', () => {
|
||||
let mockClient: any;
|
||||
let savedObjectsClient: jest.Mocked<SavedObjectsClientContract>;
|
||||
const config: SearchSessionsConfigSchema = {
|
||||
enabled: true,
|
||||
pageSize: 5,
|
||||
notTouchedInProgressTimeout: moment.duration(1, 'm'),
|
||||
notTouchedTimeout: moment.duration(5, 'm'),
|
||||
maxUpdateRetries: 3,
|
||||
defaultExpiration: moment.duration(7, 'd'),
|
||||
trackingInterval: moment.duration(10, 's'),
|
||||
expireInterval: moment.duration(10, 'm'),
|
||||
monitoringTaskTimeout: moment.duration(5, 'm'),
|
||||
cleanupInterval: moment.duration(10, 's'),
|
||||
management: {} as any,
|
||||
};
|
||||
const mockLogger: any = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
savedObjectsClient = savedObjectsClientMock.create();
|
||||
mockClient = {
|
||||
asyncSearch: {
|
||||
status: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
eql: {
|
||||
status: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
test('does nothing if there are no open sessions', async () => {
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [],
|
||||
total: 0,
|
||||
} as any);
|
||||
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.bulkUpdate).not.toBeCalled();
|
||||
expect(savedObjectsClient.delete).not.toBeCalled();
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
test('doesnt delete a non persisted, recently touched session', async () => {
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [
|
||||
{
|
||||
id: '123',
|
||||
attributes: {
|
||||
persisted: false,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
expires: moment().add(moment.duration(3, 'm')),
|
||||
created: moment().subtract(moment.duration(3, 'm')),
|
||||
touched: moment().subtract(moment.duration(10, 's')),
|
||||
idMapping: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
} as any);
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.bulkUpdate).not.toBeCalled();
|
||||
expect(savedObjectsClient.delete).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('doesnt delete a non persisted, completed session, within on screen time frame', async () => {
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [
|
||||
{
|
||||
id: '123',
|
||||
attributes: {
|
||||
persisted: false,
|
||||
status: SearchSessionStatus.COMPLETE,
|
||||
created: moment().subtract(moment.duration(3, 'm')),
|
||||
touched: moment().subtract(moment.duration(1, 'm')),
|
||||
expires: moment().add(moment.duration(3, 'm')),
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.COMPLETE,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
} as any);
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.bulkUpdate).not.toBeCalled();
|
||||
expect(savedObjectsClient.delete).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('deletes in space', async () => {
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [
|
||||
{
|
||||
id: '123',
|
||||
namespaces: ['awesome'],
|
||||
attributes: {
|
||||
persisted: false,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
expires: moment().add(moment.duration(3, 'm')),
|
||||
created: moment().subtract(moment.duration(3, 'm')),
|
||||
touched: moment().subtract(moment.duration(2, 'm')),
|
||||
idMapping: {
|
||||
'map-key': {
|
||||
strategy: ENHANCED_ES_SEARCH_STRATEGY,
|
||||
id: 'async-id',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
} as any);
|
||||
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.delete).toBeCalled();
|
||||
|
||||
const [, id, opts] = savedObjectsClient.delete.mock.calls[0];
|
||||
expect(id).toBe('123');
|
||||
expect((opts as SavedObjectsDeleteOptions).namespace).toBe('awesome');
|
||||
});
|
||||
|
||||
test('deletes a non persisted, abandoned session', async () => {
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [
|
||||
{
|
||||
id: '123',
|
||||
attributes: {
|
||||
persisted: false,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
created: moment().subtract(moment.duration(3, 'm')),
|
||||
touched: moment().subtract(moment.duration(2, 'm')),
|
||||
expires: moment().add(moment.duration(3, 'm')),
|
||||
idMapping: {
|
||||
'map-key': {
|
||||
strategy: ENHANCED_ES_SEARCH_STRATEGY,
|
||||
id: 'async-id',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
} as any);
|
||||
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.bulkUpdate).not.toBeCalled();
|
||||
expect(savedObjectsClient.delete).toBeCalled();
|
||||
|
||||
expect(mockClient.asyncSearch.delete).toBeCalled();
|
||||
|
||||
const { id } = mockClient.asyncSearch.delete.mock.calls[0][0];
|
||||
expect(id).toBe('async-id');
|
||||
});
|
||||
|
||||
test('deletes a completed, not persisted session', async () => {
|
||||
mockClient.asyncSearch.delete = jest.fn().mockResolvedValue(true);
|
||||
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [
|
||||
{
|
||||
id: '123',
|
||||
attributes: {
|
||||
persisted: false,
|
||||
status: SearchSessionStatus.COMPLETE,
|
||||
expires: moment().add(moment.duration(3, 'm')),
|
||||
created: moment().subtract(moment.duration(30, 'm')),
|
||||
touched: moment().subtract(moment.duration(6, 'm')),
|
||||
idMapping: {
|
||||
'map-key': {
|
||||
strategy: ENHANCED_ES_SEARCH_STRATEGY,
|
||||
id: 'async-id',
|
||||
status: SearchStatus.COMPLETE,
|
||||
},
|
||||
'eql-map-key': {
|
||||
strategy: EQL_SEARCH_STRATEGY,
|
||||
id: 'eql-async-id',
|
||||
status: SearchStatus.COMPLETE,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
} as any);
|
||||
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.bulkUpdate).not.toBeCalled();
|
||||
expect(savedObjectsClient.delete).toBeCalled();
|
||||
|
||||
expect(mockClient.asyncSearch.delete).toBeCalled();
|
||||
expect(mockClient.eql.delete).not.toBeCalled();
|
||||
|
||||
const { id } = mockClient.asyncSearch.delete.mock.calls[0][0];
|
||||
expect(id).toBe('async-id');
|
||||
});
|
||||
|
||||
test('ignores errors thrown while deleting async searches', async () => {
|
||||
mockClient.asyncSearch.delete = jest.fn().mockRejectedValueOnce(false);
|
||||
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [
|
||||
{
|
||||
id: '123',
|
||||
attributes: {
|
||||
persisted: false,
|
||||
status: SearchSessionStatus.COMPLETE,
|
||||
expires: moment().add(moment.duration(3, 'm')),
|
||||
created: moment().subtract(moment.duration(30, 'm')),
|
||||
touched: moment().subtract(moment.duration(6, 'm')),
|
||||
idMapping: {
|
||||
'map-key': {
|
||||
strategy: ENHANCED_ES_SEARCH_STRATEGY,
|
||||
id: 'async-id',
|
||||
status: SearchStatus.COMPLETE,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
} as any);
|
||||
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.bulkUpdate).not.toBeCalled();
|
||||
expect(savedObjectsClient.delete).toBeCalled();
|
||||
|
||||
expect(mockClient.asyncSearch.delete).toBeCalled();
|
||||
|
||||
const { id } = mockClient.asyncSearch.delete.mock.calls[0][0];
|
||||
expect(id).toBe('async-id');
|
||||
});
|
||||
|
||||
test("doesn't attempt to delete errored out async search", async () => {
|
||||
mockClient.asyncSearch.delete = jest.fn();
|
||||
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [
|
||||
{
|
||||
id: '123',
|
||||
attributes: {
|
||||
persisted: false,
|
||||
status: SearchSessionStatus.ERROR,
|
||||
expires: moment().add(moment.duration(3, 'm')),
|
||||
created: moment().subtract(moment.duration(30, 'm')),
|
||||
touched: moment().subtract(moment.duration(6, 'm')),
|
||||
idMapping: {
|
||||
'map-key': {
|
||||
strategy: ENHANCED_ES_SEARCH_STRATEGY,
|
||||
id: 'async-id',
|
||||
status: SearchStatus.ERROR,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
} as any);
|
||||
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.bulkUpdate).not.toBeCalled();
|
||||
expect(savedObjectsClient.delete).toBeCalled();
|
||||
expect(mockClient.asyncSearch.delete).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
test('does nothing if the search is still running', async () => {
|
||||
const so = {
|
||||
id: '123',
|
||||
attributes: {
|
||||
persisted: false,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
created: moment().subtract(moment.duration(3, 'm')),
|
||||
touched: moment().subtract(moment.duration(10, 's')),
|
||||
expires: moment().add(moment.duration(3, 'm')),
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.IN_PROGRESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [so],
|
||||
total: 1,
|
||||
} as any);
|
||||
|
||||
mockClient.asyncSearch.status.mockResolvedValue({
|
||||
body: {
|
||||
is_partial: true,
|
||||
is_running: true,
|
||||
},
|
||||
});
|
||||
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.bulkUpdate).not.toBeCalled();
|
||||
expect(savedObjectsClient.delete).not.toBeCalled();
|
||||
});
|
||||
|
||||
test("doesn't re-check completed or errored searches", async () => {
|
||||
savedObjectsClient.bulkUpdate = jest.fn();
|
||||
savedObjectsClient.delete = jest.fn();
|
||||
const so = {
|
||||
id: '123',
|
||||
attributes: {
|
||||
status: SearchSessionStatus.ERROR,
|
||||
expires: moment().add(moment.duration(3, 'm')),
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.COMPLETE,
|
||||
},
|
||||
'another-search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.ERROR,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [so],
|
||||
total: 1,
|
||||
} as any);
|
||||
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(mockClient.asyncSearch.status).not.toBeCalled();
|
||||
expect(savedObjectsClient.bulkUpdate).not.toBeCalled();
|
||||
expect(savedObjectsClient.delete).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('updates in space', async () => {
|
||||
savedObjectsClient.bulkUpdate = jest.fn();
|
||||
const so = {
|
||||
namespaces: ['awesome'],
|
||||
attributes: {
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
expires: moment().add(moment.duration(3, 'm')),
|
||||
touched: '123',
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.IN_PROGRESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [so],
|
||||
total: 1,
|
||||
} as any);
|
||||
|
||||
mockClient.asyncSearch.status.mockResolvedValue({
|
||||
body: {
|
||||
is_partial: false,
|
||||
is_running: false,
|
||||
completion_status: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(mockClient.asyncSearch.status).toBeCalledWith({ id: 'search-id' }, { meta: true });
|
||||
const [updateInput] = savedObjectsClient.bulkUpdate.mock.calls[0];
|
||||
const updatedAttributes = updateInput[0] as SavedObjectsBulkUpdateObject;
|
||||
expect(updatedAttributes.namespace).toBe('awesome');
|
||||
});
|
||||
|
||||
test('updates to complete if the search is done', async () => {
|
||||
savedObjectsClient.bulkUpdate = jest.fn();
|
||||
const so = {
|
||||
attributes: {
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
expires: moment().add(moment.duration(3, 'm')),
|
||||
touched: '123',
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.IN_PROGRESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [so],
|
||||
total: 1,
|
||||
} as any);
|
||||
|
||||
mockClient.asyncSearch.status.mockResolvedValue({
|
||||
body: {
|
||||
is_partial: false,
|
||||
is_running: false,
|
||||
completion_status: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(mockClient.asyncSearch.status).toBeCalledWith({ id: 'search-id' }, { meta: true });
|
||||
const [updateInput] = savedObjectsClient.bulkUpdate.mock.calls[0];
|
||||
const updatedAttributes = updateInput[0].attributes as SearchSessionSavedObjectAttributes;
|
||||
expect(updatedAttributes.status).toBe(SearchSessionStatus.COMPLETE);
|
||||
expect(updatedAttributes.touched).not.toBe('123');
|
||||
expect(updatedAttributes.completed).not.toBeUndefined();
|
||||
expect(updatedAttributes.idMapping['search-hash'].status).toBe(SearchStatus.COMPLETE);
|
||||
expect(updatedAttributes.idMapping['search-hash'].error).toBeUndefined();
|
||||
|
||||
expect(savedObjectsClient.delete).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('updates to error if the search is errored', async () => {
|
||||
savedObjectsClient.bulkUpdate = jest.fn();
|
||||
const so = {
|
||||
attributes: {
|
||||
expires: moment().add(moment.duration(3, 'm')),
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.IN_PROGRESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [so],
|
||||
total: 1,
|
||||
} as any);
|
||||
|
||||
mockClient.asyncSearch.status.mockResolvedValue({
|
||||
body: {
|
||||
is_partial: false,
|
||||
is_running: false,
|
||||
completion_status: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await checkNonPersistedSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
const [updateInput] = savedObjectsClient.bulkUpdate.mock.calls[0];
|
||||
const updatedAttributes = updateInput[0].attributes as SearchSessionSavedObjectAttributes;
|
||||
expect(updatedAttributes.status).toBe(SearchSessionStatus.ERROR);
|
||||
expect(updatedAttributes.idMapping['search-hash'].status).toBe(SearchStatus.ERROR);
|
||||
expect(updatedAttributes.idMapping['search-hash'].error).toBe(
|
||||
'Search completed with a 500 status'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,136 +0,0 @@
|
|||
/*
|
||||
* 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 { SavedObjectsFindResult } from '@kbn/core/server';
|
||||
import moment from 'moment';
|
||||
import { EMPTY } from 'rxjs';
|
||||
import { catchError, concatMap } from 'rxjs/operators';
|
||||
import { nodeBuilder, KueryNode } from '@kbn/es-query';
|
||||
import {
|
||||
ENHANCED_ES_SEARCH_STRATEGY,
|
||||
SEARCH_SESSION_TYPE,
|
||||
SearchSessionSavedObjectAttributes,
|
||||
SearchSessionStatus,
|
||||
} from '../../../common';
|
||||
import { checkSearchSessionsByPage, getSearchSessionsPage$ } from './get_search_session_page';
|
||||
import { CheckSearchSessionsDeps, SearchStatus } from './types';
|
||||
import { bulkUpdateSessions, getAllSessionsStatusUpdates } from './update_session_status';
|
||||
import { SearchSessionsConfigSchema } from '../../../config';
|
||||
|
||||
export const SEARCH_SESSIONS_CLEANUP_TASK_TYPE = 'search_sessions_cleanup';
|
||||
export const SEARCH_SESSIONS_CLEANUP_TASK_ID = `data_enhanced_${SEARCH_SESSIONS_CLEANUP_TASK_TYPE}`;
|
||||
|
||||
function isSessionStale(
|
||||
session: SavedObjectsFindResult<SearchSessionSavedObjectAttributes>,
|
||||
config: SearchSessionsConfigSchema
|
||||
) {
|
||||
const curTime = moment();
|
||||
// Delete cancelled sessions immediately
|
||||
if (session.attributes.status === SearchSessionStatus.CANCELLED) return true;
|
||||
// Delete if a running session wasn't polled for in the last notTouchedInProgressTimeout OR
|
||||
// if a completed \ errored \ canceled session wasn't saved for within notTouchedTimeout
|
||||
return (
|
||||
(session.attributes.status === SearchSessionStatus.IN_PROGRESS &&
|
||||
curTime.diff(moment(session.attributes.touched), 'ms') >
|
||||
config.notTouchedInProgressTimeout.asMilliseconds()) ||
|
||||
(session.attributes.status !== SearchSessionStatus.IN_PROGRESS &&
|
||||
curTime.diff(moment(session.attributes.touched), 'ms') >
|
||||
config.notTouchedTimeout.asMilliseconds())
|
||||
);
|
||||
}
|
||||
|
||||
function checkNonPersistedSessionsPage(
|
||||
deps: CheckSearchSessionsDeps,
|
||||
config: SearchSessionsConfigSchema,
|
||||
filter: KueryNode,
|
||||
page: number
|
||||
) {
|
||||
const { logger, client, savedObjectsClient } = deps;
|
||||
logger.debug(`${SEARCH_SESSIONS_CLEANUP_TASK_TYPE} Fetching sessions from page ${page}`);
|
||||
return getSearchSessionsPage$(deps, filter, config.pageSize, page).pipe(
|
||||
concatMap(async (nonPersistedSearchSessions) => {
|
||||
if (!nonPersistedSearchSessions.total) return nonPersistedSearchSessions;
|
||||
|
||||
logger.debug(
|
||||
`${SEARCH_SESSIONS_CLEANUP_TASK_TYPE} Found ${nonPersistedSearchSessions.total} sessions, processing ${nonPersistedSearchSessions.saved_objects.length}`
|
||||
);
|
||||
|
||||
const updatedSessions = await getAllSessionsStatusUpdates(
|
||||
deps,
|
||||
config,
|
||||
nonPersistedSearchSessions
|
||||
);
|
||||
const deletedSessionIds: string[] = [];
|
||||
|
||||
await Promise.all(
|
||||
nonPersistedSearchSessions.saved_objects.map(async (session) => {
|
||||
if (isSessionStale(session, config)) {
|
||||
// delete saved object to free up memory
|
||||
// TODO: there's a potential rare edge case of deleting an object and then receiving a new trackId for that same session!
|
||||
// Maybe we want to change state to deleted and cleanup later?
|
||||
logger.debug(`Deleting stale session | ${session.id}`);
|
||||
try {
|
||||
deletedSessionIds.push(session.id);
|
||||
await savedObjectsClient.delete(SEARCH_SESSION_TYPE, session.id, {
|
||||
namespace: session.namespaces?.[0],
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`${SEARCH_SESSIONS_CLEANUP_TASK_TYPE} Error while deleting session ${session.id}: ${e.message}`
|
||||
);
|
||||
}
|
||||
|
||||
// Send a delete request for each async search to ES
|
||||
Object.keys(session.attributes.idMapping).map(async (searchKey: string) => {
|
||||
const searchInfo = session.attributes.idMapping[searchKey];
|
||||
if (searchInfo.status === SearchStatus.ERROR) return; // skip attempting to delete async search in case we know it has errored out
|
||||
|
||||
if (searchInfo.strategy === ENHANCED_ES_SEARCH_STRATEGY) {
|
||||
try {
|
||||
await client.asyncSearch.delete({ id: searchInfo.id });
|
||||
} catch (e) {
|
||||
if (e.message !== 'resource_not_found_exception') {
|
||||
logger.error(
|
||||
`${SEARCH_SESSIONS_CLEANUP_TASK_TYPE} Error while deleting async_search ${searchInfo.id}: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const nonDeletedSessions = updatedSessions.filter((updateSession) => {
|
||||
return deletedSessionIds.indexOf(updateSession.id) === -1;
|
||||
});
|
||||
|
||||
await bulkUpdateSessions(deps, nonDeletedSessions);
|
||||
|
||||
return nonPersistedSearchSessions;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function checkNonPersistedSessions(
|
||||
deps: CheckSearchSessionsDeps,
|
||||
config: SearchSessionsConfigSchema
|
||||
) {
|
||||
const { logger } = deps;
|
||||
|
||||
const filters = nodeBuilder.is(`${SEARCH_SESSION_TYPE}.attributes.persisted`, 'false');
|
||||
|
||||
return checkSearchSessionsByPage(checkNonPersistedSessionsPage, deps, config, filters).pipe(
|
||||
catchError((e) => {
|
||||
logger.error(
|
||||
`${SEARCH_SESSIONS_CLEANUP_TASK_TYPE} Error while processing sessions: ${e?.message}`
|
||||
);
|
||||
return EMPTY;
|
||||
})
|
||||
);
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
/*
|
||||
* 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 { checkPersistedSessionsProgress } from './check_persisted_sessions';
|
||||
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import moment from 'moment';
|
||||
import { SavedObjectsClientContract } from '@kbn/core/server';
|
||||
import { SearchSessionsConfigSchema } from '../../../config';
|
||||
|
||||
describe('checkPersistedSessionsProgress', () => {
|
||||
let mockClient: any;
|
||||
let savedObjectsClient: jest.Mocked<SavedObjectsClientContract>;
|
||||
const config: SearchSessionsConfigSchema = {
|
||||
enabled: true,
|
||||
pageSize: 5,
|
||||
notTouchedInProgressTimeout: moment.duration(1, 'm'),
|
||||
notTouchedTimeout: moment.duration(5, 'm'),
|
||||
maxUpdateRetries: 3,
|
||||
defaultExpiration: moment.duration(7, 'd'),
|
||||
trackingInterval: moment.duration(10, 's'),
|
||||
cleanupInterval: moment.duration(10, 's'),
|
||||
expireInterval: moment.duration(10, 'm'),
|
||||
monitoringTaskTimeout: moment.duration(5, 'm'),
|
||||
management: {} as any,
|
||||
};
|
||||
const mockLogger: any = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
savedObjectsClient = savedObjectsClientMock.create();
|
||||
mockClient = {
|
||||
asyncSearch: {
|
||||
status: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
eql: {
|
||||
status: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
test('fetches only running persisted sessions', async () => {
|
||||
savedObjectsClient.find.mockResolvedValue({
|
||||
saved_objects: [],
|
||||
total: 0,
|
||||
} as any);
|
||||
|
||||
await checkPersistedSessionsProgress(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
const [findInput] = savedObjectsClient.find.mock.calls[0];
|
||||
|
||||
expect(findInput.filter.arguments[0].arguments[0].value).toBe(
|
||||
'search-session.attributes.persisted'
|
||||
);
|
||||
expect(findInput.filter.arguments[0].arguments[1].value).toBe('true');
|
||||
expect(findInput.filter.arguments[1].arguments[0].value).toBe(
|
||||
'search-session.attributes.status'
|
||||
);
|
||||
expect(findInput.filter.arguments[1].arguments[1].value).toBe('in_progress');
|
||||
});
|
||||
});
|
|
@ -1,74 +0,0 @@
|
|||
/*
|
||||
* 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 { EMPTY, Observable } from 'rxjs';
|
||||
import { catchError, concatMap } from 'rxjs/operators';
|
||||
import { nodeBuilder, KueryNode } from '@kbn/es-query';
|
||||
import { SEARCH_SESSION_TYPE, SearchSessionStatus } from '../../../common';
|
||||
import { checkSearchSessionsByPage, getSearchSessionsPage$ } from './get_search_session_page';
|
||||
import { CheckSearchSessionsDeps, SearchSessionsResponse } from './types';
|
||||
import { bulkUpdateSessions, getAllSessionsStatusUpdates } from './update_session_status';
|
||||
import { SearchSessionsConfigSchema } from '../../../config';
|
||||
|
||||
export const SEARCH_SESSIONS_TASK_TYPE = 'search_sessions_monitor';
|
||||
export const SEARCH_SESSIONS_TASK_ID = `data_enhanced_${SEARCH_SESSIONS_TASK_TYPE}`;
|
||||
|
||||
function checkPersistedSessionsPage(
|
||||
deps: CheckSearchSessionsDeps,
|
||||
config: SearchSessionsConfigSchema,
|
||||
filter: KueryNode,
|
||||
page: number
|
||||
): Observable<SearchSessionsResponse> {
|
||||
const { logger } = deps;
|
||||
logger.debug(`${SEARCH_SESSIONS_TASK_TYPE} Fetching sessions from page ${page}`);
|
||||
return getSearchSessionsPage$(deps, filter, config.pageSize, page).pipe(
|
||||
concatMap(async (persistedSearchSessions) => {
|
||||
if (!persistedSearchSessions.total) return persistedSearchSessions;
|
||||
|
||||
logger.debug(
|
||||
`${SEARCH_SESSIONS_TASK_TYPE} Found ${persistedSearchSessions.total} sessions, processing ${persistedSearchSessions.saved_objects.length}`
|
||||
);
|
||||
|
||||
const updatedSessions = await getAllSessionsStatusUpdates(
|
||||
deps,
|
||||
config,
|
||||
persistedSearchSessions
|
||||
);
|
||||
await bulkUpdateSessions(deps, updatedSessions);
|
||||
|
||||
return persistedSearchSessions;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function checkPersistedSessionsProgress(
|
||||
deps: CheckSearchSessionsDeps,
|
||||
config: SearchSessionsConfigSchema
|
||||
) {
|
||||
const { logger } = deps;
|
||||
|
||||
const persistedSessionsFilter = nodeBuilder.and([
|
||||
nodeBuilder.is(`${SEARCH_SESSION_TYPE}.attributes.persisted`, 'true'),
|
||||
nodeBuilder.is(
|
||||
`${SEARCH_SESSION_TYPE}.attributes.status`,
|
||||
SearchSessionStatus.IN_PROGRESS.toString()
|
||||
),
|
||||
]);
|
||||
|
||||
return checkSearchSessionsByPage(
|
||||
checkPersistedSessionsPage,
|
||||
deps,
|
||||
config,
|
||||
persistedSessionsFilter
|
||||
).pipe(
|
||||
catchError((e) => {
|
||||
logger.error(`${SEARCH_SESSIONS_TASK_TYPE} Error while processing sessions: ${e?.message}`);
|
||||
return EMPTY;
|
||||
})
|
||||
);
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
/*
|
||||
* 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 { EMPTY, Observable } from 'rxjs';
|
||||
import { catchError, concatMap } from 'rxjs/operators';
|
||||
import { nodeBuilder, KueryNode } from '@kbn/es-query';
|
||||
import { SEARCH_SESSION_TYPE, SearchSessionStatus } from '../../../common';
|
||||
import { checkSearchSessionsByPage, getSearchSessionsPage$ } from './get_search_session_page';
|
||||
import { CheckSearchSessionsDeps, SearchSessionsResponse } from './types';
|
||||
import { bulkUpdateSessions, getAllSessionsStatusUpdates } from './update_session_status';
|
||||
import { SearchSessionsConfigSchema } from '../../../config';
|
||||
|
||||
export const SEARCH_SESSIONS_EXPIRE_TASK_TYPE = 'search_sessions_expire';
|
||||
export const SEARCH_SESSIONS_EXPIRE_TASK_ID = `data_enhanced_${SEARCH_SESSIONS_EXPIRE_TASK_TYPE}`;
|
||||
|
||||
function checkSessionExpirationPage(
|
||||
deps: CheckSearchSessionsDeps,
|
||||
config: SearchSessionsConfigSchema,
|
||||
filter: KueryNode,
|
||||
page: number
|
||||
): Observable<SearchSessionsResponse> {
|
||||
const { logger } = deps;
|
||||
logger.debug(`${SEARCH_SESSIONS_EXPIRE_TASK_TYPE} Fetching sessions from page ${page}`);
|
||||
return getSearchSessionsPage$(deps, filter, config.pageSize, page).pipe(
|
||||
concatMap(async (searchSessions) => {
|
||||
if (!searchSessions.total) return searchSessions;
|
||||
|
||||
logger.debug(
|
||||
`${SEARCH_SESSIONS_EXPIRE_TASK_TYPE} Found ${searchSessions.total} sessions, processing ${searchSessions.saved_objects.length}`
|
||||
);
|
||||
|
||||
const updatedSessions = await getAllSessionsStatusUpdates(deps, config, searchSessions);
|
||||
await bulkUpdateSessions(deps, updatedSessions);
|
||||
|
||||
return searchSessions;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function checkPersistedCompletedSessionExpiration(
|
||||
deps: CheckSearchSessionsDeps,
|
||||
config: SearchSessionsConfigSchema
|
||||
) {
|
||||
const { logger } = deps;
|
||||
|
||||
const persistedSessionsFilter = nodeBuilder.and([
|
||||
nodeBuilder.is(`${SEARCH_SESSION_TYPE}.attributes.persisted`, 'true'),
|
||||
nodeBuilder.is(
|
||||
`${SEARCH_SESSION_TYPE}.attributes.status`,
|
||||
SearchSessionStatus.COMPLETE.toString()
|
||||
),
|
||||
]);
|
||||
|
||||
return checkSearchSessionsByPage(
|
||||
checkSessionExpirationPage,
|
||||
deps,
|
||||
config,
|
||||
persistedSessionsFilter
|
||||
).pipe(
|
||||
catchError((e) => {
|
||||
logger.error(
|
||||
`${SEARCH_SESSIONS_EXPIRE_TASK_TYPE} Error while processing sessions: ${e?.message}`
|
||||
);
|
||||
return EMPTY;
|
||||
})
|
||||
);
|
||||
}
|
|
@ -1,282 +0,0 @@
|
|||
/*
|
||||
* 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 { checkSearchSessionsByPage, getSearchSessionsPage$ } from './get_search_session_page';
|
||||
import { ENHANCED_ES_SEARCH_STRATEGY, SearchSessionStatus } from '../../../common';
|
||||
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import { SearchStatus } from './types';
|
||||
import moment from 'moment';
|
||||
import { SavedObjectsClientContract } from '@kbn/core/server';
|
||||
import { of, Subject, throwError } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { SearchSessionsConfigSchema } from '../../../config';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('checkSearchSessionsByPage', () => {
|
||||
const mockClient = {} as any;
|
||||
let savedObjectsClient: jest.Mocked<SavedObjectsClientContract>;
|
||||
const config: SearchSessionsConfigSchema = {
|
||||
enabled: true,
|
||||
pageSize: 5,
|
||||
management: {} as any,
|
||||
} as any;
|
||||
const mockLogger: any = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
const emptySO = {
|
||||
attributes: {
|
||||
persisted: false,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
created: moment().subtract(moment.duration(3, 'm')),
|
||||
touched: moment().subtract(moment.duration(10, 's')),
|
||||
idMapping: {},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
savedObjectsClient = savedObjectsClientMock.create();
|
||||
});
|
||||
|
||||
describe('getSearchSessionsPage$', () => {
|
||||
test('sorting is by "touched"', async () => {
|
||||
savedObjectsClient.find.mockResolvedValueOnce({
|
||||
saved_objects: [],
|
||||
total: 0,
|
||||
} as any);
|
||||
|
||||
await getSearchSessionsPage$(
|
||||
{
|
||||
savedObjectsClient,
|
||||
} as any,
|
||||
{
|
||||
type: 'literal',
|
||||
},
|
||||
1,
|
||||
1
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sortField: 'touched', sortOrder: 'asc' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
test('fetches one page if got empty response', async () => {
|
||||
const checkFn = jest.fn().mockReturnValue(of(undefined));
|
||||
|
||||
await checkSearchSessionsByPage(
|
||||
checkFn,
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config,
|
||||
[]
|
||||
).toPromise();
|
||||
|
||||
expect(checkFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('fetches one page if got response with no saved objects', async () => {
|
||||
const checkFn = jest.fn().mockReturnValue(
|
||||
of({
|
||||
total: 0,
|
||||
})
|
||||
);
|
||||
|
||||
await checkSearchSessionsByPage(
|
||||
checkFn,
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config,
|
||||
[]
|
||||
).toPromise();
|
||||
|
||||
expect(checkFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('fetches one page if less than page size object are returned', async () => {
|
||||
const checkFn = jest.fn().mockReturnValue(
|
||||
of({
|
||||
saved_objects: [emptySO, emptySO],
|
||||
total: 5,
|
||||
})
|
||||
);
|
||||
|
||||
await checkSearchSessionsByPage(
|
||||
checkFn,
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config,
|
||||
[]
|
||||
).toPromise();
|
||||
|
||||
expect(checkFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('fetches two pages if exactly page size objects are returned', async () => {
|
||||
let i = 0;
|
||||
|
||||
const checkFn = jest.fn().mockImplementation(() =>
|
||||
of({
|
||||
saved_objects: i++ === 0 ? [emptySO, emptySO, emptySO, emptySO, emptySO] : [],
|
||||
total: 5,
|
||||
page: i,
|
||||
})
|
||||
);
|
||||
|
||||
await checkSearchSessionsByPage(
|
||||
checkFn,
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config,
|
||||
[]
|
||||
).toPromise();
|
||||
|
||||
expect(checkFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// validate that page number increases
|
||||
const page1 = checkFn.mock.calls[0][3];
|
||||
const page2 = checkFn.mock.calls[1][3];
|
||||
expect(page1).toBe(1);
|
||||
expect(page2).toBe(2);
|
||||
});
|
||||
|
||||
test('fetches two pages if page size +1 objects are returned', async () => {
|
||||
let i = 0;
|
||||
|
||||
const checkFn = jest.fn().mockImplementation(() =>
|
||||
of({
|
||||
saved_objects: i++ === 0 ? [emptySO, emptySO, emptySO, emptySO, emptySO] : [emptySO],
|
||||
total: i === 0 ? 5 : 1,
|
||||
page: i,
|
||||
})
|
||||
);
|
||||
|
||||
await checkSearchSessionsByPage(
|
||||
checkFn,
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config,
|
||||
[]
|
||||
).toPromise();
|
||||
|
||||
expect(checkFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('sessions fetched in the beginning are processed even if sessions in the end fail', async () => {
|
||||
let i = 0;
|
||||
|
||||
const checkFn = jest.fn().mockImplementation(() => {
|
||||
if (++i === 2) {
|
||||
return throwError('Fake find error...');
|
||||
}
|
||||
return of({
|
||||
saved_objects:
|
||||
i <= 5
|
||||
? [
|
||||
i === 1
|
||||
? {
|
||||
id: '123',
|
||||
attributes: {
|
||||
persisted: false,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
created: moment().subtract(moment.duration(3, 'm')),
|
||||
touched: moment().subtract(moment.duration(2, 'm')),
|
||||
idMapping: {
|
||||
'map-key': {
|
||||
strategy: ENHANCED_ES_SEARCH_STRATEGY,
|
||||
id: 'async-id',
|
||||
status: SearchStatus.IN_PROGRESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: emptySO,
|
||||
emptySO,
|
||||
emptySO,
|
||||
emptySO,
|
||||
emptySO,
|
||||
]
|
||||
: [],
|
||||
total: 25,
|
||||
page: i,
|
||||
});
|
||||
});
|
||||
|
||||
await checkSearchSessionsByPage(
|
||||
checkFn,
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config,
|
||||
[]
|
||||
)
|
||||
.toPromise()
|
||||
.catch(() => {});
|
||||
|
||||
expect(checkFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('fetching is abortable', async () => {
|
||||
let i = 0;
|
||||
const abort$ = new Subject<void>();
|
||||
|
||||
const checkFn = jest.fn().mockImplementation(() => {
|
||||
if (++i === 2) {
|
||||
abort$.next();
|
||||
}
|
||||
|
||||
return of({
|
||||
saved_objects: i <= 5 ? [emptySO, emptySO, emptySO, emptySO, emptySO] : [],
|
||||
total: 25,
|
||||
page: i,
|
||||
});
|
||||
});
|
||||
|
||||
await checkSearchSessionsByPage(
|
||||
checkFn,
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
config,
|
||||
[]
|
||||
)
|
||||
.pipe(takeUntil(abort$))
|
||||
.toPromise()
|
||||
.catch(() => {});
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
// if not for `abort$` then this would be called 6 times!
|
||||
expect(checkFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,60 +0,0 @@
|
|||
/*
|
||||
* 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 { SavedObjectsClientContract, Logger } from '@kbn/core/server';
|
||||
import { from, Observable, EMPTY } from 'rxjs';
|
||||
import { concatMap } from 'rxjs/operators';
|
||||
import type { KueryNode } from '@kbn/es-query';
|
||||
import { SearchSessionSavedObjectAttributes, SEARCH_SESSION_TYPE } from '../../../common';
|
||||
import { CheckSearchSessionsDeps, CheckSearchSessionsFn } from './types';
|
||||
import { SearchSessionsConfigSchema } from '../../../config';
|
||||
|
||||
export interface GetSessionsDeps {
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export function getSearchSessionsPage$(
|
||||
{ savedObjectsClient }: GetSessionsDeps,
|
||||
filter: KueryNode,
|
||||
pageSize: number,
|
||||
page: number
|
||||
) {
|
||||
return from(
|
||||
savedObjectsClient.find<SearchSessionSavedObjectAttributes>({
|
||||
page,
|
||||
perPage: pageSize,
|
||||
type: SEARCH_SESSION_TYPE,
|
||||
namespaces: ['*'],
|
||||
// process older sessions first
|
||||
sortField: 'touched',
|
||||
sortOrder: 'asc',
|
||||
filter,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export const checkSearchSessionsByPage = (
|
||||
checkFn: CheckSearchSessionsFn,
|
||||
deps: CheckSearchSessionsDeps,
|
||||
config: SearchSessionsConfigSchema,
|
||||
filters: any,
|
||||
nextPage = 1
|
||||
): Observable<void> =>
|
||||
checkFn(deps, config, filters, nextPage).pipe(
|
||||
concatMap((result) => {
|
||||
if (!result || !result.saved_objects || result.saved_objects.length < config.pageSize) {
|
||||
return EMPTY;
|
||||
} else {
|
||||
// TODO: while processing previous page session list might have been changed and we might skip a session,
|
||||
// because it would appear now on a different "page".
|
||||
// This isn't critical, as we would pick it up on a next task iteration, but maybe we could improve this somehow
|
||||
return checkSearchSessionsByPage(checkFn, deps, config, filters, result.page + 1);
|
||||
}
|
||||
})
|
||||
);
|
|
@ -9,24 +9,25 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import type { TransportResult } from '@elastic/elasticsearch';
|
||||
import { ElasticsearchClient } from '@kbn/core/server';
|
||||
import { SearchSessionRequestInfo } from '../../../common';
|
||||
import { AsyncSearchStatusResponse } from '../..';
|
||||
import { SearchSessionRequestStatus } from '../../../common';
|
||||
import { SearchStatus } from './types';
|
||||
import { AsyncSearchStatusResponse } from '../..';
|
||||
|
||||
export async function getSearchStatus(
|
||||
client: ElasticsearchClient,
|
||||
internalClient: ElasticsearchClient,
|
||||
asyncId: string
|
||||
): Promise<Pick<SearchSessionRequestInfo, 'status' | 'error'>> {
|
||||
): Promise<SearchSessionRequestStatus> {
|
||||
// TODO: Handle strategies other than the default one
|
||||
// https://github.com/elastic/kibana/issues/127880
|
||||
try {
|
||||
// @ts-expect-error start_time_in_millis: EpochMillis is string | number
|
||||
const apiResponse: TransportResult<AsyncSearchStatusResponse> = await client.asyncSearch.status(
|
||||
{
|
||||
id: asyncId,
|
||||
},
|
||||
{ meta: true }
|
||||
);
|
||||
const apiResponse: TransportResult<AsyncSearchStatusResponse> =
|
||||
await internalClient.asyncSearch.status(
|
||||
{
|
||||
id: asyncId,
|
||||
},
|
||||
{ meta: true }
|
||||
);
|
||||
const response = apiResponse.body;
|
||||
if ((response.is_partial && !response.is_running) || response.completion_status >= 400) {
|
||||
return {
|
||||
|
|
|
@ -6,72 +6,141 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { SearchStatus } from './types';
|
||||
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
|
||||
import { getSessionStatus } from './get_session_status';
|
||||
import { SearchSessionStatus } from '../../../common';
|
||||
import { SearchSessionSavedObjectAttributes, SearchSessionStatus } from '../../../common';
|
||||
import moment from 'moment';
|
||||
import { SearchSessionsConfigSchema } from '../../../config';
|
||||
|
||||
const mockInProgressSearchResponse = {
|
||||
body: {
|
||||
is_partial: true,
|
||||
is_running: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockErrorSearchResponse = {
|
||||
body: {
|
||||
is_partial: false,
|
||||
is_running: false,
|
||||
completion_status: 500,
|
||||
},
|
||||
};
|
||||
|
||||
const mockCompletedSearchResponse = {
|
||||
body: {
|
||||
is_partial: false,
|
||||
is_running: false,
|
||||
completion_status: 200,
|
||||
},
|
||||
};
|
||||
|
||||
describe('getSessionStatus', () => {
|
||||
const mockConfig = {
|
||||
notTouchedInProgressTimeout: moment.duration(1, 'm'),
|
||||
} as unknown as SearchSessionsConfigSchema;
|
||||
test("returns an in_progress status if there's nothing inside the session", () => {
|
||||
beforeEach(() => {
|
||||
deps.internalClient.asyncSearch.status.mockReset();
|
||||
});
|
||||
|
||||
const mockConfig = {} as unknown as SearchSessionsConfigSchema;
|
||||
const deps = { internalClient: elasticsearchServiceMock.createElasticsearchClient() };
|
||||
test("returns an in_progress status if there's nothing inside the session", async () => {
|
||||
const session: any = {
|
||||
idMapping: {},
|
||||
touched: moment(),
|
||||
};
|
||||
expect(getSessionStatus(session, mockConfig)).toBe(SearchSessionStatus.IN_PROGRESS);
|
||||
expect(await getSessionStatus(deps, session, mockConfig)).toBe(SearchSessionStatus.IN_PROGRESS);
|
||||
});
|
||||
|
||||
test("returns an error status if there's at least one error", () => {
|
||||
test("returns an error status if there's at least one error", async () => {
|
||||
deps.internalClient.asyncSearch.status.mockImplementation(async ({ id }): Promise<any> => {
|
||||
switch (id) {
|
||||
case 'a':
|
||||
return mockInProgressSearchResponse;
|
||||
case 'b':
|
||||
return mockErrorSearchResponse;
|
||||
case 'c':
|
||||
return mockCompletedSearchResponse;
|
||||
default:
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Not mocked search id');
|
||||
throw new Error('Not mocked search id');
|
||||
}
|
||||
});
|
||||
const session: any = {
|
||||
idMapping: {
|
||||
a: { status: SearchStatus.IN_PROGRESS },
|
||||
b: { status: SearchStatus.ERROR, error: 'Nope' },
|
||||
c: { status: SearchStatus.COMPLETE },
|
||||
a: {
|
||||
id: 'a',
|
||||
},
|
||||
b: { id: 'b' },
|
||||
c: { id: 'c' },
|
||||
},
|
||||
};
|
||||
expect(getSessionStatus(session, mockConfig)).toBe(SearchSessionStatus.ERROR);
|
||||
expect(await getSessionStatus(deps, session, mockConfig)).toBe(SearchSessionStatus.ERROR);
|
||||
});
|
||||
|
||||
test('expires a empty session after a minute', () => {
|
||||
test('expires a session if expired < now', async () => {
|
||||
const session: any = {
|
||||
idMapping: {},
|
||||
touched: moment().subtract(2, 'm'),
|
||||
expires: moment().subtract(2, 'm'),
|
||||
};
|
||||
expect(getSessionStatus(session, mockConfig)).toBe(SearchSessionStatus.EXPIRED);
|
||||
|
||||
expect(await getSessionStatus(deps, session, mockConfig)).toBe(SearchSessionStatus.EXPIRED);
|
||||
});
|
||||
|
||||
test('doesnt expire a full session after a minute', () => {
|
||||
test('doesnt expire if expire > now', async () => {
|
||||
deps.internalClient.asyncSearch.status.mockResolvedValue(mockInProgressSearchResponse as any);
|
||||
|
||||
const session: any = {
|
||||
idMapping: {
|
||||
a: { status: SearchStatus.IN_PROGRESS },
|
||||
a: { id: 'a' },
|
||||
},
|
||||
touched: moment().subtract(2, 'm'),
|
||||
expires: moment().add(2, 'm'),
|
||||
};
|
||||
expect(getSessionStatus(session, mockConfig)).toBe(SearchSessionStatus.IN_PROGRESS);
|
||||
expect(await getSessionStatus(deps, session, mockConfig)).toBe(SearchSessionStatus.IN_PROGRESS);
|
||||
});
|
||||
|
||||
test('returns a complete status if all are complete', () => {
|
||||
const session: any = {
|
||||
test('returns cancelled status if session was cancelled', async () => {
|
||||
const session: Partial<SearchSessionSavedObjectAttributes> = {
|
||||
idMapping: {
|
||||
a: { status: SearchStatus.COMPLETE },
|
||||
b: { status: SearchStatus.COMPLETE },
|
||||
c: { status: SearchStatus.COMPLETE },
|
||||
a: { id: 'a', strategy: 'ese' },
|
||||
},
|
||||
isCanceled: true,
|
||||
expires: moment().subtract(2, 'm').toISOString(),
|
||||
};
|
||||
expect(getSessionStatus(session, mockConfig)).toBe(SearchSessionStatus.COMPLETE);
|
||||
expect(
|
||||
await getSessionStatus(deps, session as SearchSessionSavedObjectAttributes, mockConfig)
|
||||
).toBe(SearchSessionStatus.CANCELLED);
|
||||
});
|
||||
|
||||
test('returns a running status if some are still running', () => {
|
||||
test('returns a complete status if all are complete', async () => {
|
||||
deps.internalClient.asyncSearch.status.mockResolvedValue(mockCompletedSearchResponse as any);
|
||||
|
||||
const session: any = {
|
||||
idMapping: {
|
||||
a: { status: SearchStatus.IN_PROGRESS },
|
||||
b: { status: SearchStatus.COMPLETE },
|
||||
c: { status: SearchStatus.IN_PROGRESS },
|
||||
a: { id: 'a' },
|
||||
b: { id: 'b' },
|
||||
c: { id: 'c' },
|
||||
},
|
||||
};
|
||||
expect(getSessionStatus(session, mockConfig)).toBe(SearchSessionStatus.IN_PROGRESS);
|
||||
expect(await getSessionStatus(deps, session, mockConfig)).toBe(SearchSessionStatus.COMPLETE);
|
||||
});
|
||||
|
||||
test('returns a running status if some are still running', async () => {
|
||||
deps.internalClient.asyncSearch.status.mockImplementation(async ({ id }): Promise<any> => {
|
||||
switch (id) {
|
||||
case 'a':
|
||||
return mockInProgressSearchResponse;
|
||||
default:
|
||||
return mockCompletedSearchResponse;
|
||||
}
|
||||
});
|
||||
|
||||
const session: any = {
|
||||
idMapping: {
|
||||
a: { id: 'a' },
|
||||
b: { id: 'b' },
|
||||
c: { id: 'c' },
|
||||
},
|
||||
};
|
||||
expect(await getSessionStatus(deps, session, mockConfig)).toBe(SearchSessionStatus.IN_PROGRESS);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,25 +7,40 @@
|
|||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
import { ElasticsearchClient } from '@kbn/core/server';
|
||||
import { SearchSessionSavedObjectAttributes, SearchSessionStatus } from '../../../common';
|
||||
import { SearchStatus } from './types';
|
||||
import { SearchSessionsConfigSchema } from '../../../config';
|
||||
import { getSearchStatus } from './get_search_status';
|
||||
|
||||
export function getSessionStatus(
|
||||
export async function getSessionStatus(
|
||||
deps: { internalClient: ElasticsearchClient },
|
||||
session: SearchSessionSavedObjectAttributes,
|
||||
config: SearchSessionsConfigSchema
|
||||
): SearchSessionStatus {
|
||||
const searchStatuses = Object.values(session.idMapping);
|
||||
const curTime = moment();
|
||||
): Promise<SearchSessionStatus> {
|
||||
if (session.isCanceled === true) {
|
||||
return SearchSessionStatus.CANCELLED;
|
||||
}
|
||||
|
||||
const now = moment();
|
||||
|
||||
if (moment(session.expires).isBefore(now)) {
|
||||
return SearchSessionStatus.EXPIRED;
|
||||
}
|
||||
|
||||
const searches = Object.values(session.idMapping);
|
||||
const searchStatuses = await Promise.all(
|
||||
searches.map(async (s) => {
|
||||
const status = await getSearchStatus(deps.internalClient, s.id);
|
||||
return {
|
||||
...s,
|
||||
...status,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (searchStatuses.some((item) => item.status === SearchStatus.ERROR)) {
|
||||
return SearchSessionStatus.ERROR;
|
||||
} else if (
|
||||
searchStatuses.length === 0 &&
|
||||
curTime.diff(moment(session.touched), 'ms') >
|
||||
moment.duration(config.notTouchedInProgressTimeout).asMilliseconds()
|
||||
) {
|
||||
// Expire empty sessions that weren't touched for a minute
|
||||
return SearchSessionStatus.EXPIRED;
|
||||
} else if (
|
||||
searchStatuses.length > 0 &&
|
||||
searchStatuses.every((item) => item.status === SearchStatus.COMPLETE)
|
||||
|
|
|
@ -22,6 +22,7 @@ export function createSearchSessionsClientMock(): jest.Mocked<IScopedSearchSessi
|
|||
cancel: jest.fn(),
|
||||
extend: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
status: jest.fn(),
|
||||
getConfig: jest.fn(
|
||||
() =>
|
||||
({
|
||||
|
|
|
@ -11,17 +11,16 @@ import {
|
|||
SavedObjectsClientContract,
|
||||
SavedObjectsErrorHelpers,
|
||||
} from '@kbn/core/server';
|
||||
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import { ElasticsearchClientMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import { nodeBuilder } from '@kbn/es-query';
|
||||
import { SearchSessionService } from './session_service';
|
||||
import { createRequestHash } from './utils';
|
||||
import moment from 'moment';
|
||||
import { coreMock } from '@kbn/core/server/mocks';
|
||||
import { ConfigSchema } from '../../../config';
|
||||
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
|
||||
import type { AuthenticatedUser } from '@kbn/security-plugin/common/model';
|
||||
import { SEARCH_SESSION_TYPE, SearchSessionStatus } from '../../../common';
|
||||
import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server';
|
||||
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
|
||||
|
||||
const MAX_UPDATE_RETRIES = 3;
|
||||
|
||||
|
@ -29,8 +28,8 @@ const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
|||
|
||||
describe('SearchSessionService', () => {
|
||||
let savedObjectsClient: jest.Mocked<SavedObjectsClientContract>;
|
||||
let elasticsearchClient: ElasticsearchClientMock;
|
||||
let service: SearchSessionService;
|
||||
let mockTaskManager: jest.Mocked<TaskManagerStartContract>;
|
||||
|
||||
const MOCK_STRATEGY = 'ese';
|
||||
|
||||
|
@ -67,19 +66,14 @@ describe('SearchSessionService', () => {
|
|||
describe('Feature disabled', () => {
|
||||
beforeEach(async () => {
|
||||
savedObjectsClient = savedObjectsClientMock.create();
|
||||
elasticsearchClient = elasticsearchServiceMock.createElasticsearchClient();
|
||||
const config: ConfigSchema = {
|
||||
search: {
|
||||
sessions: {
|
||||
enabled: false,
|
||||
pageSize: 10000,
|
||||
notTouchedInProgressTimeout: moment.duration(1, 'm'),
|
||||
notTouchedTimeout: moment.duration(2, 'm'),
|
||||
maxUpdateRetries: MAX_UPDATE_RETRIES,
|
||||
defaultExpiration: moment.duration(7, 'd'),
|
||||
monitoringTaskTimeout: moment.duration(5, 'm'),
|
||||
cleanupInterval: moment.duration(10, 's'),
|
||||
trackingInterval: moment.duration(10, 's'),
|
||||
expireInterval: moment.duration(10, 'm'),
|
||||
management: {} as any,
|
||||
},
|
||||
},
|
||||
|
@ -90,23 +84,14 @@ describe('SearchSessionService', () => {
|
|||
error: jest.fn(),
|
||||
};
|
||||
service = new SearchSessionService(mockLogger, config, '8.0.0');
|
||||
service.setup(coreMock.createSetup(), { taskManager: taskManagerMock.createSetup() });
|
||||
const coreStart = coreMock.createStart();
|
||||
mockTaskManager = taskManagerMock.createStart();
|
||||
service.setup(coreMock.createSetup(), {});
|
||||
await flushPromises();
|
||||
await service.start(coreStart, {
|
||||
taskManager: mockTaskManager,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stop();
|
||||
});
|
||||
|
||||
it('task is cleared, if exists', async () => {
|
||||
expect(mockTaskManager.removeIfExists).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trackId ignores', async () => {
|
||||
await service.trackId({ savedObjectsClient }, mockUser1, { params: {} }, '123', {
|
||||
sessionId: '321',
|
||||
|
@ -148,19 +133,15 @@ describe('SearchSessionService', () => {
|
|||
describe('Feature enabled', () => {
|
||||
beforeEach(async () => {
|
||||
savedObjectsClient = savedObjectsClientMock.create();
|
||||
elasticsearchClient = elasticsearchServiceMock.createElasticsearchClient();
|
||||
const config: ConfigSchema = {
|
||||
search: {
|
||||
sessions: {
|
||||
enabled: true,
|
||||
pageSize: 10000,
|
||||
notTouchedInProgressTimeout: moment.duration(1, 'm'),
|
||||
notTouchedTimeout: moment.duration(2, 'm'),
|
||||
maxUpdateRetries: MAX_UPDATE_RETRIES,
|
||||
defaultExpiration: moment.duration(7, 'd'),
|
||||
trackingInterval: moment.duration(10, 's'),
|
||||
expireInterval: moment.duration(10, 'm'),
|
||||
monitoringTaskTimeout: moment.duration(5, 'm'),
|
||||
cleanupInterval: moment.duration(10, 's'),
|
||||
management: {} as any,
|
||||
},
|
||||
},
|
||||
|
@ -171,24 +152,17 @@ describe('SearchSessionService', () => {
|
|||
error: jest.fn(),
|
||||
};
|
||||
service = new SearchSessionService(mockLogger, config, '8.0.0');
|
||||
service.setup(coreMock.createSetup(), { taskManager: taskManagerMock.createSetup() });
|
||||
service.setup(coreMock.createSetup(), {});
|
||||
const coreStart = coreMock.createStart();
|
||||
mockTaskManager = taskManagerMock.createStart();
|
||||
|
||||
await flushPromises();
|
||||
await service.start(coreStart, {
|
||||
taskManager: mockTaskManager,
|
||||
});
|
||||
await service.start(coreStart, {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stop();
|
||||
});
|
||||
|
||||
it('task is cleared and re-created', async () => {
|
||||
expect(mockTaskManager.removeIfExists).toHaveBeenCalled();
|
||||
expect(mockTaskManager.ensureScheduled).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
it('throws if `name` is not provided', () => {
|
||||
expect(() =>
|
||||
|
@ -198,7 +172,9 @@ describe('SearchSessionService', () => {
|
|||
|
||||
it('throws if `appId` is not provided', () => {
|
||||
expect(
|
||||
service.save({ savedObjectsClient }, mockUser1, sessionId, { name: 'banana' })
|
||||
service.save({ savedObjectsClient }, mockUser1, sessionId, {
|
||||
name: 'banana',
|
||||
})
|
||||
).rejects.toMatchInlineSnapshot(`[Error: AppId is required]`);
|
||||
});
|
||||
|
||||
|
@ -232,8 +208,6 @@ describe('SearchSessionService', () => {
|
|||
expect(type).toBe(SEARCH_SESSION_TYPE);
|
||||
expect(id).toBe(sessionId);
|
||||
expect(callAttributes).not.toHaveProperty('idMapping');
|
||||
expect(callAttributes).toHaveProperty('touched');
|
||||
expect(callAttributes).toHaveProperty('persisted', true);
|
||||
expect(callAttributes).toHaveProperty('name', 'banana');
|
||||
expect(callAttributes).toHaveProperty('appId', 'nanana');
|
||||
expect(callAttributes).toHaveProperty('locatorId', 'panama');
|
||||
|
@ -265,10 +239,8 @@ describe('SearchSessionService', () => {
|
|||
expect(type).toBe(SEARCH_SESSION_TYPE);
|
||||
expect(options?.id).toBe(sessionId);
|
||||
expect(callAttributes).toHaveProperty('idMapping', {});
|
||||
expect(callAttributes).toHaveProperty('touched');
|
||||
expect(callAttributes).toHaveProperty('expires');
|
||||
expect(callAttributes).toHaveProperty('created');
|
||||
expect(callAttributes).toHaveProperty('persisted', true);
|
||||
expect(callAttributes).toHaveProperty('name', 'banana');
|
||||
expect(callAttributes).toHaveProperty('appId', 'nanana');
|
||||
expect(callAttributes).toHaveProperty('locatorId', 'panama');
|
||||
|
@ -343,13 +315,20 @@ describe('SearchSessionService', () => {
|
|||
total: 1,
|
||||
per_page: 1,
|
||||
page: 0,
|
||||
statuses: {
|
||||
[mockSavedObject.id]: { status: SearchSessionStatus.IN_PROGRESS },
|
||||
},
|
||||
};
|
||||
savedObjectsClient.find.mockResolvedValue(mockResponse);
|
||||
|
||||
const options = { page: 0, perPage: 5 };
|
||||
const response = await service.find({ savedObjectsClient }, mockUser1, options);
|
||||
const response = await service.find(
|
||||
{ savedObjectsClient, internalElasticsearchClient: elasticsearchClient },
|
||||
mockUser1,
|
||||
options
|
||||
);
|
||||
|
||||
expect(response).toBe(mockResponse);
|
||||
expect(response).toEqual(mockResponse);
|
||||
const [[findOptions]] = savedObjectsClient.find.mock.calls;
|
||||
expect(findOptions).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
|
@ -424,17 +403,28 @@ describe('SearchSessionService', () => {
|
|||
total: 1,
|
||||
per_page: 1,
|
||||
page: 0,
|
||||
statuses: {
|
||||
[mockSavedObject.id]: { status: SearchSessionStatus.IN_PROGRESS },
|
||||
},
|
||||
};
|
||||
savedObjectsClient.find.mockResolvedValue(mockResponse);
|
||||
|
||||
const options1 = { filter: 'foobar' };
|
||||
const response1 = await service.find({ savedObjectsClient }, mockUser1, options1);
|
||||
const response1 = await service.find(
|
||||
{ savedObjectsClient, internalElasticsearchClient: elasticsearchClient },
|
||||
mockUser1,
|
||||
options1
|
||||
);
|
||||
|
||||
const options2 = { filter: nodeBuilder.is('foo', 'bar') };
|
||||
const response2 = await service.find({ savedObjectsClient }, mockUser1, options2);
|
||||
const response2 = await service.find(
|
||||
{ savedObjectsClient, internalElasticsearchClient: elasticsearchClient },
|
||||
mockUser1,
|
||||
options2
|
||||
);
|
||||
|
||||
expect(response1).toBe(mockResponse);
|
||||
expect(response2).toBe(mockResponse);
|
||||
expect(response1).toEqual(mockResponse);
|
||||
expect(response2).toEqual(mockResponse);
|
||||
|
||||
const [[findOptions1], [findOptions2]] = savedObjectsClient.find.mock.calls;
|
||||
expect(findOptions1).toMatchInlineSnapshot(`
|
||||
|
@ -599,13 +589,20 @@ describe('SearchSessionService', () => {
|
|||
total: 1,
|
||||
per_page: 1,
|
||||
page: 0,
|
||||
statuses: {
|
||||
[mockSavedObject.id]: { status: SearchSessionStatus.IN_PROGRESS },
|
||||
},
|
||||
};
|
||||
savedObjectsClient.find.mockResolvedValue(mockResponse);
|
||||
|
||||
const options = { page: 0, perPage: 5 };
|
||||
const response = await service.find({ savedObjectsClient }, null, options);
|
||||
const response = await service.find(
|
||||
{ savedObjectsClient, internalElasticsearchClient: elasticsearchClient },
|
||||
null,
|
||||
options
|
||||
);
|
||||
|
||||
expect(response).toBe(mockResponse);
|
||||
expect(response).toEqual(mockResponse);
|
||||
const [[findOptions]] = savedObjectsClient.find.mock.calls;
|
||||
expect(findOptions).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
|
@ -642,7 +639,6 @@ describe('SearchSessionService', () => {
|
|||
expect(type).toBe(SEARCH_SESSION_TYPE);
|
||||
expect(id).toBe(sessionId);
|
||||
expect(callAttributes).toHaveProperty('name', attributes.name);
|
||||
expect(callAttributes).toHaveProperty('touched');
|
||||
});
|
||||
|
||||
it('throws if user conflicts', () => {
|
||||
|
@ -675,7 +671,6 @@ describe('SearchSessionService', () => {
|
|||
expect(type).toBe(SEARCH_SESSION_TYPE);
|
||||
expect(id).toBe(sessionId);
|
||||
expect(callAttributes).toHaveProperty('name', 'new_name');
|
||||
expect(callAttributes).toHaveProperty('touched');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -688,8 +683,7 @@ describe('SearchSessionService', () => {
|
|||
|
||||
expect(type).toBe(SEARCH_SESSION_TYPE);
|
||||
expect(id).toBe(sessionId);
|
||||
expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED);
|
||||
expect(callAttributes).toHaveProperty('touched');
|
||||
expect(callAttributes).toHaveProperty('isCanceled', true);
|
||||
});
|
||||
|
||||
it('throws if user conflicts', () => {
|
||||
|
@ -709,8 +703,7 @@ describe('SearchSessionService', () => {
|
|||
|
||||
expect(type).toBe(SEARCH_SESSION_TYPE);
|
||||
expect(id).toBe(sessionId);
|
||||
expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED);
|
||||
expect(callAttributes).toHaveProperty('touched');
|
||||
expect(callAttributes).toHaveProperty('isCanceled', true);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -740,11 +733,9 @@ describe('SearchSessionService', () => {
|
|||
expect(callAttributes).toHaveProperty('idMapping', {
|
||||
[requestHash]: {
|
||||
id: searchId,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
strategy: MOCK_STRATEGY,
|
||||
},
|
||||
});
|
||||
expect(callAttributes).toHaveProperty('touched');
|
||||
});
|
||||
|
||||
it('retries updating the saved object if there was a ES conflict 409', async () => {
|
||||
|
@ -827,15 +818,12 @@ describe('SearchSessionService', () => {
|
|||
expect(callAttributes).toHaveProperty('idMapping', {
|
||||
[requestHash]: {
|
||||
id: searchId,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
strategy: MOCK_STRATEGY,
|
||||
},
|
||||
});
|
||||
expect(callAttributes).toHaveProperty('expires');
|
||||
expect(callAttributes).toHaveProperty('created');
|
||||
expect(callAttributes).toHaveProperty('touched');
|
||||
expect(callAttributes).toHaveProperty('sessionId', sessionId);
|
||||
expect(callAttributes).toHaveProperty('persisted', false);
|
||||
});
|
||||
|
||||
it('retries updating if update returned 404 and then update returned conflict 409 (first create race condition)', async () => {
|
||||
|
@ -939,16 +927,13 @@ describe('SearchSessionService', () => {
|
|||
expect(callAttributes1).toHaveProperty('idMapping', {
|
||||
[requestHash1]: {
|
||||
id: searchId1,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
strategy: MOCK_STRATEGY,
|
||||
},
|
||||
[requestHash2]: {
|
||||
id: searchId2,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
strategy: MOCK_STRATEGY,
|
||||
},
|
||||
});
|
||||
expect(callAttributes1).toHaveProperty('touched');
|
||||
|
||||
const [type2, id2, callAttributes2] = savedObjectsClient.update.mock.calls[1];
|
||||
expect(type2).toBe(SEARCH_SESSION_TYPE);
|
||||
|
@ -956,11 +941,9 @@ describe('SearchSessionService', () => {
|
|||
expect(callAttributes2).toHaveProperty('idMapping', {
|
||||
[requestHash3]: {
|
||||
id: searchId3,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
strategy: MOCK_STRATEGY,
|
||||
},
|
||||
});
|
||||
expect(callAttributes2).toHaveProperty('touched');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -8,67 +8,48 @@
|
|||
|
||||
import { notFound } from '@hapi/boom';
|
||||
import { debounce } from 'lodash';
|
||||
import { nodeBuilder, fromKueryExpression } from '@kbn/es-query';
|
||||
import { fromKueryExpression, nodeBuilder } from '@kbn/es-query';
|
||||
import {
|
||||
CoreSetup,
|
||||
CoreStart,
|
||||
KibanaRequest,
|
||||
SavedObjectsClientContract,
|
||||
Logger,
|
||||
SavedObject,
|
||||
SavedObjectsFindOptions,
|
||||
SavedObjectsClientContract,
|
||||
SavedObjectsErrorHelpers,
|
||||
SavedObjectsFindOptions,
|
||||
ElasticsearchClient,
|
||||
} from '@kbn/core/server';
|
||||
import type { AuthenticatedUser, SecurityPluginSetup } from '@kbn/security-plugin/server';
|
||||
import type {
|
||||
TaskManagerSetupContract,
|
||||
TaskManagerStartContract,
|
||||
} from '@kbn/task-manager-plugin/server';
|
||||
import {
|
||||
ENHANCED_ES_SEARCH_STRATEGY,
|
||||
IKibanaSearchRequest,
|
||||
ISearchOptions,
|
||||
ENHANCED_ES_SEARCH_STRATEGY,
|
||||
SEARCH_SESSION_TYPE,
|
||||
SearchSessionRequestInfo,
|
||||
SearchSessionSavedObjectAttributes,
|
||||
SearchSessionStatus,
|
||||
SearchSessionsFindResponse,
|
||||
SearchSessionStatusResponse,
|
||||
} from '../../../common';
|
||||
import { ISearchSessionService, NoSearchIdInSessionError } from '../..';
|
||||
import { createRequestHash } from './utils';
|
||||
import { ConfigSchema, SearchSessionsConfigSchema } from '../../../config';
|
||||
import {
|
||||
registerSearchSessionsTask,
|
||||
scheduleSearchSessionsTask,
|
||||
unscheduleSearchSessionsTask,
|
||||
} from './setup_task';
|
||||
import { SearchStatus } from './types';
|
||||
import {
|
||||
checkPersistedSessionsProgress,
|
||||
SEARCH_SESSIONS_TASK_ID,
|
||||
SEARCH_SESSIONS_TASK_TYPE,
|
||||
} from './check_persisted_sessions';
|
||||
import {
|
||||
SEARCH_SESSIONS_CLEANUP_TASK_TYPE,
|
||||
checkNonPersistedSessions,
|
||||
SEARCH_SESSIONS_CLEANUP_TASK_ID,
|
||||
} from './check_non_persisted_sessions';
|
||||
import {
|
||||
SEARCH_SESSIONS_EXPIRE_TASK_TYPE,
|
||||
SEARCH_SESSIONS_EXPIRE_TASK_ID,
|
||||
checkPersistedCompletedSessionExpiration,
|
||||
} from './expire_persisted_sessions';
|
||||
import { getSessionStatus } from './get_session_status';
|
||||
|
||||
export interface SearchSessionDependencies {
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
}
|
||||
|
||||
export interface SearchSessionStatusDependencies extends SearchSessionDependencies {
|
||||
internalElasticsearchClient: ElasticsearchClient;
|
||||
}
|
||||
|
||||
interface SetupDependencies {
|
||||
taskManager: TaskManagerSetupContract;
|
||||
security?: SecurityPluginSetup;
|
||||
}
|
||||
|
||||
interface StartDependencies {
|
||||
taskManager: TaskManagerStartContract;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface StartDependencies {}
|
||||
|
||||
const DEBOUNCE_UPDATE_OR_CREATE_WAIT = 1000;
|
||||
const DEBOUNCE_UPDATE_OR_CREATE_MAX_WAIT = 5000;
|
||||
|
@ -101,83 +82,17 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
|
||||
public setup(core: CoreSetup, deps: SetupDependencies) {
|
||||
this.security = deps.security;
|
||||
const taskDeps = {
|
||||
config: this.config,
|
||||
taskManager: deps.taskManager,
|
||||
logger: this.logger,
|
||||
};
|
||||
|
||||
registerSearchSessionsTask(
|
||||
core,
|
||||
taskDeps,
|
||||
SEARCH_SESSIONS_TASK_TYPE,
|
||||
'persisted session progress',
|
||||
checkPersistedSessionsProgress
|
||||
);
|
||||
|
||||
registerSearchSessionsTask(
|
||||
core,
|
||||
taskDeps,
|
||||
SEARCH_SESSIONS_CLEANUP_TASK_TYPE,
|
||||
'non persisted session cleanup',
|
||||
checkNonPersistedSessions
|
||||
);
|
||||
|
||||
registerSearchSessionsTask(
|
||||
core,
|
||||
taskDeps,
|
||||
SEARCH_SESSIONS_EXPIRE_TASK_TYPE,
|
||||
'complete session expiration',
|
||||
checkPersistedCompletedSessionExpiration
|
||||
);
|
||||
|
||||
this.setupCompleted = true;
|
||||
}
|
||||
|
||||
public async start(core: CoreStart, deps: StartDependencies) {
|
||||
public start(core: CoreStart, deps: StartDependencies) {
|
||||
if (!this.setupCompleted)
|
||||
throw new Error('SearchSessionService setup() must be called before start()');
|
||||
|
||||
return this.setupMonitoring(core, deps);
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
|
||||
private setupMonitoring = async (core: CoreStart, deps: StartDependencies) => {
|
||||
const taskDeps = {
|
||||
config: this.config,
|
||||
taskManager: deps.taskManager,
|
||||
logger: this.logger,
|
||||
};
|
||||
|
||||
if (this.sessionConfig.enabled) {
|
||||
scheduleSearchSessionsTask(
|
||||
taskDeps,
|
||||
SEARCH_SESSIONS_TASK_ID,
|
||||
SEARCH_SESSIONS_TASK_TYPE,
|
||||
this.sessionConfig.trackingInterval
|
||||
);
|
||||
|
||||
scheduleSearchSessionsTask(
|
||||
taskDeps,
|
||||
SEARCH_SESSIONS_CLEANUP_TASK_ID,
|
||||
SEARCH_SESSIONS_CLEANUP_TASK_TYPE,
|
||||
this.sessionConfig.cleanupInterval
|
||||
);
|
||||
|
||||
scheduleSearchSessionsTask(
|
||||
taskDeps,
|
||||
SEARCH_SESSIONS_EXPIRE_TASK_ID,
|
||||
SEARCH_SESSIONS_EXPIRE_TASK_TYPE,
|
||||
this.sessionConfig.expireInterval
|
||||
);
|
||||
} else {
|
||||
unscheduleSearchSessionsTask(taskDeps, SEARCH_SESSIONS_TASK_ID);
|
||||
unscheduleSearchSessionsTask(taskDeps, SEARCH_SESSIONS_CLEANUP_TASK_ID);
|
||||
unscheduleSearchSessionsTask(taskDeps, SEARCH_SESSIONS_EXPIRE_TASK_ID);
|
||||
}
|
||||
};
|
||||
|
||||
private processUpdateOrCreateBatchQueue = debounce(
|
||||
() => {
|
||||
const queue = [...this.updateOrCreateBatchQueue];
|
||||
|
@ -304,7 +219,6 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
locatorId,
|
||||
initialState,
|
||||
restoreState,
|
||||
persisted: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -324,14 +238,11 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
SEARCH_SESSION_TYPE,
|
||||
{
|
||||
sessionId,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
expires: new Date(
|
||||
Date.now() + this.sessionConfig.defaultExpiration.asMilliseconds()
|
||||
).toISOString(),
|
||||
created: new Date().toISOString(),
|
||||
touched: new Date().toISOString(),
|
||||
idMapping: {},
|
||||
persisted: false,
|
||||
version: this.version,
|
||||
realmType,
|
||||
realmName,
|
||||
|
@ -353,14 +264,15 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
sessionId
|
||||
);
|
||||
this.throwOnUserConflict(user, session);
|
||||
|
||||
return session;
|
||||
};
|
||||
|
||||
public find = (
|
||||
{ savedObjectsClient }: SearchSessionDependencies,
|
||||
public find = async (
|
||||
{ savedObjectsClient, internalElasticsearchClient }: SearchSessionStatusDependencies,
|
||||
user: AuthenticatedUser | null,
|
||||
options: Omit<SavedObjectsFindOptions, 'type'>
|
||||
) => {
|
||||
): Promise<SearchSessionsFindResponse> => {
|
||||
const userFilters =
|
||||
user === null
|
||||
? []
|
||||
|
@ -378,11 +290,31 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
const filterKueryNode =
|
||||
typeof options.filter === 'string' ? fromKueryExpression(options.filter) : options.filter;
|
||||
const filter = nodeBuilder.and(userFilters.concat(filterKueryNode ?? []));
|
||||
return savedObjectsClient.find<SearchSessionSavedObjectAttributes>({
|
||||
const findResponse = await savedObjectsClient.find<SearchSessionSavedObjectAttributes>({
|
||||
...options,
|
||||
filter,
|
||||
type: SEARCH_SESSION_TYPE,
|
||||
});
|
||||
|
||||
const sessionStatuses = await Promise.all(
|
||||
findResponse.saved_objects.map(async (so) => {
|
||||
const sessionStatus = await getSessionStatus(
|
||||
{ internalClient: internalElasticsearchClient },
|
||||
so.attributes,
|
||||
this.sessionConfig
|
||||
);
|
||||
|
||||
return sessionStatus;
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...findResponse,
|
||||
statuses: sessionStatuses.reduce((res, status, index) => {
|
||||
res[findResponse.saved_objects[index].id] = { status };
|
||||
return res;
|
||||
}, {} as Record<string, SearchSessionStatusResponse>),
|
||||
};
|
||||
};
|
||||
|
||||
public update = async (
|
||||
|
@ -399,7 +331,6 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
sessionId,
|
||||
{
|
||||
...attributes,
|
||||
touched: new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@ -419,9 +350,9 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
user: AuthenticatedUser | null,
|
||||
sessionId: string
|
||||
) => {
|
||||
this.logger.debug(`delete | ${sessionId}`);
|
||||
this.logger.debug(`cancel | ${sessionId}`);
|
||||
return this.update(deps, user, sessionId, {
|
||||
status: SearchSessionStatus.CANCELLED,
|
||||
isCanceled: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -445,8 +376,9 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
user: AuthenticatedUser | null,
|
||||
searchRequest: IKibanaSearchRequest,
|
||||
searchId: string,
|
||||
{ sessionId, strategy = ENHANCED_ES_SEARCH_STRATEGY }: ISearchOptions
|
||||
options: ISearchOptions
|
||||
) => {
|
||||
const { sessionId, strategy = ENHANCED_ES_SEARCH_STRATEGY } = options;
|
||||
if (!this.sessionConfig.enabled || !sessionId || !searchId) return;
|
||||
this.logger.debug(`trackId | ${sessionId} | ${searchId}`);
|
||||
|
||||
|
@ -454,10 +386,9 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
|
||||
if (searchRequest.params) {
|
||||
const requestHash = createRequestHash(searchRequest.params);
|
||||
const searchInfo = {
|
||||
const searchInfo: SearchSessionRequestInfo = {
|
||||
id: searchId,
|
||||
strategy,
|
||||
status: SearchStatus.IN_PROGRESS,
|
||||
};
|
||||
idMapping = { [requestHash]: searchInfo };
|
||||
}
|
||||
|
@ -471,6 +402,7 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
sessionId: string
|
||||
) {
|
||||
const searchSession = await this.get(deps, user, sessionId);
|
||||
|
||||
const searchIdMapping = new Map<string, string>();
|
||||
Object.values(searchSession.attributes.idMapping).forEach((requestInfo) => {
|
||||
searchIdMapping.set(requestInfo.id, requestInfo.strategy);
|
||||
|
@ -478,6 +410,23 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
return searchIdMapping;
|
||||
}
|
||||
|
||||
public async status(
|
||||
deps: SearchSessionStatusDependencies,
|
||||
user: AuthenticatedUser | null,
|
||||
sessionId: string
|
||||
): Promise<SearchSessionStatusResponse> {
|
||||
this.logger.debug(`status | ${sessionId}`);
|
||||
const session = await this.get(deps, user, sessionId);
|
||||
|
||||
const sessionStatus = await getSessionStatus(
|
||||
{ internalClient: deps.internalElasticsearchClient },
|
||||
session.attributes,
|
||||
this.sessionConfig
|
||||
);
|
||||
|
||||
return { status: sessionStatus };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an existing search ID that matches the given request in the given session so that the
|
||||
* request can continue rather than restart.
|
||||
|
@ -510,13 +459,15 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
return session.attributes.idMapping[requestHash].id;
|
||||
};
|
||||
|
||||
public asScopedProvider = ({ savedObjects }: CoreStart) => {
|
||||
public asScopedProvider = ({ savedObjects, elasticsearch }: CoreStart) => {
|
||||
return (request: KibanaRequest) => {
|
||||
const user = this.security?.authc.getCurrentUser(request) ?? null;
|
||||
const savedObjectsClient = savedObjects.getScopedClient(request, {
|
||||
includedHiddenTypes: [SEARCH_SESSION_TYPE],
|
||||
});
|
||||
const deps = { savedObjectsClient };
|
||||
|
||||
const internalElasticsearchClient = elasticsearch.client.asScoped(request).asInternalUser;
|
||||
const deps = { savedObjectsClient, internalElasticsearchClient };
|
||||
return {
|
||||
getId: this.getId.bind(this, deps, user),
|
||||
trackId: this.trackId.bind(this, deps, user),
|
||||
|
@ -528,6 +479,7 @@ export class SearchSessionService implements ISearchSessionService {
|
|||
extend: this.extend.bind(this, deps, user),
|
||||
cancel: this.cancel.bind(this, deps, user),
|
||||
delete: this.delete.bind(this, deps, user),
|
||||
status: this.status.bind(this, deps, user),
|
||||
getConfig: () => this.config.search.sessions,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,121 +0,0 @@
|
|||
/*
|
||||
* 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 { Duration } from 'moment';
|
||||
import { filter, takeUntil } from 'rxjs/operators';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import type { RunContext, TaskRunCreatorFunction } from '@kbn/task-manager-plugin/server';
|
||||
import { CoreSetup, SavedObjectsClient } from '@kbn/core/server';
|
||||
import { SEARCH_SESSION_TYPE } from '../../../common';
|
||||
import {
|
||||
SearchSessionTaskSetupDeps,
|
||||
SearchSessionTaskStartDeps,
|
||||
SearchSessionTaskFn,
|
||||
} from './types';
|
||||
|
||||
export function searchSessionTaskRunner(
|
||||
core: CoreSetup,
|
||||
deps: SearchSessionTaskSetupDeps,
|
||||
title: string,
|
||||
checkFn: SearchSessionTaskFn
|
||||
): TaskRunCreatorFunction {
|
||||
const { logger, config } = deps;
|
||||
return ({ taskInstance }: RunContext) => {
|
||||
const aborted$ = new BehaviorSubject<boolean>(false);
|
||||
return {
|
||||
async run() {
|
||||
try {
|
||||
const sessionConfig = config.search.sessions;
|
||||
const [coreStart] = await core.getStartServices();
|
||||
if (!sessionConfig.enabled) {
|
||||
logger.debug(`Search sessions are disabled. Skipping task ${title}.`);
|
||||
return;
|
||||
}
|
||||
if (aborted$.getValue()) return;
|
||||
|
||||
const internalRepo = coreStart.savedObjects.createInternalRepository([
|
||||
SEARCH_SESSION_TYPE,
|
||||
]);
|
||||
const internalSavedObjectsClient = new SavedObjectsClient(internalRepo);
|
||||
await checkFn(
|
||||
{
|
||||
logger,
|
||||
client: coreStart.elasticsearch.client.asInternalUser,
|
||||
savedObjectsClient: internalSavedObjectsClient,
|
||||
},
|
||||
sessionConfig
|
||||
)
|
||||
.pipe(takeUntil(aborted$.pipe(filter((aborted) => aborted))))
|
||||
.toPromise();
|
||||
|
||||
return {
|
||||
state: {},
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred. Skipping task ${title}.`);
|
||||
}
|
||||
},
|
||||
cancel: async () => {
|
||||
aborted$.next(true);
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function registerSearchSessionsTask(
|
||||
core: CoreSetup,
|
||||
deps: SearchSessionTaskSetupDeps,
|
||||
taskType: string,
|
||||
title: string,
|
||||
checkFn: SearchSessionTaskFn
|
||||
) {
|
||||
deps.taskManager.registerTaskDefinitions({
|
||||
[taskType]: {
|
||||
title,
|
||||
createTaskRunner: searchSessionTaskRunner(core, deps, title, checkFn),
|
||||
timeout: `${deps.config.search.sessions.monitoringTaskTimeout.asSeconds()}s`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function unscheduleSearchSessionsTask(
|
||||
{ taskManager, logger }: SearchSessionTaskStartDeps,
|
||||
taskId: string
|
||||
) {
|
||||
try {
|
||||
await taskManager.removeIfExists(taskId);
|
||||
logger.debug(`${taskId} cleared`);
|
||||
} catch (e) {
|
||||
logger.error(`${taskId} Error clearing task ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleSearchSessionsTask(
|
||||
{ taskManager, logger }: SearchSessionTaskStartDeps,
|
||||
taskId: string,
|
||||
taskType: string,
|
||||
interval: Duration
|
||||
) {
|
||||
await taskManager.removeIfExists(taskId);
|
||||
|
||||
try {
|
||||
await taskManager.ensureScheduled({
|
||||
id: taskId,
|
||||
taskType,
|
||||
schedule: {
|
||||
interval: `${interval.asSeconds()}s`,
|
||||
},
|
||||
state: {},
|
||||
params: {},
|
||||
});
|
||||
|
||||
logger.debug(`${taskId} scheduled to run`);
|
||||
} catch (e) {
|
||||
logger.error(`${taskId} Error scheduling task ${e.message}`);
|
||||
}
|
||||
}
|
|
@ -6,26 +6,23 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
CoreStart,
|
||||
KibanaRequest,
|
||||
SavedObject,
|
||||
SavedObjectsFindOptions,
|
||||
SavedObjectsFindResponse,
|
||||
SavedObjectsUpdateResponse,
|
||||
ElasticsearchClient,
|
||||
Logger,
|
||||
SavedObjectsClientContract,
|
||||
} from '@kbn/core/server';
|
||||
import type {
|
||||
TaskManagerSetupContract,
|
||||
TaskManagerStartContract,
|
||||
} from '@kbn/task-manager-plugin/server';
|
||||
import { KueryNode } from '@kbn/es-query';
|
||||
import { SearchSessionSavedObjectAttributes } from '../../../common';
|
||||
import { IKibanaSearchRequest, ISearchOptions } from '../../../common/search';
|
||||
import { SearchSessionsConfigSchema, ConfigSchema } from '../../../config';
|
||||
import {
|
||||
IKibanaSearchRequest,
|
||||
ISearchOptions,
|
||||
SearchSessionsFindResponse,
|
||||
SearchSessionSavedObjectAttributes,
|
||||
SearchSessionStatusResponse,
|
||||
} from '../../../common/search';
|
||||
import { SearchSessionsConfigSchema } from '../../../config';
|
||||
|
||||
export { SearchStatus } from '../../../common/search';
|
||||
|
||||
export interface IScopedSearchSessionsClient {
|
||||
getId: (request: IKibanaSearchRequest, options: ISearchOptions) => Promise<string>;
|
||||
|
@ -40,9 +37,7 @@ export interface IScopedSearchSessionsClient {
|
|||
attributes: Partial<SearchSessionSavedObjectAttributes>
|
||||
) => Promise<SavedObject<SearchSessionSavedObjectAttributes> | undefined>;
|
||||
get: (sessionId: string) => Promise<SavedObject<SearchSessionSavedObjectAttributes>>;
|
||||
find: (
|
||||
options: Omit<SavedObjectsFindOptions, 'type'>
|
||||
) => Promise<SavedObjectsFindResponse<SearchSessionSavedObjectAttributes>>;
|
||||
find: (options: Omit<SavedObjectsFindOptions, 'type'>) => Promise<SearchSessionsFindResponse>;
|
||||
update: (
|
||||
sessionId: string,
|
||||
attributes: Partial<SearchSessionSavedObjectAttributes>
|
||||
|
@ -53,50 +48,10 @@ export interface IScopedSearchSessionsClient {
|
|||
sessionId: string,
|
||||
expires: Date
|
||||
) => Promise<SavedObjectsUpdateResponse<SearchSessionSavedObjectAttributes>>;
|
||||
status: (sessionId: string) => Promise<SearchSessionStatusResponse>;
|
||||
getConfig: () => SearchSessionsConfigSchema | null;
|
||||
}
|
||||
|
||||
export interface ISearchSessionService {
|
||||
asScopedProvider: (core: CoreStart) => (request: KibanaRequest) => IScopedSearchSessionsClient;
|
||||
}
|
||||
|
||||
export enum SearchStatus {
|
||||
IN_PROGRESS = 'in_progress',
|
||||
ERROR = 'error',
|
||||
COMPLETE = 'complete',
|
||||
}
|
||||
|
||||
export interface CheckSearchSessionsDeps {
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
client: ElasticsearchClient;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export interface SearchSessionTaskSetupDeps {
|
||||
taskManager: TaskManagerSetupContract;
|
||||
logger: Logger;
|
||||
config: ConfigSchema;
|
||||
}
|
||||
|
||||
export interface SearchSessionTaskStartDeps {
|
||||
taskManager: TaskManagerStartContract;
|
||||
logger: Logger;
|
||||
config: ConfigSchema;
|
||||
}
|
||||
|
||||
export type SearchSessionTaskFn = (
|
||||
deps: CheckSearchSessionsDeps,
|
||||
config: SearchSessionsConfigSchema
|
||||
) => Observable<void>;
|
||||
|
||||
export type SearchSessionsResponse = SavedObjectsFindResponse<
|
||||
SearchSessionSavedObjectAttributes,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export type CheckSearchSessionsFn = (
|
||||
deps: CheckSearchSessionsDeps,
|
||||
config: SearchSessionsConfigSchema,
|
||||
filter: KueryNode,
|
||||
page: number
|
||||
) => Observable<SearchSessionsResponse>;
|
||||
|
|
|
@ -1,327 +0,0 @@
|
|||
/*
|
||||
* 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 { bulkUpdateSessions, updateSessionStatus } from './update_session_status';
|
||||
import { SearchSessionStatus, SearchSessionSavedObjectAttributes } from '../../../common';
|
||||
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import { SearchStatus } from './types';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
SavedObjectsBulkUpdateObject,
|
||||
SavedObjectsClientContract,
|
||||
SavedObjectsFindResult,
|
||||
} from '@kbn/core/server';
|
||||
|
||||
describe('bulkUpdateSessions', () => {
|
||||
let mockClient: any;
|
||||
const mockConfig: any = {};
|
||||
let savedObjectsClient: jest.Mocked<SavedObjectsClientContract>;
|
||||
const mockLogger: any = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
savedObjectsClient = savedObjectsClientMock.create();
|
||||
mockClient = {
|
||||
asyncSearch: {
|
||||
status: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
eql: {
|
||||
status: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('updateSessionStatus', () => {
|
||||
test('updates expired session', async () => {
|
||||
const so: SavedObjectsFindResult<SearchSessionSavedObjectAttributes> = {
|
||||
id: '123',
|
||||
attributes: {
|
||||
persisted: false,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
expires: moment().subtract(moment.duration(5, 'd')),
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.IN_PROGRESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const updated = await updateSessionStatus(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
mockConfig,
|
||||
so
|
||||
);
|
||||
|
||||
expect(updated).toBeTruthy();
|
||||
expect(so.attributes.status).toBe(SearchSessionStatus.EXPIRED);
|
||||
});
|
||||
|
||||
test('does nothing if the search is still running', async () => {
|
||||
const so = {
|
||||
id: '123',
|
||||
attributes: {
|
||||
persisted: false,
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
created: moment().subtract(moment.duration(3, 'm')),
|
||||
touched: moment().subtract(moment.duration(10, 's')),
|
||||
expires: moment().add(moment.duration(5, 'd')),
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.IN_PROGRESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
mockClient.asyncSearch.status.mockResolvedValue({
|
||||
body: {
|
||||
is_partial: true,
|
||||
is_running: true,
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await updateSessionStatus(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
mockConfig,
|
||||
so
|
||||
);
|
||||
|
||||
expect(updated).toBeFalsy();
|
||||
expect(so.attributes.status).toBe(SearchSessionStatus.IN_PROGRESS);
|
||||
});
|
||||
|
||||
test("doesn't re-check completed or errored searches", async () => {
|
||||
const so = {
|
||||
id: '123',
|
||||
attributes: {
|
||||
expires: moment().add(moment.duration(5, 'd')),
|
||||
status: SearchSessionStatus.ERROR,
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.COMPLETE,
|
||||
},
|
||||
'another-search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.ERROR,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const updated = await updateSessionStatus(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
mockConfig,
|
||||
so
|
||||
);
|
||||
|
||||
expect(updated).toBeFalsy();
|
||||
expect(mockClient.asyncSearch.status).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('updates to complete if the search is done', async () => {
|
||||
savedObjectsClient.bulkUpdate = jest.fn();
|
||||
const so = {
|
||||
attributes: {
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
touched: '123',
|
||||
expires: moment().add(moment.duration(5, 'd')),
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.IN_PROGRESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
mockClient.asyncSearch.status.mockResolvedValue({
|
||||
body: {
|
||||
is_partial: false,
|
||||
is_running: false,
|
||||
completion_status: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await updateSessionStatus(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
mockConfig,
|
||||
so
|
||||
);
|
||||
|
||||
expect(updated).toBeTruthy();
|
||||
|
||||
expect(mockClient.asyncSearch.status).toBeCalledWith({ id: 'search-id' }, { meta: true });
|
||||
expect(so.attributes.status).toBe(SearchSessionStatus.COMPLETE);
|
||||
expect(so.attributes.status).toBe(SearchSessionStatus.COMPLETE);
|
||||
expect(so.attributes.touched).not.toBe('123');
|
||||
expect(so.attributes.completed).not.toBeUndefined();
|
||||
expect(so.attributes.idMapping['search-hash'].status).toBe(SearchStatus.COMPLETE);
|
||||
expect(so.attributes.idMapping['search-hash'].error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('updates to error if the search is errored', async () => {
|
||||
savedObjectsClient.bulkUpdate = jest.fn();
|
||||
const so = {
|
||||
attributes: {
|
||||
expires: moment().add(moment.duration(5, 'd')),
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.IN_PROGRESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
mockClient.asyncSearch.status.mockResolvedValue({
|
||||
body: {
|
||||
is_partial: false,
|
||||
is_running: false,
|
||||
completion_status: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await updateSessionStatus(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
mockConfig,
|
||||
so
|
||||
);
|
||||
|
||||
expect(updated).toBeTruthy();
|
||||
expect(so.attributes.status).toBe(SearchSessionStatus.ERROR);
|
||||
expect(so.attributes.touched).not.toBe('123');
|
||||
expect(so.attributes.idMapping['search-hash'].status).toBe(SearchStatus.ERROR);
|
||||
expect(so.attributes.idMapping['search-hash'].error).toBe(
|
||||
'Search completed with a 500 status'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUpdateSessions', () => {
|
||||
test('does nothing if there are no open sessions', async () => {
|
||||
await bulkUpdateSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.bulkUpdate).not.toBeCalled();
|
||||
expect(savedObjectsClient.delete).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('updates in space', async () => {
|
||||
const so = {
|
||||
namespaces: ['awesome'],
|
||||
attributes: {
|
||||
expires: moment().add(moment.duration(5, 'd')),
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
touched: '123',
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.IN_PROGRESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
savedObjectsClient.bulkUpdate = jest.fn().mockResolvedValue({
|
||||
saved_objects: [so],
|
||||
});
|
||||
|
||||
await bulkUpdateSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
[so]
|
||||
);
|
||||
|
||||
const [updateInput] = savedObjectsClient.bulkUpdate.mock.calls[0];
|
||||
const updatedAttributes = updateInput[0] as SavedObjectsBulkUpdateObject;
|
||||
expect(updatedAttributes.namespace).toBe('awesome');
|
||||
});
|
||||
|
||||
test('logs failures', async () => {
|
||||
const so = {
|
||||
namespaces: ['awesome'],
|
||||
attributes: {
|
||||
expires: moment().add(moment.duration(5, 'd')),
|
||||
status: SearchSessionStatus.IN_PROGRESS,
|
||||
touched: '123',
|
||||
idMapping: {
|
||||
'search-hash': {
|
||||
id: 'search-id',
|
||||
strategy: 'cool',
|
||||
status: SearchStatus.IN_PROGRESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
savedObjectsClient.bulkUpdate = jest.fn().mockResolvedValue({
|
||||
saved_objects: [
|
||||
{
|
||||
error: 'nope',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await bulkUpdateSessions(
|
||||
{
|
||||
savedObjectsClient,
|
||||
client: mockClient,
|
||||
logger: mockLogger,
|
||||
},
|
||||
[so]
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.bulkUpdate).toBeCalledTimes(1);
|
||||
expect(mockLogger.error).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,132 +0,0 @@
|
|||
/*
|
||||
* 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 { SavedObjectsFindResult, SavedObjectsUpdateResponse } from '@kbn/core/server';
|
||||
import { SearchSessionsConfigSchema } from '../../../config';
|
||||
import {
|
||||
SearchSessionRequestInfo,
|
||||
SearchSessionSavedObjectAttributes,
|
||||
SearchSessionStatus,
|
||||
} from '../../../common';
|
||||
import { getSearchStatus } from './get_search_status';
|
||||
import { getSessionStatus } from './get_session_status';
|
||||
import { CheckSearchSessionsDeps, SearchSessionsResponse, SearchStatus } from './types';
|
||||
import { isSearchSessionExpired } from './utils';
|
||||
|
||||
export async function updateSessionStatus(
|
||||
{ logger, client }: CheckSearchSessionsDeps,
|
||||
config: SearchSessionsConfigSchema,
|
||||
session: SavedObjectsFindResult<SearchSessionSavedObjectAttributes>
|
||||
) {
|
||||
let sessionUpdated = false;
|
||||
const isExpired = isSearchSessionExpired(session);
|
||||
|
||||
if (!isExpired) {
|
||||
// Check statuses of all running searches
|
||||
await Promise.all(
|
||||
Object.keys(session.attributes.idMapping).map(async (searchKey: string) => {
|
||||
const updateSearchRequest = (
|
||||
currentStatus: Pick<SearchSessionRequestInfo, 'status' | 'error'>
|
||||
) => {
|
||||
sessionUpdated = true;
|
||||
session.attributes.idMapping[searchKey] = {
|
||||
...session.attributes.idMapping[searchKey],
|
||||
...currentStatus,
|
||||
};
|
||||
};
|
||||
|
||||
const searchInfo = session.attributes.idMapping[searchKey];
|
||||
if (searchInfo.status === SearchStatus.IN_PROGRESS) {
|
||||
try {
|
||||
const currentStatus = await getSearchStatus(client, searchInfo.id);
|
||||
|
||||
if (currentStatus.status !== searchInfo.status) {
|
||||
logger.debug(`search ${searchInfo.id} | status changed to ${currentStatus.status}`);
|
||||
updateSearchRequest(currentStatus);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
updateSearchRequest({
|
||||
status: SearchStatus.ERROR,
|
||||
error: e.message || e.meta.error?.caused_by?.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// And only then derive the session's status
|
||||
const sessionStatus = isExpired
|
||||
? SearchSessionStatus.EXPIRED
|
||||
: getSessionStatus(session.attributes, config);
|
||||
if (sessionStatus !== session.attributes.status) {
|
||||
const now = new Date().toISOString();
|
||||
session.attributes.status = sessionStatus;
|
||||
session.attributes.touched = now;
|
||||
if (sessionStatus === SearchSessionStatus.COMPLETE) {
|
||||
session.attributes.completed = now;
|
||||
} else if (session.attributes.completed) {
|
||||
session.attributes.completed = null;
|
||||
}
|
||||
sessionUpdated = true;
|
||||
}
|
||||
|
||||
return sessionUpdated;
|
||||
}
|
||||
|
||||
export async function getAllSessionsStatusUpdates(
|
||||
deps: CheckSearchSessionsDeps,
|
||||
config: SearchSessionsConfigSchema,
|
||||
searchSessions: SearchSessionsResponse
|
||||
) {
|
||||
const updatedSessions = new Array<SavedObjectsFindResult<SearchSessionSavedObjectAttributes>>();
|
||||
|
||||
await Promise.all(
|
||||
searchSessions.saved_objects.map(async (session) => {
|
||||
const updated = await updateSessionStatus(deps, config, session);
|
||||
|
||||
if (updated) {
|
||||
updatedSessions.push(session);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return updatedSessions;
|
||||
}
|
||||
|
||||
export async function bulkUpdateSessions(
|
||||
{ logger, savedObjectsClient }: CheckSearchSessionsDeps,
|
||||
updatedSessions: Array<SavedObjectsFindResult<SearchSessionSavedObjectAttributes>>
|
||||
) {
|
||||
if (updatedSessions.length) {
|
||||
// If there's an error, we'll try again in the next iteration, so there's no need to check the output.
|
||||
const updatedResponse = await savedObjectsClient.bulkUpdate<SearchSessionSavedObjectAttributes>(
|
||||
updatedSessions.map((session) => ({
|
||||
...session,
|
||||
namespace: session.namespaces?.[0],
|
||||
}))
|
||||
);
|
||||
|
||||
const success: Array<SavedObjectsUpdateResponse<SearchSessionSavedObjectAttributes>> = [];
|
||||
const fail: Array<SavedObjectsUpdateResponse<SearchSessionSavedObjectAttributes>> = [];
|
||||
|
||||
updatedResponse.saved_objects.forEach((savedObjectResponse) => {
|
||||
if ('error' in savedObjectResponse) {
|
||||
fail.push(savedObjectResponse);
|
||||
logger.error(
|
||||
`Error while updating search session ${savedObjectResponse?.id}: ${savedObjectResponse.error?.message}`
|
||||
);
|
||||
} else {
|
||||
success.push(savedObjectResponse);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`Updating search sessions: success: ${success.length}, fail: ${fail.length}`);
|
||||
}
|
||||
}
|
|
@ -21,7 +21,7 @@ const getMockSearchSessionsConfig = ({
|
|||
|
||||
describe('request utils', () => {
|
||||
describe('getCommonDefaultAsyncSubmitParams', () => {
|
||||
test('Uses `keep_alive` from default params if no `sessionId` is provided', async () => {
|
||||
test('Uses short `keep_alive` if no `sessionId` is provided', async () => {
|
||||
const mockConfig = getMockSearchSessionsConfig({
|
||||
defaultExpiration: moment.duration(3, 'd'),
|
||||
});
|
||||
|
@ -29,13 +29,24 @@ describe('request utils', () => {
|
|||
expect(params).toHaveProperty('keep_alive', '1m');
|
||||
});
|
||||
|
||||
test('Uses `keep_alive` from config if enabled', async () => {
|
||||
test('Uses short `keep_alive` if sessions enabled but no yet saved', async () => {
|
||||
const mockConfig = getMockSearchSessionsConfig({
|
||||
defaultExpiration: moment.duration(3, 'd'),
|
||||
});
|
||||
const params = getCommonDefaultAsyncSubmitParams(mockConfig, {
|
||||
sessionId: 'foo',
|
||||
});
|
||||
expect(params).toHaveProperty('keep_alive', '1m');
|
||||
});
|
||||
|
||||
test('Uses `keep_alive` from config if sessions enabled and session is saved', async () => {
|
||||
const mockConfig = getMockSearchSessionsConfig({
|
||||
defaultExpiration: moment.duration(3, 'd'),
|
||||
});
|
||||
const params = getCommonDefaultAsyncSubmitParams(mockConfig, {
|
||||
sessionId: 'foo',
|
||||
isStored: true,
|
||||
});
|
||||
expect(params).toHaveProperty('keep_alive', '259200000ms');
|
||||
});
|
||||
|
||||
|
@ -89,12 +100,51 @@ describe('request utils', () => {
|
|||
expect(params).toHaveProperty('keep_alive', '1m');
|
||||
});
|
||||
|
||||
test('Has no `keep_alive` if `sessionId` is provided', async () => {
|
||||
test('Has short `keep_alive` if `sessionId` is provided', async () => {
|
||||
const mockConfig = getMockSearchSessionsConfig({
|
||||
defaultExpiration: moment.duration(3, 'd'),
|
||||
enabled: true,
|
||||
});
|
||||
const params = getCommonDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' });
|
||||
expect(params).toHaveProperty('keep_alive', '1m');
|
||||
});
|
||||
|
||||
test('Has `keep_alive` from config if `sessionId` is provided and session is stored', async () => {
|
||||
const mockConfig = getMockSearchSessionsConfig({
|
||||
defaultExpiration: moment.duration(3, 'd'),
|
||||
enabled: true,
|
||||
});
|
||||
const params = getCommonDefaultAsyncGetParams(mockConfig, {
|
||||
sessionId: 'foo',
|
||||
isStored: true,
|
||||
});
|
||||
expect(params).toHaveProperty('keep_alive', '259200000ms');
|
||||
});
|
||||
|
||||
test("Don't extend keepAlive if search has already been extended", async () => {
|
||||
const mockConfig = getMockSearchSessionsConfig({
|
||||
defaultExpiration: moment.duration(3, 'd'),
|
||||
enabled: true,
|
||||
});
|
||||
const params = getCommonDefaultAsyncGetParams(mockConfig, {
|
||||
sessionId: 'foo',
|
||||
isStored: true,
|
||||
isSearchStored: true,
|
||||
});
|
||||
expect(params).not.toHaveProperty('keep_alive');
|
||||
});
|
||||
|
||||
test("Don't extend keepAlive if search is being restored", async () => {
|
||||
const mockConfig = getMockSearchSessionsConfig({
|
||||
defaultExpiration: moment.duration(3, 'd'),
|
||||
enabled: true,
|
||||
});
|
||||
const params = getCommonDefaultAsyncGetParams(mockConfig, {
|
||||
sessionId: 'foo',
|
||||
isStored: true,
|
||||
isSearchStored: false,
|
||||
isRestore: true,
|
||||
});
|
||||
expect(params).not.toHaveProperty('keep_alive');
|
||||
});
|
||||
|
||||
|
|
|
@ -25,9 +25,10 @@ export function getCommonDefaultAsyncSubmitParams(
|
|||
> {
|
||||
const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId;
|
||||
|
||||
const keepAlive = useSearchSessions
|
||||
? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms`
|
||||
: '1m';
|
||||
const keepAlive =
|
||||
useSearchSessions && options.isStored
|
||||
? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms`
|
||||
: '1m';
|
||||
|
||||
return {
|
||||
// Wait up to 100ms for the response to return
|
||||
|
@ -51,9 +52,13 @@ export function getCommonDefaultAsyncGetParams(
|
|||
return {
|
||||
// Wait up to 100ms for the response to return
|
||||
wait_for_completion_timeout: '100ms',
|
||||
...(useSearchSessions
|
||||
? // Don't change the expiration of search requests that are tracked in a search session
|
||||
undefined
|
||||
...(useSearchSessions && options.isStored
|
||||
? // Use session's keep_alive if search belongs to a stored session
|
||||
options.isSearchStored || options.isRestore // if search was already stored and extended, then no need to extend keepAlive
|
||||
? {}
|
||||
: {
|
||||
keep_alive: `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms`,
|
||||
}
|
||||
: {
|
||||
// We still need to do polling for searches not within the context of a search session or when search session disabled
|
||||
keep_alive: '1m',
|
||||
|
|
|
@ -205,7 +205,7 @@ describe('ES search strategy', () => {
|
|||
});
|
||||
|
||||
describe('with sessionId', () => {
|
||||
it('makes a POST request with params (long keepalive)', async () => {
|
||||
it('Submit search with session id that is not saved creates a search with short keep_alive', async () => {
|
||||
mockSubmitCaller.mockResolvedValueOnce(mockAsyncResponse);
|
||||
|
||||
const params = { index: 'logstash-*', body: { query: {} } };
|
||||
|
@ -218,10 +218,26 @@ describe('ES search strategy', () => {
|
|||
expect(request.index).toEqual(params.index);
|
||||
expect(request.body).toEqual(params.body);
|
||||
|
||||
expect(request).toHaveProperty('keep_alive', '1m');
|
||||
});
|
||||
|
||||
it('Submit search with session id and session is saved creates a search with long keep_alive', async () => {
|
||||
mockSubmitCaller.mockResolvedValueOnce(mockAsyncResponse);
|
||||
|
||||
const params = { index: 'logstash-*', body: { query: {} } };
|
||||
const esSearch = await enhancedEsSearchStrategyProvider(mockLegacyConfig$, mockLogger);
|
||||
|
||||
await esSearch.search({ params }, { sessionId: '1', isStored: true }, mockDeps).toPromise();
|
||||
|
||||
expect(mockSubmitCaller).toBeCalled();
|
||||
const request = mockSubmitCaller.mock.calls[0][0];
|
||||
expect(request.index).toEqual(params.index);
|
||||
expect(request.body).toEqual(params.body);
|
||||
|
||||
expect(request).toHaveProperty('keep_alive', '604800000ms');
|
||||
});
|
||||
|
||||
it('makes a GET request to async search without keepalive', async () => {
|
||||
it('makes a GET request to async search with short keepalive, if session is not saved', async () => {
|
||||
mockGetCaller.mockResolvedValueOnce(mockAsyncResponse);
|
||||
|
||||
const params = { index: 'logstash-*', body: { query: {} } };
|
||||
|
@ -229,6 +245,44 @@ describe('ES search strategy', () => {
|
|||
|
||||
await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise();
|
||||
|
||||
expect(mockGetCaller).toBeCalled();
|
||||
const request = mockGetCaller.mock.calls[0][0];
|
||||
expect(request.id).toEqual('foo');
|
||||
expect(request).toHaveProperty('wait_for_completion_timeout');
|
||||
expect(request).toHaveProperty('keep_alive', '1m');
|
||||
});
|
||||
|
||||
it('makes a GET request to async search with long keepalive, if session is saved', async () => {
|
||||
mockGetCaller.mockResolvedValueOnce(mockAsyncResponse);
|
||||
|
||||
const params = { index: 'logstash-*', body: { query: {} } };
|
||||
const esSearch = await enhancedEsSearchStrategyProvider(mockLegacyConfig$, mockLogger);
|
||||
|
||||
await esSearch
|
||||
.search({ id: 'foo', params }, { sessionId: '1', isStored: true }, mockDeps)
|
||||
.toPromise();
|
||||
|
||||
expect(mockGetCaller).toBeCalled();
|
||||
const request = mockGetCaller.mock.calls[0][0];
|
||||
expect(request.id).toEqual('foo');
|
||||
expect(request).toHaveProperty('wait_for_completion_timeout');
|
||||
expect(request).toHaveProperty('keep_alive', '604800000ms');
|
||||
});
|
||||
|
||||
it('makes a GET request to async search with no keepalive, if session is session saved and search is stored', async () => {
|
||||
mockGetCaller.mockResolvedValueOnce(mockAsyncResponse);
|
||||
|
||||
const params = { index: 'logstash-*', body: { query: {} } };
|
||||
const esSearch = await enhancedEsSearchStrategyProvider(mockLegacyConfig$, mockLogger);
|
||||
|
||||
await esSearch
|
||||
.search(
|
||||
{ id: 'foo', params },
|
||||
{ sessionId: '1', isSearchStored: true, isStored: true },
|
||||
mockDeps
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
expect(mockGetCaller).toBeCalled();
|
||||
const request = mockGetCaller.mock.calls[0][0];
|
||||
expect(request.id).toEqual('foo');
|
||||
|
|
|
@ -60,7 +60,7 @@ describe('request utils', () => {
|
|||
expect(params).toHaveProperty('keep_alive', '1m');
|
||||
});
|
||||
|
||||
test('Uses `keep_alive` from config if enabled', async () => {
|
||||
test('Uses `keep_alive` from config if enabled and session is stored', async () => {
|
||||
const mockUiSettingsClient = getMockUiSettingsClient({
|
||||
[UI_SETTINGS.SEARCH_INCLUDE_FROZEN]: false,
|
||||
});
|
||||
|
@ -69,6 +69,7 @@ describe('request utils', () => {
|
|||
});
|
||||
const params = await getDefaultAsyncSubmitParams(mockUiSettingsClient, mockConfig, {
|
||||
sessionId: 'foo',
|
||||
isStored: true,
|
||||
});
|
||||
expect(params).toHaveProperty('keep_alive', '259200000ms');
|
||||
});
|
||||
|
@ -132,12 +133,16 @@ describe('request utils', () => {
|
|||
expect(params).toHaveProperty('keep_alive', '1m');
|
||||
});
|
||||
|
||||
test('Has no `keep_alive` if `sessionId` is provided', async () => {
|
||||
test('Has no `keep_alive` if `sessionId` is provided and search already stored', async () => {
|
||||
const mockConfig = getMockSearchSessionsConfig({
|
||||
defaultExpiration: moment.duration(3, 'd'),
|
||||
enabled: true,
|
||||
});
|
||||
const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' });
|
||||
const params = getDefaultAsyncGetParams(mockConfig, {
|
||||
sessionId: 'foo',
|
||||
isStored: true,
|
||||
isSearchStored: true,
|
||||
});
|
||||
expect(params).not.toHaveProperty('keep_alive');
|
||||
});
|
||||
|
||||
|
|
|
@ -29,12 +29,13 @@ describe('request utils', () => {
|
|||
expect(params).toHaveProperty('keep_alive', '1m');
|
||||
});
|
||||
|
||||
test('Uses `keep_alive` from config if enabled', async () => {
|
||||
test('Uses `keep_alive` from config if enabled and session is stored', async () => {
|
||||
const mockConfig = getMockSearchSessionsConfig({
|
||||
defaultExpiration: moment.duration(3, 'd'),
|
||||
});
|
||||
const params = getDefaultAsyncSubmitParams(mockConfig, {
|
||||
sessionId: 'foo',
|
||||
isStored: true,
|
||||
});
|
||||
expect(params).toHaveProperty('keep_alive', '259200000ms');
|
||||
});
|
||||
|
@ -89,12 +90,16 @@ describe('request utils', () => {
|
|||
expect(params).toHaveProperty('keep_alive', '1m');
|
||||
});
|
||||
|
||||
test('Has no `keep_alive` if `sessionId` is provided', async () => {
|
||||
test('Has no `keep_alive` if `sessionId` is provided, search and session are stored', async () => {
|
||||
const mockConfig = getMockSearchSessionsConfig({
|
||||
defaultExpiration: moment.duration(3, 'd'),
|
||||
enabled: true,
|
||||
});
|
||||
const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' });
|
||||
const params = getDefaultAsyncGetParams(mockConfig, {
|
||||
sessionId: 'foo',
|
||||
isStored: true,
|
||||
isSearchStored: true,
|
||||
});
|
||||
expect(params).not.toHaveProperty('keep_alive');
|
||||
});
|
||||
|
||||
|
|
|
@ -89,6 +89,7 @@ export interface IScopedSearchClient extends ISearchClient {
|
|||
cancelSession: IScopedSearchSessionsClient['cancel'];
|
||||
deleteSession: IScopedSearchSessionsClient['delete'];
|
||||
extendSession: IScopedSearchSessionsClient['extend'];
|
||||
getSessionStatus: IScopedSearchSessionsClient['status'];
|
||||
}
|
||||
|
||||
export interface ISearchStart<
|
||||
|
|
|
@ -132,7 +132,7 @@ export const RequestCodeViewer = ({ indexPattern, json }: RequestCodeViewerProps
|
|||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiFlexItem grow={true} data-test-subj="inspectorRequestCodeViewerContainer">
|
||||
<CodeEditor
|
||||
languageId={XJsonLang.ID}
|
||||
value={json}
|
||||
|
|
|
@ -102,16 +102,9 @@ export function getTimelionRequestHandler({
|
|||
|
||||
const esQueryConfigs = getEsQueryConfig(uiSettings);
|
||||
|
||||
// parse the time range client side to make sure it behaves like other charts
|
||||
const timeRangeBounds = timefilter.calculateBounds(timeRange);
|
||||
const untrackSearch =
|
||||
dataSearch.session.isCurrentSession(searchSessionId) &&
|
||||
dataSearch.session.trackSearch({
|
||||
abort: () => abortController.abort(),
|
||||
});
|
||||
|
||||
try {
|
||||
const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId);
|
||||
const doSearch = async (
|
||||
searchOptions: ReturnType<typeof dataSearch.session.getSearchOptions>
|
||||
): Promise<TimelionSuccessResponse> => {
|
||||
return await http.post('/api/timelion/run', {
|
||||
body: JSON.stringify({
|
||||
sheet: [expression],
|
||||
|
@ -126,14 +119,40 @@ export function getTimelionRequestHandler({
|
|||
interval: visParams.interval,
|
||||
timezone,
|
||||
},
|
||||
...(searchSessionOptions && {
|
||||
searchSession: searchSessionOptions,
|
||||
}),
|
||||
...(searchOptions
|
||||
? {
|
||||
searchSession: searchOptions,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
context: executionContext,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
};
|
||||
|
||||
// parse the time range client side to make sure it behaves like other charts
|
||||
const timeRangeBounds = timefilter.calculateBounds(timeRange);
|
||||
const searchTracker = dataSearch.session.isCurrentSession(searchSessionId)
|
||||
? dataSearch.session.trackSearch({
|
||||
abort: () => abortController.abort(),
|
||||
poll: async () => {
|
||||
// don't use, keep this empty, onSavingSession is used instead
|
||||
},
|
||||
onSavingSession: async (searchSessionOptions) => {
|
||||
await doSearch(searchSessionOptions);
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId);
|
||||
const visData = await doSearch(searchSessionOptions);
|
||||
|
||||
searchTracker?.complete();
|
||||
return visData;
|
||||
} catch (e) {
|
||||
searchTracker?.error();
|
||||
|
||||
if (e && e.body) {
|
||||
const err = new Error(
|
||||
`${i18n.translate('timelion.requestHandlerErrorTitle', {
|
||||
|
@ -146,10 +165,6 @@ export function getTimelionRequestHandler({
|
|||
throw e;
|
||||
}
|
||||
} finally {
|
||||
if (untrackSearch && dataSearch.session.isCurrentSession(searchSessionId)) {
|
||||
// call `untrack` if this search still belongs to current session
|
||||
untrackSearch();
|
||||
}
|
||||
expressionAbortSignal.removeEventListener('abort', expressionAbortHandler);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -49,33 +49,46 @@ export const metricsRequestHandler = async ({
|
|||
const dataSearch = data.search;
|
||||
const parsedTimeRange = data.query.timefilter.timefilter.calculateBounds(input?.timeRange!);
|
||||
|
||||
const doSearch = async (
|
||||
searchOptions: ReturnType<typeof dataSearch.session.getSearchOptions>
|
||||
): Promise<TimeseriesVisData> => {
|
||||
return await getCoreStart().http.post(ROUTES.VIS_DATA, {
|
||||
body: JSON.stringify({
|
||||
timerange: {
|
||||
timezone,
|
||||
...parsedTimeRange,
|
||||
},
|
||||
query: input?.query,
|
||||
filters: input?.filters,
|
||||
panels: [visParams],
|
||||
state: uiStateObj,
|
||||
...(searchOptions
|
||||
? {
|
||||
searchSession: searchOptions,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
context: executionContext,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
};
|
||||
|
||||
if (visParams && visParams.id && !visParams.isModelInvalid && !expressionAbortSignal.aborted) {
|
||||
const untrackSearch =
|
||||
dataSearch.session.isCurrentSession(searchSessionId) &&
|
||||
dataSearch.session.trackSearch({
|
||||
abort: () => abortController.abort(),
|
||||
});
|
||||
const searchTracker = dataSearch.session.isCurrentSession(searchSessionId)
|
||||
? dataSearch.session.trackSearch({
|
||||
abort: () => abortController.abort(),
|
||||
poll: async () => {
|
||||
// don't use, keep this empty, onSavingSession is used instead
|
||||
},
|
||||
onSavingSession: async (searchSessionOptions) => {
|
||||
await doSearch(searchSessionOptions);
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId);
|
||||
|
||||
const visData: TimeseriesVisData = await getCoreStart().http.post(ROUTES.VIS_DATA, {
|
||||
body: JSON.stringify({
|
||||
timerange: {
|
||||
timezone,
|
||||
...parsedTimeRange,
|
||||
},
|
||||
query: input?.query,
|
||||
filters: input?.filters,
|
||||
panels: [visParams],
|
||||
state: uiStateObj,
|
||||
...(searchSessionOptions && {
|
||||
searchSession: searchSessionOptions,
|
||||
}),
|
||||
}),
|
||||
context: executionContext,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const visData: TimeseriesVisData = await doSearch(searchSessionOptions);
|
||||
|
||||
inspectorAdapters?.requests?.reset();
|
||||
|
||||
|
@ -86,12 +99,12 @@ export const metricsRequestHandler = async ({
|
|||
.ok({ time: query.time, json: { rawResponse: query.response } });
|
||||
});
|
||||
|
||||
searchTracker?.complete();
|
||||
|
||||
return visData;
|
||||
} catch (e) {
|
||||
searchTracker?.error();
|
||||
} finally {
|
||||
if (untrackSearch && dataSearch.session.isCurrentSession(searchSessionId)) {
|
||||
// untrack if this search still belongs to current session
|
||||
untrackSearch();
|
||||
}
|
||||
expressionAbortSignal.removeEventListener('abort', expressionAbortHandler);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -179,6 +179,14 @@ export class DashboardPanelActionsService extends FtrService {
|
|||
return searchSessionId;
|
||||
}
|
||||
|
||||
async getSearchResponseByTitle(title: string) {
|
||||
await this.openInspectorByTitle(title);
|
||||
await this.inspector.openInspectorRequestsView();
|
||||
const response = await this.inspector.getResponse();
|
||||
await this.inspector.close();
|
||||
return response;
|
||||
}
|
||||
|
||||
async openInspector(parent?: WebElementWrapper) {
|
||||
await this.openContextMenu(parent);
|
||||
const exists = await this.testSubjects.exists(OPEN_INSPECTOR_TEST_SUBJ);
|
||||
|
|
|
@ -17,6 +17,7 @@ export class InspectorService extends FtrService {
|
|||
private readonly testSubjects = this.ctx.getService('testSubjects');
|
||||
private readonly find = this.ctx.getService('find');
|
||||
private readonly comboBox = this.ctx.getService('comboBox');
|
||||
private readonly monacoEditor = this.ctx.getService('monacoEditor');
|
||||
|
||||
private async getIsEnabled(): Promise<boolean> {
|
||||
const ariaDisabled = await this.testSubjects.getAttribute('openInspectorButton', 'disabled');
|
||||
|
@ -212,6 +213,7 @@ export class InspectorService extends FtrService {
|
|||
* Opens inspector requests view
|
||||
*/
|
||||
public async openInspectorRequestsView(): Promise<void> {
|
||||
if (!(await this.testSubjects.exists('inspectorViewChooser'))) return;
|
||||
await this.openInspectorView('Requests');
|
||||
}
|
||||
|
||||
|
@ -253,6 +255,15 @@ export class InspectorService extends FtrService {
|
|||
return this.testSubjects.find('inspectorRequestDetailResponse');
|
||||
}
|
||||
|
||||
public async getResponse(): Promise<Record<string, any>> {
|
||||
await (await this.getOpenRequestDetailResponseButton()).click();
|
||||
|
||||
await this.monacoEditor.waitCodeEditorReady('inspectorRequestCodeViewerContainer');
|
||||
const responseString = await this.monacoEditor.getCodeEditorValue();
|
||||
this.log.debug('Response string from inspector:', responseString);
|
||||
return JSON.parse(responseString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the value equals the combobox options list
|
||||
* @param value default combobox single option text
|
||||
|
|
|
@ -91,20 +91,14 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
|
|||
'unifiedSearch.autocomplete.valueSuggestions.tiers (array)',
|
||||
'unifiedSearch.autocomplete.valueSuggestions.timeout (duration)',
|
||||
'data.search.aggs.shardDelay.enabled (boolean)',
|
||||
'data.search.sessions.cleanupInterval (duration)',
|
||||
'data.search.sessions.defaultExpiration (duration)',
|
||||
'data.search.sessions.enabled (boolean)',
|
||||
'data.search.sessions.expireInterval (duration)',
|
||||
'data.search.sessions.management.expiresSoonWarning (duration)',
|
||||
'data.search.sessions.management.maxSessions (number)',
|
||||
'data.search.sessions.management.refreshInterval (duration)',
|
||||
'data.search.sessions.management.refreshTimeout (duration)',
|
||||
'data.search.sessions.maxUpdateRetries (number)',
|
||||
'data.search.sessions.monitoringTaskTimeout (duration)',
|
||||
'data.search.sessions.notTouchedInProgressTimeout (duration)',
|
||||
'data.search.sessions.notTouchedTimeout (duration)',
|
||||
'data.search.sessions.pageSize (number)',
|
||||
'data.search.sessions.trackingInterval (duration)',
|
||||
'enterpriseSearch.host (string)',
|
||||
'guidedOnboarding.ui (boolean)',
|
||||
'home.disableWelcomeScreen (boolean)',
|
||||
|
|
|
@ -17,6 +17,10 @@ export const REMOVED_TYPES: string[] = [
|
|||
|
||||
// deprecated in https://github.com/elastic/kibana/pull/121442
|
||||
'alerting:siem.signals',
|
||||
|
||||
'search_sessions_monitor',
|
||||
'search_sessions_cleanup',
|
||||
'search_sessions_expire',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
@ -92,11 +92,11 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
.expect(200);
|
||||
|
||||
const resp = await supertest
|
||||
.get(`/internal/session/${sessionId}`)
|
||||
.get(`/internal/session/${sessionId}/status`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200);
|
||||
|
||||
const { status } = resp.body.attributes;
|
||||
const { status } = resp.body;
|
||||
expect(status).to.equal(SearchSessionStatus.CANCELLED);
|
||||
});
|
||||
|
||||
|
@ -132,10 +132,10 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
expect(updatedSession.name).to.equal(newName);
|
||||
});
|
||||
|
||||
it('should sync search ids into persisted session', async () => {
|
||||
it('should sync search ids into saved session', async () => {
|
||||
const sessionId = `my-session-${Math.random()}`;
|
||||
|
||||
// run search
|
||||
// run search, this will not be persisted because session is not saved yet
|
||||
const searchRes1 = await supertest
|
||||
.post(`/internal/search/ese`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
|
@ -156,7 +156,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
|
||||
const { id: id1 } = searchRes1.body;
|
||||
|
||||
// persist session
|
||||
// save session
|
||||
await supertest
|
||||
.post(`/internal/session`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
|
@ -175,6 +175,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
sessionId,
|
||||
isStored: true,
|
||||
params: {
|
||||
body: {
|
||||
query: {
|
||||
|
@ -188,20 +189,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
|
||||
const { id: id2 } = searchRes2.body;
|
||||
|
||||
await retry.waitFor('searches persisted into session', async () => {
|
||||
await retry.waitFor('a search persisted into session', async () => {
|
||||
const resp = await supertest
|
||||
.get(`/internal/session/${sessionId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200);
|
||||
|
||||
const { name, touched, created, persisted, idMapping } = resp.body.attributes;
|
||||
expect(persisted).to.be(true);
|
||||
const { name, created, idMapping } = resp.body.attributes;
|
||||
expect(name).to.be('My Session');
|
||||
expect(touched).not.to.be(undefined);
|
||||
expect(created).not.to.be(undefined);
|
||||
|
||||
const idMappings = Object.values(idMapping).map((value: any) => value.id);
|
||||
expect(idMappings).to.contain(id1);
|
||||
expect(idMappings).not.to.contain(id1);
|
||||
expect(idMappings).to.contain(id2);
|
||||
return true;
|
||||
});
|
||||
|
@ -241,71 +240,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
.expect(404);
|
||||
});
|
||||
|
||||
it('should sync search ids into not persisted session', async () => {
|
||||
const sessionId = `my-session-${Math.random()}`;
|
||||
|
||||
// run search
|
||||
const searchRes1 = await supertest
|
||||
.post(`/internal/search/ese`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
sessionId,
|
||||
params: {
|
||||
body: {
|
||||
query: {
|
||||
term: {
|
||||
agent: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
wait_for_completion_timeout: '1ms',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const { id: id1 } = searchRes1.body;
|
||||
|
||||
// run search
|
||||
const searchRes2 = await supertest
|
||||
.post(`/internal/search/ese`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
sessionId,
|
||||
params: {
|
||||
body: {
|
||||
query: {
|
||||
match_all: {},
|
||||
},
|
||||
},
|
||||
wait_for_completion_timeout: '1ms',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const { id: id2 } = searchRes2.body;
|
||||
|
||||
await retry.waitFor('searches persisted into session', async () => {
|
||||
const resp = await supertest
|
||||
.get(`/internal/session/${sessionId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200);
|
||||
|
||||
const { appId, name, touched, created, persisted, idMapping } = resp.body.attributes;
|
||||
expect(persisted).to.be(false);
|
||||
expect(name).to.be(undefined);
|
||||
expect(appId).to.be(undefined);
|
||||
expect(touched).not.to.be(undefined);
|
||||
expect(created).not.to.be(undefined);
|
||||
|
||||
const idMappings = Object.values(idMapping).map((value: any) => value.id);
|
||||
expect(idMappings).to.contain(id1);
|
||||
expect(idMappings).to.contain(id2);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('should complete session when searches complete', async () => {
|
||||
const sessionId = `my-session-${Math.random()}`;
|
||||
const searchParams = {
|
||||
body: {
|
||||
query: {
|
||||
term: {
|
||||
agent: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
wait_for_completion_timeout: '1ms',
|
||||
};
|
||||
|
||||
// run search
|
||||
const searchRes = await supertest
|
||||
|
@ -313,16 +259,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
sessionId,
|
||||
params: {
|
||||
body: {
|
||||
query: {
|
||||
term: {
|
||||
agent: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
wait_for_completion_timeout: '1ms',
|
||||
},
|
||||
params: searchParams,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
|
@ -341,15 +278,24 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
})
|
||||
.expect(200);
|
||||
|
||||
// run search to persist into a session
|
||||
await supertest
|
||||
.post(`/internal/search/ese/${id}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
sessionId,
|
||||
params: searchParams,
|
||||
isStored: true,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await retry.waitFor('searches persisted into session', async () => {
|
||||
const resp = await supertest
|
||||
.get(`/internal/session/${sessionId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200);
|
||||
|
||||
const { touched, created, persisted, idMapping } = resp.body.attributes;
|
||||
expect(persisted).to.be(true);
|
||||
expect(touched).not.to.be(undefined);
|
||||
const { created, idMapping } = resp.body.attributes;
|
||||
expect(created).not.to.be(undefined);
|
||||
|
||||
const idMappings = Object.values(idMapping).map((value: any) => value.id);
|
||||
|
@ -357,92 +303,23 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
return true;
|
||||
});
|
||||
|
||||
// session refresh interval is 10 seconds, wait to give a chance for status to update
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
|
||||
await retry.waitFor(
|
||||
'searches eventually complete and session gets into the complete state',
|
||||
async () => {
|
||||
const resp = await supertest
|
||||
.get(`/internal/session/${sessionId}`)
|
||||
.get(`/internal/session/${sessionId}/status`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200);
|
||||
|
||||
const { status, completed } = resp.body.attributes;
|
||||
const { status } = resp.body;
|
||||
|
||||
expect(status).to.be(SearchSessionStatus.COMPLETE);
|
||||
expect(completed).not.to.be(undefined);
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('touched time updates when you poll on an search', async () => {
|
||||
const sessionId = `my-session-${Math.random()}`;
|
||||
|
||||
// run search
|
||||
const searchRes1 = await supertest
|
||||
.post(`/internal/search/ese`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
sessionId,
|
||||
params: {
|
||||
body: {
|
||||
query: {
|
||||
term: {
|
||||
agent: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
wait_for_completion_timeout: '1ms',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const { id: id1 } = searchRes1.body;
|
||||
|
||||
// it might take the session a moment to be created
|
||||
await retry.waitFor('search session created', async () => {
|
||||
const response = await supertest
|
||||
.get(`/internal/session/${sessionId}`)
|
||||
.set('kbn-xsrf', 'foo');
|
||||
return response.body.statusCode === undefined;
|
||||
});
|
||||
|
||||
const getSessionFirstTime = await supertest
|
||||
.get(`/internal/session/${sessionId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200);
|
||||
|
||||
// poll on search
|
||||
await supertest
|
||||
.post(`/internal/search/ese/${id1}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
sessionId,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// it might take the session a moment to be updated
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
|
||||
await retry.waitFor('search session touched time updated', async () => {
|
||||
const getSessionSecondTime = await supertest
|
||||
.get(`/internal/session/${sessionId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200);
|
||||
|
||||
expect(getSessionFirstTime.body.attributes.sessionId).to.be.equal(
|
||||
getSessionSecondTime.body.attributes.sessionId
|
||||
);
|
||||
expect(getSessionFirstTime.body.attributes.touched).to.be.lessThan(
|
||||
getSessionSecondTime.body.attributes.touched
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('with security', () => {
|
||||
before(async () => {
|
||||
await security.user.create('other_user', {
|
||||
|
@ -623,86 +500,27 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
await spacesService.delete(spaceId);
|
||||
});
|
||||
|
||||
it('should complete and delete non-persistent sessions', async () => {
|
||||
const sessionId = `my-session-${Math.random()}`;
|
||||
|
||||
// run search
|
||||
const searchRes = await supertest
|
||||
.post(`/s/${spaceId}/internal/search/ese`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
sessionId,
|
||||
params: {
|
||||
body: {
|
||||
query: {
|
||||
term: {
|
||||
agent: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
wait_for_completion_timeout: '1ms',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const { id } = searchRes.body;
|
||||
|
||||
await retry.waitFor('searches persisted into session', async () => {
|
||||
const resp = await supertest
|
||||
.get(`/s/${spaceId}/internal/session/${sessionId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200);
|
||||
|
||||
const { touched, created, persisted, idMapping } = resp.body.attributes;
|
||||
expect(persisted).to.be(false);
|
||||
expect(touched).not.to.be(undefined);
|
||||
expect(created).not.to.be(undefined);
|
||||
|
||||
const idMappings = Object.values(idMapping).map((value: any) => value.id);
|
||||
expect(idMappings).to.contain(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
// not touched timeout in tests is 15s, wait to give a chance for status to update
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(() => {
|
||||
resolve(void 0);
|
||||
}, 15_000)
|
||||
);
|
||||
|
||||
await retry.waitForWithTimeout(
|
||||
'searches eventually complete and session gets into the complete state',
|
||||
30_000,
|
||||
async () => {
|
||||
await supertest
|
||||
.get(`/s/${spaceId}/internal/session/${sessionId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(404);
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should complete persisted session', async () => {
|
||||
const sessionId = `my-session-${Math.random()}`;
|
||||
|
||||
const searchParams = {
|
||||
body: {
|
||||
query: {
|
||||
term: {
|
||||
agent: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
wait_for_completion_timeout: '1ms',
|
||||
};
|
||||
|
||||
// run search
|
||||
const searchRes = await supertest
|
||||
.post(`/s/${spaceId}/internal/search/ese`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
sessionId,
|
||||
params: {
|
||||
body: {
|
||||
query: {
|
||||
term: {
|
||||
agent: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
wait_for_completion_timeout: '1ms',
|
||||
},
|
||||
params: searchParams,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
|
@ -721,15 +539,24 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
})
|
||||
.expect(200);
|
||||
|
||||
// run search to persist into a session
|
||||
await supertest
|
||||
.post(`/s/${spaceId}/internal/search/ese/${id}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
sessionId,
|
||||
params: searchParams,
|
||||
isStored: true,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await retry.waitFor('searches persisted into session', async () => {
|
||||
const resp = await supertest
|
||||
.get(`/s/${spaceId}/internal/session/${sessionId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200);
|
||||
|
||||
const { touched, created, persisted, idMapping } = resp.body.attributes;
|
||||
expect(persisted).to.be(true);
|
||||
expect(touched).not.to.be(undefined);
|
||||
const { created, idMapping } = resp.body.attributes;
|
||||
expect(created).not.to.be(undefined);
|
||||
|
||||
const idMappings = Object.values(idMapping).map((value: any) => value.id);
|
||||
|
@ -737,18 +564,15 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
return true;
|
||||
});
|
||||
|
||||
// session refresh interval is 5 seconds, wait to give a chance for status to update
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
await retry.waitFor(
|
||||
'searches eventually complete and session gets into the complete state',
|
||||
async () => {
|
||||
const resp = await supertest
|
||||
.get(`/s/${spaceId}/internal/session/${sessionId}`)
|
||||
.get(`/s/${spaceId}/internal/session/${sessionId}/status`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200);
|
||||
|
||||
const { status } = resp.body.attributes;
|
||||
const { status } = resp.body;
|
||||
|
||||
expect(status).to.be(SearchSessionStatus.COMPLETE);
|
||||
return true;
|
||||
|
|
|
@ -28,10 +28,6 @@ export async function getApiIntegrationConfig({ readConfigFile }: FtrConfigProvi
|
|||
'--xpack.security.session.idleTimeout=3600000', // 1 hour
|
||||
'--telemetry.optIn=true',
|
||||
'--xpack.fleet.agents.pollingRequestTimeout=5000', // 5 seconds
|
||||
'--xpack.data_enhanced.search.sessions.enabled=true', // enable WIP send to background UI
|
||||
'--xpack.data_enhanced.search.sessions.notTouchedTimeout=15s', // shorten notTouchedTimeout for quicker testing
|
||||
'--xpack.data_enhanced.search.sessions.trackingInterval=5s', // shorten trackingInterval for quicker testing
|
||||
'--xpack.data_enhanced.search.sessions.cleanupInterval=5s', // shorten cleanupInterval for quicker testing
|
||||
'--xpack.ruleRegistry.write.enabled=true',
|
||||
'--xpack.ruleRegistry.write.enabled=true',
|
||||
'--xpack.ruleRegistry.write.cache.enabled=false',
|
||||
|
|
|
@ -252,4 +252,72 @@
|
|||
"type": "dashboard",
|
||||
"updated_at": "2020-03-19T11:59:53.701Z",
|
||||
"version": "WzE3LDJd"
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
"attributes": {
|
||||
"description": "",
|
||||
"hits": 0,
|
||||
"kibanaSavedObjectMeta": {
|
||||
"searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"
|
||||
},
|
||||
"optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncTooltips\":false,\"hidePanelTitles\":false}",
|
||||
"panelsJSON": "[{\"version\":\"8.4.0\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"83d70d60-6917-4432-9a67-c612ea014a3a\"},\"panelIndex\":\"83d70d60-6917-4432-9a67-c612ea014a3a\",\"embeddableConfig\":{\"attributes\":{\"title\":\"Lens with other bucket\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-f7673857-e676-47bf-83ac-cd7afb16942e\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\"},\"valueLabels\":\"hide\",\"fittingFunction\":\"None\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"f7673857-e676-47bf-83ac-cd7afb16942e\",\"accessors\":[\"e4ae40b5-d347-4473-937e-7519a699330e\"],\"position\":\"top\",\"seriesType\":\"bar_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"xAccessor\":\"5b89d5af-a7db-4be3-b355-08cbf4c2d124\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"indexpattern\":{\"layers\":{\"f7673857-e676-47bf-83ac-cd7afb16942e\":{\"columns\":{\"5b89d5af-a7db-4be3-b355-08cbf4c2d124\":{\"label\":\"Top 3 values of extension.raw\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"extension.raw\",\"isBucketed\":true,\"params\":{\"size\":3,\"orderBy\":{\"type\":\"column\",\"columnId\":\"e4ae40b5-d347-4473-937e-7519a699330e\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"}}},\"e4ae40b5-d347-4473-937e-7519a699330e\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"5b89d5af-a7db-4be3-b355-08cbf4c2d124\",\"e4ae40b5-d347-4473-937e-7519a699330e\"],\"incompleteColumns\":{}}}}}}},\"enhancements\":{}}}]",
|
||||
"timeRestore": true,
|
||||
"timeTo": "2015-09-23T00:09:17.180Z",
|
||||
"timeFrom": "2015-09-19T17:34:10.297Z",
|
||||
"refreshInterval": {
|
||||
"pause": true,
|
||||
"value": 0
|
||||
},
|
||||
"title": "Lens with other bucket",
|
||||
"version": 1
|
||||
},
|
||||
"coreMigrationVersion": "8.4.0",
|
||||
"id": "b4444110-0cfb-11ed-8b10-7919f14bd1fd",
|
||||
"migrationVersion": { "dashboard": "8.3.0" },
|
||||
"references": [
|
||||
{
|
||||
"id": "logstash-*",
|
||||
"name": "83d70d60-6917-4432-9a67-c612ea014a3a:indexpattern-datasource-layer-f7673857-e676-47bf-83ac-cd7afb16942e",
|
||||
"type": "index-pattern"
|
||||
}
|
||||
],
|
||||
"type": "dashboard",
|
||||
"updated_at": "2022-07-26T15:57:51.910Z",
|
||||
"version": "Wzg5MywzXQ=="
|
||||
}
|
||||
|
||||
{
|
||||
"attributes": {
|
||||
"description": "",
|
||||
"hits": 0,
|
||||
"kibanaSavedObjectMeta": {
|
||||
"searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"
|
||||
},
|
||||
"optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncTooltips\":false,\"hidePanelTitles\":false}",
|
||||
"panelsJSON": "[{\"version\":\"8.5.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"342a4e6a-be32-4dc1-ad13-f9520388c904\"},\"panelIndex\":\"342a4e6a-be32-4dc1-ad13-f9520388c904\",\"embeddableConfig\":{\"savedVis\":{\"id\":\"\",\"title\":\"TSVB\",\"description\":\"\",\"type\":\"metrics\",\"params\":{\"axis_formatter\":\"number\",\"axis_position\":\"left\",\"axis_scale\":\"normal\",\"drop_last_bucket\":0,\"id\":\"979d6cfd-06d2-473f-99f6-d2109c2b0d42\",\"interval\":\"\",\"max_lines_legend\":1,\"series\":[{\"axis_position\":\"right\",\"chart_type\":\"line\",\"color\":\"#68BC00\",\"fill\":0.5,\"formatter\":\"default\",\"id\":\"ec61d9bd-03d3-4c33-80cd-997a9a074f3f\",\"line_width\":1,\"metrics\":[{\"id\":\"e2b677b2-784c-4634-ad0c-1801a232627c\",\"type\":\"count\"}],\"override_index_pattern\":0,\"palette\":{\"name\":\"default\",\"type\":\"palette\"},\"point_size\":1,\"separate_axis\":0,\"series_drop_last_bucket\":0,\"split_mode\":\"terms\",\"stacked\":\"none\",\"terms_field\":\"extension.raw\",\"time_range_mode\":\"entire_time_range\"}],\"show_grid\":1,\"show_legend\":1,\"time_field\":\"\",\"time_range_mode\":\"entire_time_range\",\"tooltip_mode\":\"show_all\",\"truncate_legend\":1,\"type\":\"timeseries\",\"use_kibana_indexes\":true,\"index_pattern_ref_name\":\"metrics_342a4e6a-be32-4dc1-ad13-f9520388c904_0_index_pattern\"},\"uiState\":{},\"data\":{\"aggs\":[],\"searchSource\":{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}}},\"enhancements\":{}}},{\"version\":\"8.5.0\",\"type\":\"visualization\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"6eb73754-f243-4630-bf22-138083311015\"},\"panelIndex\":\"6eb73754-f243-4630-bf22-138083311015\",\"embeddableConfig\":{\"savedVis\":{\"id\":\"\",\"title\":\"\",\"description\":\"\",\"type\":\"timelion\",\"params\":{\"expression\":\".es(*)\",\"interval\":\"auto\"},\"uiState\":{},\"data\":{\"aggs\":[],\"searchSource\":{}}},\"enhancements\":{}}}]",
|
||||
"timeRestore": true,
|
||||
"timeTo": "2015-09-23T00:09:17.180Z",
|
||||
"timeFrom": "2015-09-19T17:34:10.297Z",
|
||||
"refreshInterval": {
|
||||
"pause": true,
|
||||
"value": 0
|
||||
},
|
||||
"title": "TSVBwithTimelion",
|
||||
"version": 1
|
||||
},
|
||||
"coreMigrationVersion": "8.5.0",
|
||||
"id": "d2af3180-22e7-11ed-af88-d5938945bd09",
|
||||
"migrationVersion": { "dashboard": "8.4.0" },
|
||||
"references": [
|
||||
{
|
||||
"id": "logstash-*",
|
||||
"name": "342a4e6a-be32-4dc1-ad13-f9520388c904:metrics_342a4e6a-be32-4dc1-ad13-f9520388c904_0_index_pattern",
|
||||
"type": "index-pattern"
|
||||
}
|
||||
],
|
||||
"type": "dashboard",
|
||||
"updated_at": "2022-08-23T13:30:58.587Z",
|
||||
"version": "WzE4NSwxXQ=="
|
||||
}
|
||||
|
|
|
@ -59,6 +59,14 @@ export function SearchSessionsPageProvider({ getService, getPageObjects }: FtrPr
|
|||
);
|
||||
await PageObjects.common.clickConfirmOnModal();
|
||||
},
|
||||
extend: async () => {
|
||||
log.debug('management ui: extend the session');
|
||||
await actionsCell.click();
|
||||
await find.clickByCssSelector(
|
||||
'[data-test-subj="sessionManagementPopoverAction-extend"]'
|
||||
);
|
||||
await PageObjects.common.clickConfirmOnModal();
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
|
@ -31,6 +31,7 @@ export class SearchSessionsService extends FtrService {
|
|||
private readonly retry = this.ctx.getService('retry');
|
||||
private readonly browser = this.ctx.getService('browser');
|
||||
private readonly security = this.ctx.getService('security');
|
||||
private readonly es = this.ctx.getService('es');
|
||||
|
||||
public async find(): Promise<WebElementWrapper> {
|
||||
return this.testSubjects.find(SEARCH_SESSION_INDICATOR_TEST_SUBJ);
|
||||
|
@ -49,14 +50,18 @@ export class SearchSessionsService extends FtrService {
|
|||
await expect(await (await this.find()).getAttribute('data-save-disabled')).to.be('true');
|
||||
}
|
||||
|
||||
public async expectState(state: SessionStateType) {
|
||||
return this.retry.waitFor(`searchSessions indicator to get into state = ${state}`, async () => {
|
||||
const currentState = await (
|
||||
await this.testSubjects.find(SEARCH_SESSION_INDICATOR_TEST_SUBJ)
|
||||
).getAttribute('data-state');
|
||||
this.log.info(`searchSessions state current: ${currentState} expected: ${state}`);
|
||||
return currentState === state;
|
||||
});
|
||||
public async expectState(state: SessionStateType, timeout = 10000) {
|
||||
return this.retry.waitForWithTimeout(
|
||||
`searchSessions indicator to get into state = ${state}`,
|
||||
timeout,
|
||||
async () => {
|
||||
const currentState = await (
|
||||
await this.testSubjects.find(SEARCH_SESSION_INDICATOR_TEST_SUBJ)
|
||||
).getAttribute('data-state');
|
||||
this.log.info(`searchSessions state current: ${currentState} expected: ${state}`);
|
||||
return currentState === state;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async viewSearchSessions() {
|
||||
|
@ -184,4 +189,14 @@ export class SearchSessionsService extends FtrService {
|
|||
this.browser.removeLocalStorageItem(TOUR_RESTORE_STEP_KEY),
|
||||
]);
|
||||
}
|
||||
|
||||
public async getAsyncSearchStatus(asyncSearchId: string) {
|
||||
const asyncSearchStatus = await this.es.asyncSearch.status({ id: asyncSearchId });
|
||||
return asyncSearchStatus;
|
||||
}
|
||||
|
||||
public async getAsyncSearchExpirationTime(asyncSearchId: string): Promise<number> {
|
||||
const asyncSearchStatus = await this.getAsyncSearchStatus(asyncSearchId);
|
||||
return Number(asyncSearchStatus.expiration_time_in_millis);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,7 +32,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
|||
|
||||
kbnTestServer: {
|
||||
...xpackFunctionalConfig.get('kbnTestServer'),
|
||||
serverArgs: [...xpackFunctionalConfig.get('kbnTestServer.serverArgs')],
|
||||
serverArgs: [
|
||||
...xpackFunctionalConfig.get('kbnTestServer.serverArgs'),
|
||||
'--data.search.sessions.management.refreshInterval=10s', // enable automatic refresh for sessions management screen
|
||||
],
|
||||
},
|
||||
services,
|
||||
};
|
||||
|
|
|
@ -35,6 +35,7 @@ export default function ({ loadTestFile, getService, getPageObjects }: FtrProvid
|
|||
});
|
||||
|
||||
loadTestFile(require.resolve('./async_search'));
|
||||
loadTestFile(require.resolve('./session_searches_integration'));
|
||||
loadTestFile(require.resolve('./save_search_session'));
|
||||
loadTestFile(require.resolve('./save_search_session_relative_time'));
|
||||
loadTestFile(require.resolve('./search_sessions_tour'));
|
||||
|
|
|
@ -12,12 +12,19 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
const es = getService('es');
|
||||
const testSubjects = getService('testSubjects');
|
||||
const log = getService('log');
|
||||
const PageObjects = getPageObjects(['common', 'header', 'dashboard', 'visChart']);
|
||||
const PageObjects = getPageObjects([
|
||||
'common',
|
||||
'header',
|
||||
'dashboard',
|
||||
'visChart',
|
||||
'searchSessionsManagement',
|
||||
]);
|
||||
const dashboardPanelActions = getService('dashboardPanelActions');
|
||||
const browser = getService('browser');
|
||||
const searchSessions = getService('searchSessions');
|
||||
const queryBar = getService('queryBar');
|
||||
const elasticChart = getService('elasticChart');
|
||||
const toasts = getService('toasts');
|
||||
|
||||
const enableNewChartLibraryDebug = async () => {
|
||||
await elasticChart.setNewChartUiDebugFlag();
|
||||
|
@ -113,5 +120,33 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
await searchSessions.missingOrFail();
|
||||
});
|
||||
|
||||
describe('TSVB & Timelion', () => {
|
||||
it('Restore session with TSVB & Timelion', async () => {
|
||||
await PageObjects.dashboard.loadSavedDashboard('TSVBwithTimelion');
|
||||
await PageObjects.dashboard.waitForRenderComplete();
|
||||
await searchSessions.expectState('completed');
|
||||
await searchSessions.save();
|
||||
await searchSessions.expectState('backgroundCompleted');
|
||||
|
||||
const savedSessionId = await dashboardPanelActions.getSearchSessionIdByTitle('TSVB');
|
||||
|
||||
// check that searches saved into the session
|
||||
await searchSessions.openPopover();
|
||||
await searchSessions.viewSearchSessions();
|
||||
|
||||
const searchSessionList = await PageObjects.searchSessionsManagement.getList();
|
||||
const searchSessionItem = searchSessionList.find(
|
||||
(session) => session.id === savedSessionId
|
||||
)!;
|
||||
expect(searchSessionItem.searchesCount).to.be(2);
|
||||
|
||||
await searchSessionItem.view();
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.dashboard.waitForRenderComplete();
|
||||
await searchSessions.expectState('restored');
|
||||
expect(await toasts.getToastCount()).to.be(0); // no session restoration related warnings
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const es = getService('es');
|
||||
const log = getService('log');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const browser = getService('browser');
|
||||
const PageObjects = getPageObjects([
|
||||
'common',
|
||||
'header',
|
||||
'dashboard',
|
||||
'visChart',
|
||||
'searchSessionsManagement',
|
||||
]);
|
||||
const toasts = getService('toasts');
|
||||
const dashboardPanelActions = getService('dashboardPanelActions');
|
||||
const searchSessions = getService('searchSessions');
|
||||
const retry = getService('retry');
|
||||
const listingTable = getService('listingTable');
|
||||
const testSubjects = getService('testSubjects');
|
||||
const elasticChart = getService('elasticChart');
|
||||
|
||||
describe('Session and searches integration', () => {
|
||||
before(async function () {
|
||||
const body = await es.info();
|
||||
if (!body.version.number.includes('SNAPSHOT')) {
|
||||
log.debug('Skipping because this build does not have the required shard_delay agg');
|
||||
this.skip();
|
||||
}
|
||||
await PageObjects.common.navigateToApp('dashboard');
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
await searchSessions.deleteAllSearchSessions();
|
||||
});
|
||||
|
||||
it('until session is saved search keepAlive is short, when it is saved, keepAlive is extended and search is saved into the session saved object, when session is extended, searches are also extended', async () => {
|
||||
await PageObjects.dashboard.loadSavedDashboard('Not Delayed');
|
||||
await PageObjects.dashboard.waitForRenderComplete();
|
||||
await searchSessions.expectState('completed');
|
||||
|
||||
const searchResponse = await dashboardPanelActions.getSearchResponseByTitle(
|
||||
'Sum of Bytes by Extension'
|
||||
);
|
||||
|
||||
const asyncSearchId = searchResponse.id;
|
||||
expect(typeof asyncSearchId).to.be('string');
|
||||
|
||||
const asyncExpirationTimeBeforeSessionWasSaved =
|
||||
await searchSessions.getAsyncSearchExpirationTime(asyncSearchId);
|
||||
expect(asyncExpirationTimeBeforeSessionWasSaved).to.be.lessThan(
|
||||
Date.now() + 1000 * 60,
|
||||
'expiration time should be less then a minute from now'
|
||||
);
|
||||
|
||||
await searchSessions.save();
|
||||
await searchSessions.expectState('backgroundCompleted');
|
||||
|
||||
let asyncExpirationTimeAfterSessionWasSaved: number;
|
||||
await retry.waitFor('async search keepAlive is extended', async () => {
|
||||
asyncExpirationTimeAfterSessionWasSaved = await searchSessions.getAsyncSearchExpirationTime(
|
||||
asyncSearchId
|
||||
);
|
||||
|
||||
return (
|
||||
asyncExpirationTimeAfterSessionWasSaved > asyncExpirationTimeBeforeSessionWasSaved &&
|
||||
asyncExpirationTimeAfterSessionWasSaved > Date.now() + 1000 * 60
|
||||
);
|
||||
});
|
||||
|
||||
const savedSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(
|
||||
'Sum of Bytes by Extension'
|
||||
);
|
||||
|
||||
// check that search saved into the session
|
||||
|
||||
await searchSessions.openPopover();
|
||||
await searchSessions.viewSearchSessions();
|
||||
|
||||
const searchSessionList = await PageObjects.searchSessionsManagement.getList();
|
||||
const searchSessionItem = searchSessionList.find((session) => session.id === savedSessionId)!;
|
||||
expect(searchSessionItem.searchesCount).to.be(1);
|
||||
|
||||
await searchSessionItem.extend();
|
||||
|
||||
const asyncExpirationTimeAfterSessionWasExtended =
|
||||
await searchSessions.getAsyncSearchExpirationTime(asyncSearchId);
|
||||
|
||||
expect(asyncExpirationTimeAfterSessionWasExtended).to.be.greaterThan(
|
||||
asyncExpirationTimeAfterSessionWasSaved!
|
||||
);
|
||||
});
|
||||
|
||||
it('When session is deleted, searches are also deleted', async () => {
|
||||
await PageObjects.common.navigateToApp('dashboard');
|
||||
await PageObjects.dashboard.loadSavedDashboard('Not Delayed');
|
||||
await PageObjects.dashboard.waitForRenderComplete();
|
||||
await searchSessions.expectState('completed');
|
||||
|
||||
const searchResponse = await dashboardPanelActions.getSearchResponseByTitle(
|
||||
'Sum of Bytes by Extension'
|
||||
);
|
||||
|
||||
const asyncSearchId = searchResponse.id;
|
||||
expect(typeof asyncSearchId).to.be('string');
|
||||
|
||||
await searchSessions.save();
|
||||
await searchSessions.expectState('backgroundCompleted');
|
||||
|
||||
const savedSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(
|
||||
'Sum of Bytes by Extension'
|
||||
);
|
||||
|
||||
// check that search saved into the session
|
||||
|
||||
await searchSessions.openPopover();
|
||||
await searchSessions.viewSearchSessions();
|
||||
|
||||
const searchSessionList = await PageObjects.searchSessionsManagement.getList();
|
||||
const searchSessionItem = searchSessionList.find((session) => session.id === savedSessionId)!;
|
||||
expect(searchSessionItem.searchesCount).to.be(1);
|
||||
await searchSessionItem.delete();
|
||||
|
||||
const searchNotFoundError = await searchSessions
|
||||
.getAsyncSearchStatus(asyncSearchId)
|
||||
.catch((e) => e);
|
||||
expect(searchNotFoundError.name).to.be('ResponseError');
|
||||
expect(searchNotFoundError.meta.body.error.type).to.be('resource_not_found_exception');
|
||||
});
|
||||
|
||||
describe('Slow lens with other bucket', () => {
|
||||
before(async function () {
|
||||
await kibanaServer.uiSettings.unset('search:timeout');
|
||||
await PageObjects.common.navigateToApp('dashboard', { insertTimestamp: false });
|
||||
await browser.execute(() => {
|
||||
window.ELASTIC_LENS_DELAY_SECONDS = 25;
|
||||
});
|
||||
await elasticChart.setNewChartUiDebugFlag(true);
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
await browser.execute(() => {
|
||||
window.ELASTIC_LENS_DELAY_SECONDS = undefined;
|
||||
});
|
||||
await kibanaServer.uiSettings.replace({ 'search:timeout': 10000 });
|
||||
});
|
||||
|
||||
it('Other bucket should be added to a session when restoring', async () => {
|
||||
// not using regular navigation method, because don't want to wait until all panels load
|
||||
// await PageObjects.dashboard.loadSavedDashboard('Lens with other bucket');
|
||||
await listingTable.clickItemLink('dashboard', 'Lens with other bucket');
|
||||
await testSubjects.missingOrFail('dashboardLandingPage');
|
||||
|
||||
await searchSessions.expectState('loading');
|
||||
await searchSessions.save();
|
||||
await searchSessions.expectState('backgroundLoading');
|
||||
|
||||
const savedSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(
|
||||
'Lens with other bucket'
|
||||
);
|
||||
|
||||
await searchSessions.openPopover();
|
||||
await searchSessions.viewSearchSessions();
|
||||
|
||||
let searchSessionList = await PageObjects.searchSessionsManagement.getList();
|
||||
let searchSessionItem = searchSessionList.find((session) => session.id === savedSessionId)!;
|
||||
expect(searchSessionItem.searchesCount).to.be(1);
|
||||
|
||||
await searchSessionItem.view();
|
||||
|
||||
// Check that session is still loading
|
||||
await searchSessions.expectState('backgroundLoading');
|
||||
expect(await toasts.getToastCount()).to.be(1); // there should be a session restoration warnings related to other bucket
|
||||
await toasts.dismissAllToasts();
|
||||
|
||||
// check that other bucket requested add to a session
|
||||
await searchSessions.openPopover();
|
||||
await searchSessions.viewSearchSessions();
|
||||
|
||||
searchSessionList = await PageObjects.searchSessionsManagement.getList();
|
||||
searchSessionItem = searchSessionList.find((session) => session.id === savedSessionId)!;
|
||||
expect(searchSessionItem.searchesCount).to.be(2);
|
||||
|
||||
await searchSessionItem.view();
|
||||
expect(await toasts.getToastCount()).to.be(0); // there should be no warnings
|
||||
await searchSessions.expectState('restored', 20000);
|
||||
await testSubjects.missingOrFail('embeddableError');
|
||||
|
||||
const data = await elasticChart.getChartDebugData();
|
||||
expect(data!.bars![0].bars.length).to.eql(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -123,7 +123,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
"[eCommerce] Orders Test 6",
|
||||
"16 Feb, 2021, 00:00:00",
|
||||
"--",
|
||||
"error",
|
||||
"expired",
|
||||
],
|
||||
Array [
|
||||
"lens",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue