diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts index 19649dab708cb..a1f5e0e1988cb 100644 --- a/src/plugins/data/common/search/types.ts +++ b/src/plugins/data/common/search/types.ts @@ -130,6 +130,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) @@ -155,5 +160,11 @@ export interface ISearchOptions { */ export type ISearchOptionsSerializable = Pick< ISearchOptions, - 'strategy' | 'legacyHitsTotal' | 'sessionId' | 'isStored' | 'isRestore' | 'executionContext' + | 'strategy' + | 'legacyHitsTotal' + | 'sessionId' + | 'isStored' + | 'isSearchStored' + | 'isRestore' + | 'executionContext' >; diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts index b0e545d0b3e7a..3d27b86352ae0 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts @@ -548,6 +548,7 @@ describe('SearchInterceptor', () => { sessionId, isStored: true, isRestore: true, + isSearchStored: false, strategy: 'ese', }, }) @@ -736,7 +737,7 @@ describe('SearchInterceptor', () => { sessionService.trackSearch.mockImplementation(() => ({ complete: trackSearchComplete, error: () => {}, - polled: () => {}, + beforePoll: () => [{ isSearchStored: false }, () => {}], })); const response = searchInterceptor.search({}, { pollInterval: 0, sessionId }); diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts index 9b2b1022af7d7..044b00ee28780 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts @@ -210,6 +210,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; } @@ -229,15 +231,27 @@ export class SearchInterceptor { const { sessionId, strategy } = options; const search = () => { - searchTracker?.polled(); + 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) diff --git a/src/plugins/data/public/search/session/mocks.ts b/src/plugins/data/public/search/session/mocks.ts index e630ad58f6e1d..351b112764e3d 100644 --- a/src/plugins/data/public/search/session/mocks.ts +++ b/src/plugins/data/public/search/session/mocks.ts @@ -40,7 +40,9 @@ export function getSessionServiceMock(): jest.Mocked { trackSearch: jest.fn((searchDescriptor) => ({ complete: jest.fn(), error: jest.fn(), - polled: jest.fn(), + beforePoll: jest.fn(() => { + return [{ isSearchStored: false }, () => {}]; + }), })), destroy: jest.fn(), cancel: jest.fn(), diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts index c4dc9ffcbc14d..58eff5364fd62 100644 --- a/src/plugins/data/public/search/session/session_service.ts +++ b/src/plugins/data/public/search/session/session_service.ts @@ -93,9 +93,13 @@ interface TrackSearchHandler { error(): void; /** - * Call to notify when search was polled + * 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 */ - polled(): void; + beforePoll(): [ + currentSearchState: { isSearchStored: boolean }, + afterPoll: (newSearchState: { isSearchStored: boolean }) => void + ]; } /** @@ -331,11 +335,20 @@ export class SessionService { error: () => { this.state.transitions.errorSearch(searchDescriptor); }, - polled: () => { + beforePoll: () => { + const search = this.state.selectors.getSearch(searchDescriptor); this.state.transitions.updateSearchMeta(searchDescriptor, { lastPollingTime: new Date(), - isStored: this.isStored(), }); + + return [ + { isSearchStored: search?.searchMeta?.isStored ?? false }, + ({ isSearchStored }) => { + this.state.transitions.updateSearchMeta(searchDescriptor, { + isStored: isSearchStored, + }); + }, + ]; }, }; } diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index d862415f60380..bae1767457f2e 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -392,18 +392,29 @@ export class SearchService implements Plugin { !(options.isRestore && searchRequest.id) // and not restoring already tracked search ) { // then track this search inside the search-session saved object - return from(deps.searchSessionsClient.trackId(request, response.id, options)).pipe( - map(() => ({ + + // check if search was already tracked and extended, don't track again in this case + if (options.isSearchStored) { + return of({ ...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 from( + deps.searchSessionsClient.trackId(request, response.id, options) + ).pipe( + 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); } diff --git a/src/plugins/data/server/search/session/session_service.ts b/src/plugins/data/server/search/session/session_service.ts index 5e815b021a1dd..11d4b07cd1327 100644 --- a/src/plugins/data/server/search/session/session_service.ts +++ b/src/plugins/data/server/search/session/session_service.ts @@ -392,19 +392,6 @@ export class SearchSessionService if (!this.sessionConfig.enabled || !sessionId || !searchId) return; this.logger.debug(`trackId | ${sessionId} | ${searchId}`); - let isIdTracked = false; - try { - const hasId = await this.checkId(deps, user, searchRequest, options); - isIdTracked = hasId; - } catch (e) { - this.logger.warn( - `trackId | Error while checking if search is already tracked by a session object: "${e?.message}". Continue assuming not tracked.` - ); - } - - // no need to update search saved object if id is already tracked in a session object - if (isIdTracked) return; - let idMapping: Record = {}; if (searchRequest.params) { @@ -465,30 +452,6 @@ export class SearchSessionService return session.attributes.idMapping[requestHash].id; }; - /** - * Look up an existing search ID that matches the given request in the given session - * @internal - */ - public checkId = async ( - deps: SearchSessionDependencies, - user: AuthenticatedUser | null, - searchRequest: IKibanaSearchRequest, - { sessionId, isStored }: ISearchOptions - ): Promise => { - if (!this.sessionConfig.enabled) { - throw new Error('Search sessions are disabled'); - } else if (!sessionId) { - throw new Error('Session ID is required'); - } else if (!isStored) { - throw new Error('Cannot check search ID from a session that is not stored'); - } - - const session = await this.get(deps, user, sessionId); - const requestHash = createRequestHash(searchRequest.params); - - return session.attributes.idMapping.hasOwnProperty(requestHash); - }; - public asScopedProvider = ({ savedObjects, elasticsearch }: CoreStart) => { return (request: KibanaRequest) => { const user = this.security?.authc.getCurrentUser(request) ?? null; diff --git a/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx b/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx index df2b3d5c88b93..5ab50ba33a514 100644 --- a/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx +++ b/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx @@ -132,7 +132,7 @@ export const RequestCodeViewer = ({ indexPattern, json }: RequestCodeViewerProps )} - + { const ariaDisabled = await this.testSubjects.getAttribute('openInspectorButton', 'disabled'); @@ -253,6 +254,15 @@ export class InspectorService extends FtrService { return this.testSubjects.find('inspectorRequestDetailResponse'); } + public async getResponse(): Promise> { + 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 diff --git a/x-pack/test/functional/services/search_sessions.ts b/x-pack/test/functional/services/search_sessions.ts index a3d04af530d54..a0c30b1443c1a 100644 --- a/x-pack/test/functional/services/search_sessions.ts +++ b/x-pack/test/functional/services/search_sessions.ts @@ -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 supertest = this.ctx.getService('supertest'); + private readonly es = this.ctx.getService('es'); public async find(): Promise { return this.testSubjects.find(SEARCH_SESSION_INDICATOR_TEST_SUBJ); @@ -174,4 +175,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 { + const asyncSearchStatus = await this.getAsyncSearchStatus(asyncSearchId); + return Number(asyncSearchStatus.expiration_time_in_millis); + } } diff --git a/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/index.ts b/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/index.ts index 77dc12bfec031..a5be0e5983a7e 100644 --- a/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/index.ts +++ b/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/index.ts @@ -34,6 +34,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')); diff --git a/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/session_searches_integration.ts b/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/session_searches_integration.ts new file mode 100644 index 0000000000000..274184c2832ce --- /dev/null +++ b/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/session_searches_integration.ts @@ -0,0 +1,85 @@ +/* + * 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 PageObjects = getPageObjects([ + 'common', + 'header', + 'dashboard', + 'visChart', + 'searchSessionsManagement', + ]); + const dashboardPanelActions = getService('dashboardPanelActions'); + const searchSessions = getService('searchSessions'); + const retry = getService('retry'); + + 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', 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'); + + await retry.waitFor('async search keepAlive is extended', async () => { + const 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); + }); + }); +}