diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md
new file mode 100644
index 0000000000000..984f99004ebe8
--- /dev/null
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md
@@ -0,0 +1,22 @@
+
+
+[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [getSerializableOptions](./kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md)
+
+## SearchInterceptor.getSerializableOptions() method
+
+Signature:
+
+```typescript
+protected getSerializableOptions(options?: ISearchOptions): Pick;
+```
+
+## Parameters
+
+| Parameter | Type | Description |
+| --- | --- | --- |
+| options | ISearchOptions
| |
+
+Returns:
+
+`Pick`
+
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md
index 9d18309fc07be..653f052dd5a3a 100644
--- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md
@@ -26,6 +26,7 @@ export declare class SearchInterceptor
| Method | Modifiers | Description |
| --- | --- | --- |
+| [getSerializableOptions(options)](./kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md) | | |
| [getTimeoutMode()](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) | | |
| [handleSearchError(e, options, isTimeout)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | |
| [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search
method. Overrides the AbortSignal
with one that will abort either when the request times out, or when the original AbortSignal
is aborted. Updates pendingCount$
when the request is started/finalized. |
diff --git a/examples/search_examples/common/index.ts b/examples/search_examples/common/index.ts
index dd953b1ec8982..cc47c0f575973 100644
--- a/examples/search_examples/common/index.ts
+++ b/examples/search_examples/common/index.ts
@@ -16,6 +16,7 @@ export interface IMyStrategyRequest extends IEsSearchRequest {
}
export interface IMyStrategyResponse extends IEsSearchResponse {
cool: string;
+ executed_at: number;
}
export const SERVER_SEARCH_ROUTE_PATH = '/api/examples/search';
diff --git a/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx
index 3bac445581ae7..8f31d242faf5e 100644
--- a/examples/search_examples/public/search/app.tsx
+++ b/examples/search_examples/public/search/app.tsx
@@ -111,7 +111,7 @@ export const SearchExamplesApp = ({
setSelectedNumericField(fields?.length ? getNumeric(fields)[0] : null);
}, [fields]);
- const doAsyncSearch = async (strategy?: string) => {
+ const doAsyncSearch = async (strategy?: string, sessionId?: string) => {
if (!indexPattern || !selectedNumericField) return;
// Construct the query portion of the search request
@@ -138,6 +138,7 @@ export const SearchExamplesApp = ({
const searchSubscription$ = data.search
.search(req, {
strategy,
+ sessionId,
})
.subscribe({
next: (res) => {
@@ -148,19 +149,30 @@ export const SearchExamplesApp = ({
? // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregation in the search response
res.rawResponse.aggregations[1].value
: undefined;
+ const isCool = (res as IMyStrategyResponse).cool;
+ const executedAt = (res as IMyStrategyResponse).executed_at;
const message = (
Searched {res.rawResponse.hits.total} documents.
The average of {selectedNumericField!.name} is{' '}
{avgResult ? Math.floor(avgResult) : 0}.
- Is it Cool? {String((res as IMyStrategyResponse).cool)}
+ {isCool ? `Is it Cool? ${isCool}` : undefined}
+
+
+ {executedAt ? `Executed at? ${executedAt}` : undefined}
+
);
- notifications.toasts.addSuccess({
- title: 'Query result',
- text: mountReactNode(message),
- });
+ notifications.toasts.addSuccess(
+ {
+ title: 'Query result',
+ text: mountReactNode(message),
+ },
+ {
+ toastLifeTimeMs: 300000,
+ }
+ );
searchSubscription$.unsubscribe();
} else if (isErrorResponse(res)) {
// TODO: Make response error status clearer
@@ -227,6 +239,10 @@ export const SearchExamplesApp = ({
doAsyncSearch('myStrategy');
};
+ const onClientSideSessionCacheClickHandler = () => {
+ doAsyncSearch('myStrategy', data.search.session.getSessionId());
+ };
+
const onServerClickHandler = async () => {
if (!indexPattern || !selectedNumericField) return;
try {
@@ -374,6 +390,45 @@ export const SearchExamplesApp = ({
+
+ Client side search session caching
+
+
+ data.search.session.start()}
+ iconType="alert"
+ data-test-subj="searchExamplesStartSession"
+ >
+
+
+ data.search.session.clear()}
+ iconType="alert"
+ data-test-subj="searchExamplesClearSession"
+ >
+
+
+
+
+
+
+
Using search on the server
diff --git a/examples/search_examples/server/my_strategy.ts b/examples/search_examples/server/my_strategy.ts
index 2cf039e99f6e9..0a64788960091 100644
--- a/examples/search_examples/server/my_strategy.ts
+++ b/examples/search_examples/server/my_strategy.ts
@@ -20,6 +20,7 @@ export const mySearchStrategyProvider = (
map((esSearchRes) => ({
...esSearchRes,
cool: request.get_cool ? 'YES' : 'NOPE',
+ executed_at: new Date().getTime(),
}))
),
cancel: async (id, options, deps) => {
diff --git a/src/plugins/data/common/search/tabify/tabify.ts b/src/plugins/data/common/search/tabify/tabify.ts
index 9f096886491ad..4a8972d4384c2 100644
--- a/src/plugins/data/common/search/tabify/tabify.ts
+++ b/src/plugins/data/common/search/tabify/tabify.ts
@@ -139,7 +139,7 @@ export function tabifyAggResponse(
const write = new TabbedAggResponseWriter(aggConfigs, respOpts || {});
const topLevelBucket: AggResponseBucket = {
...esResponse.aggregations,
- doc_count: esResponse.hits.total,
+ doc_count: esResponse.hits?.total,
};
collectBucket(aggConfigs, write, topLevelBucket, '', 1);
diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md
index d99d754a3364d..35f13fc855e99 100644
--- a/src/plugins/data/public/public.api.md
+++ b/src/plugins/data/public/public.api.md
@@ -2353,6 +2353,8 @@ export class SearchInterceptor {
// (undocumented)
protected readonly deps: SearchInterceptorDeps;
// (undocumented)
+ protected getSerializableOptions(options?: ISearchOptions): Pick;
+ // (undocumented)
protected getTimeoutMode(): TimeoutErrorMode;
// Warning: (ae-forgotten-export) The symbol "KibanaServerError" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "AbortError" needs to be exported by the entry point index.d.ts
diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts
index 3df2313f83798..e3fb31c9179fd 100644
--- a/src/plugins/data/public/search/search_interceptor.ts
+++ b/src/plugins/data/public/search/search_interceptor.ts
@@ -113,20 +113,14 @@ export class SearchInterceptor {
}
}
- /**
- * @internal
- * @throws `AbortError` | `ErrorLike`
- */
- protected runSearch(
- request: IKibanaSearchRequest,
- options?: ISearchOptions
- ): Promise {
- const { abortSignal, sessionId, ...requestOptions } = options || {};
+ protected getSerializableOptions(options?: ISearchOptions) {
+ const { sessionId, ...requestOptions } = options || {};
+
+ const serializableOptions: ISearchOptionsSerializable = {};
const combined = {
...requestOptions,
...this.deps.session.getSearchOptions(sessionId),
};
- const serializableOptions: ISearchOptionsSerializable = {};
if (combined.sessionId !== undefined) serializableOptions.sessionId = combined.sessionId;
if (combined.isRestore !== undefined) serializableOptions.isRestore = combined.isRestore;
@@ -135,10 +129,22 @@ export class SearchInterceptor {
if (combined.strategy !== undefined) serializableOptions.strategy = combined.strategy;
if (combined.isStored !== undefined) serializableOptions.isStored = combined.isStored;
+ return serializableOptions;
+ }
+
+ /**
+ * @internal
+ * @throws `AbortError` | `ErrorLike`
+ */
+ protected runSearch(
+ request: IKibanaSearchRequest,
+ options?: ISearchOptions
+ ): Promise {
+ const { abortSignal } = options || {};
return this.batchedFetch(
{
request,
- options: serializableOptions,
+ options: this.getSerializableOptions(options),
},
abortSignal
);
diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts
index 381410574ecda..71f51b4bc8d83 100644
--- a/src/plugins/data/public/search/session/session_service.ts
+++ b/src/plugins/data/public/search/session/session_service.ts
@@ -73,7 +73,7 @@ export interface SearchSessionIndicatorUiConfig {
}
/**
- * Responsible for tracking a current search session. Supports only a single session at a time.
+ * Responsible for tracking a current search session. Supports a single session at a time.
*/
export class SessionService {
public readonly state$: Observable;
diff --git a/test/api_integration/apis/saved_objects/find.ts b/test/api_integration/apis/saved_objects/find.ts
index a01562861e606..a4862707e2d0e 100644
--- a/test/api_integration/apis/saved_objects/find.ts
+++ b/test/api_integration/apis/saved_objects/find.ts
@@ -9,7 +9,6 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
import { SavedObject } from '../../../../src/core/server';
-import { getKibanaVersion } from './lib/saved_objects_test_utils';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
@@ -17,12 +16,6 @@ export default function ({ getService }: FtrProviderContext) {
const esDeleteAllIndices = getService('esDeleteAllIndices');
describe('find', () => {
- let KIBANA_VERSION: string;
-
- before(async () => {
- KIBANA_VERSION = await getKibanaVersion(getService);
- });
-
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
@@ -32,33 +25,9 @@ export default function ({ getService }: FtrProviderContext) {
.get('/api/saved_objects/_find?type=visualization&fields=title')
.expect(200)
.then((resp) => {
- expect(resp.body).to.eql({
- page: 1,
- per_page: 20,
- total: 1,
- saved_objects: [
- {
- type: 'visualization',
- id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
- version: 'WzE4LDJd',
- attributes: {
- title: 'Count of requests',
- },
- score: 0,
- migrationVersion: resp.body.saved_objects[0].migrationVersion,
- coreMigrationVersion: KIBANA_VERSION,
- namespaces: ['default'],
- references: [
- {
- id: '91200a00-9efd-11e7-acb3-3dab96693fab',
- name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
- type: 'index-pattern',
- },
- ],
- updated_at: '2017-09-21T18:51:23.794Z',
- },
- ],
- });
+ expect(resp.body.saved_objects.map((so: { id: string }) => so.id)).to.eql([
+ 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
+ ]);
expect(resp.body.saved_objects[0].migrationVersion).to.be.ok();
}));
@@ -129,33 +98,12 @@ export default function ({ getService }: FtrProviderContext) {
.get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=default')
.expect(200)
.then((resp) => {
- expect(resp.body).to.eql({
- page: 1,
- per_page: 20,
- total: 1,
- saved_objects: [
- {
- type: 'visualization',
- id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
- version: 'WzE4LDJd',
- attributes: {
- title: 'Count of requests',
- },
- migrationVersion: resp.body.saved_objects[0].migrationVersion,
- coreMigrationVersion: KIBANA_VERSION,
- namespaces: ['default'],
- score: 0,
- references: [
- {
- id: '91200a00-9efd-11e7-acb3-3dab96693fab',
- name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
- type: 'index-pattern',
- },
- ],
- updated_at: '2017-09-21T18:51:23.794Z',
- },
- ],
- });
+ expect(
+ resp.body.saved_objects.map((so: { id: string; namespaces: string[] }) => ({
+ id: so.id,
+ namespaces: so.namespaces,
+ }))
+ ).to.eql([{ id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', namespaces: ['default'] }]);
expect(resp.body.saved_objects[0].migrationVersion).to.be.ok();
}));
});
@@ -166,53 +114,15 @@ export default function ({ getService }: FtrProviderContext) {
.get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=*')
.expect(200)
.then((resp) => {
- expect(resp.body).to.eql({
- page: 1,
- per_page: 20,
- total: 2,
- saved_objects: [
- {
- type: 'visualization',
- id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
- version: 'WzE4LDJd',
- attributes: {
- title: 'Count of requests',
- },
- migrationVersion: resp.body.saved_objects[0].migrationVersion,
- coreMigrationVersion: KIBANA_VERSION,
- namespaces: ['default'],
- score: 0,
- references: [
- {
- id: '91200a00-9efd-11e7-acb3-3dab96693fab',
- name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
- type: 'index-pattern',
- },
- ],
- updated_at: '2017-09-21T18:51:23.794Z',
- },
- {
- attributes: {
- title: 'Count of requests',
- },
- id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
- migrationVersion: resp.body.saved_objects[0].migrationVersion,
- coreMigrationVersion: KIBANA_VERSION,
- namespaces: ['foo-ns'],
- references: [
- {
- id: '91200a00-9efd-11e7-acb3-3dab96693fab',
- name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
- type: 'index-pattern',
- },
- ],
- score: 0,
- type: 'visualization',
- updated_at: '2017-09-21T18:51:23.794Z',
- version: 'WzIyLDJd',
- },
- ],
- });
+ expect(
+ resp.body.saved_objects.map((so: { id: string; namespaces: string[] }) => ({
+ id: so.id,
+ namespaces: so.namespaces,
+ }))
+ ).to.eql([
+ { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', namespaces: ['default'] },
+ { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', namespaces: ['foo-ns'] },
+ ]);
}));
});
@@ -224,42 +134,9 @@ export default function ({ getService }: FtrProviderContext) {
)
.expect(200)
.then((resp) => {
- expect(resp.body).to.eql({
- page: 1,
- per_page: 20,
- total: 1,
- saved_objects: [
- {
- type: 'visualization',
- id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
- attributes: {
- title: 'Count of requests',
- visState: resp.body.saved_objects[0].attributes.visState,
- uiStateJSON: '{"spy":{"mode":{"name":null,"fill":false}}}',
- description: '',
- version: 1,
- kibanaSavedObjectMeta: {
- searchSourceJSON:
- resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta
- .searchSourceJSON,
- },
- },
- namespaces: ['default'],
- score: 0,
- references: [
- {
- name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
- type: 'index-pattern',
- id: '91200a00-9efd-11e7-acb3-3dab96693fab',
- },
- ],
- migrationVersion: resp.body.saved_objects[0].migrationVersion,
- coreMigrationVersion: KIBANA_VERSION,
- updated_at: '2017-09-21T18:51:23.794Z',
- version: 'WzE4LDJd',
- },
- ],
- });
+ expect(resp.body.saved_objects.map((so: { id: string }) => so.id)).to.eql([
+ 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
+ ]);
}));
it('wrong type should return 400 with Bad Request', async () =>
diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts
index 6ab2352ebb05f..8fb3884a5b37b 100644
--- a/test/api_integration/apis/saved_objects_management/find.ts
+++ b/test/api_integration/apis/saved_objects_management/find.ts
@@ -34,44 +34,9 @@ export default function ({ getService }: FtrProviderContext) {
.get('/api/kibana/management/saved_objects/_find?type=visualization&fields=title')
.expect(200)
.then((resp: Response) => {
- expect(resp.body).to.eql({
- page: 1,
- per_page: 20,
- total: 1,
- saved_objects: [
- {
- type: 'visualization',
- id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
- version: 'WzE4LDJd',
- attributes: {
- title: 'Count of requests',
- },
- migrationVersion: resp.body.saved_objects[0].migrationVersion,
- coreMigrationVersion: KIBANA_VERSION,
- namespaces: ['default'],
- references: [
- {
- id: '91200a00-9efd-11e7-acb3-3dab96693fab',
- name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
- type: 'index-pattern',
- },
- ],
- score: 0,
- updated_at: '2017-09-21T18:51:23.794Z',
- meta: {
- editUrl:
- '/management/kibana/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab',
- icon: 'visualizeApp',
- inAppUrl: {
- path: '/app/visualize#/edit/dd7caf20-9efd-11e7-acb3-3dab96693fab',
- uiCapabilitiesPath: 'visualize.show',
- },
- title: 'Count of requests',
- namespaceType: 'single',
- },
- },
- ],
- });
+ expect(resp.body.saved_objects.map((so: { id: string }) => so.id)).to.eql([
+ 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
+ ]);
}));
describe('unknown type', () => {
diff --git a/test/plugin_functional/test_suites/saved_objects_management/find.ts b/test/plugin_functional/test_suites/saved_objects_management/find.ts
index 5dce8f43339a1..e5a5d69c7e4d4 100644
--- a/test/plugin_functional/test_suites/saved_objects_management/find.ts
+++ b/test/plugin_functional/test_suites/saved_objects_management/find.ts
@@ -33,28 +33,17 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
.set('kbn-xsrf', 'true')
.expect(200)
.then((resp) => {
- expect(resp.body).to.eql({
- page: 1,
- per_page: 20,
- total: 1,
- saved_objects: [
- {
- type: 'test-hidden-importable-exportable',
- id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab',
- attributes: {
- title: 'Hidden Saved object type that is importable/exportable.',
- },
- references: [],
- updated_at: '2021-02-11T18:51:23.794Z',
- version: 'WzIsMl0=',
- namespaces: ['default'],
- score: 0,
- meta: {
- namespaceType: 'single',
- },
- },
- ],
- });
+ expect(
+ resp.body.saved_objects.map((so: { id: string; type: string }) => ({
+ id: so.id,
+ type: so.type,
+ }))
+ ).to.eql([
+ {
+ type: 'test-hidden-importable-exportable',
+ id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab',
+ },
+ ]);
}));
it('returns empty response for non importableAndExportable types', async () =>
@@ -65,12 +54,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
.set('kbn-xsrf', 'true')
.expect(200)
.then((resp) => {
- expect(resp.body).to.eql({
- page: 1,
- per_page: 20,
- total: 0,
- saved_objects: [],
- });
+ expect(resp.body.saved_objects).to.eql([]);
}));
});
});
diff --git a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts b/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts
index 68282c1e947f7..a52fdef9819b8 100644
--- a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts
+++ b/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts
@@ -21,13 +21,15 @@ describe('search abort controller', () => {
test('immediately aborts when passed an aborted signal in the constructor', () => {
const controller = new AbortController();
controller.abort();
- const sac = new SearchAbortController(controller.signal);
+ const sac = new SearchAbortController();
+ sac.addAbortSignal(controller.signal);
expect(sac.getSignal().aborted).toBe(true);
});
test('aborts when input signal is aborted', () => {
const controller = new AbortController();
- const sac = new SearchAbortController(controller.signal);
+ const sac = new SearchAbortController();
+ sac.addAbortSignal(controller.signal);
expect(sac.getSignal().aborted).toBe(false);
controller.abort();
expect(sac.getSignal().aborted).toBe(true);
@@ -35,7 +37,8 @@ describe('search abort controller', () => {
test('aborts when all input signals are aborted', () => {
const controller = new AbortController();
- const sac = new SearchAbortController(controller.signal);
+ const sac = new SearchAbortController();
+ sac.addAbortSignal(controller.signal);
const controller2 = new AbortController();
sac.addAbortSignal(controller2.signal);
@@ -48,7 +51,8 @@ describe('search abort controller', () => {
test('aborts explicitly even if all inputs are not aborted', () => {
const controller = new AbortController();
- const sac = new SearchAbortController(controller.signal);
+ const sac = new SearchAbortController();
+ sac.addAbortSignal(controller.signal);
const controller2 = new AbortController();
sac.addAbortSignal(controller2.signal);
@@ -60,7 +64,8 @@ describe('search abort controller', () => {
test('doesnt abort, if cleared', () => {
const controller = new AbortController();
- const sac = new SearchAbortController(controller.signal);
+ const sac = new SearchAbortController();
+ sac.addAbortSignal(controller.signal);
expect(sac.getSignal().aborted).toBe(false);
sac.cleanup();
controller.abort();
@@ -77,7 +82,7 @@ describe('search abort controller', () => {
});
test('doesnt abort on timeout, if cleared', () => {
- const sac = new SearchAbortController(undefined, 100);
+ const sac = new SearchAbortController(100);
expect(sac.getSignal().aborted).toBe(false);
sac.cleanup();
timeTravel(100);
@@ -85,7 +90,7 @@ describe('search abort controller', () => {
});
test('aborts on timeout, even if no signals passed in', () => {
- const sac = new SearchAbortController(undefined, 100);
+ const sac = new SearchAbortController(100);
expect(sac.getSignal().aborted).toBe(false);
timeTravel(100);
expect(sac.getSignal().aborted).toBe(true);
@@ -94,7 +99,8 @@ describe('search abort controller', () => {
test('aborts on timeout, even if there are unaborted signals', () => {
const controller = new AbortController();
- const sac = new SearchAbortController(controller.signal, 100);
+ const sac = new SearchAbortController(100);
+ sac.addAbortSignal(controller.signal);
expect(sac.getSignal().aborted).toBe(false);
timeTravel(100);
diff --git a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts b/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts
index 4482a7771dc28..7bc74b56a3903 100644
--- a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts
+++ b/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts
@@ -18,11 +18,7 @@ export class SearchAbortController {
private destroyed = false;
private reason?: AbortReason;
- constructor(abortSignal?: AbortSignal, timeout?: number) {
- if (abortSignal) {
- this.addAbortSignal(abortSignal);
- }
-
+ constructor(timeout?: number) {
if (timeout) {
this.timeoutSub = timer(timeout).subscribe(() => {
this.reason = AbortReason.Timeout;
@@ -41,6 +37,7 @@ export class SearchAbortController {
};
public cleanup() {
+ if (this.destroyed) return;
this.destroyed = true;
this.timeoutSub?.unsubscribe();
this.inputAbortSignals.forEach((abortSignal) => {
diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts
index 02671974e5053..0e511c545f3e2 100644
--- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts
+++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts
@@ -23,9 +23,12 @@ import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks
import { BehaviorSubject } from 'rxjs';
import * as xpackResourceNotFoundException from '../../common/search/test_data/search_phase_execution_exception.json';
-const timeTravel = (msToRun = 0) => {
+const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
+
+const timeTravel = async (msToRun = 0) => {
+ await flushPromises();
jest.advanceTimersByTime(msToRun);
- return new Promise((resolve) => setImmediate(resolve));
+ return flushPromises();
};
const next = jest.fn();
@@ -39,10 +42,20 @@ let fetchMock: jest.Mock;
jest.useFakeTimers();
+jest.mock('./utils', () => ({
+ createRequestHash: jest.fn().mockImplementation((input) => {
+ return Promise.resolve(JSON.stringify(input));
+ }),
+}));
+
function mockFetchImplementation(responses: any[]) {
let i = 0;
- fetchMock.mockImplementation(() => {
+ fetchMock.mockImplementation((r) => {
+ if (!r.request.id) i = 0;
const { time = 0, value = {}, isError = false } = responses[i++];
+ value.meta = {
+ size: 10,
+ };
return new Promise((resolve, reject) =>
setTimeout(() => {
return (isError ? reject : resolve)(value);
@@ -452,7 +465,7 @@ describe('EnhancedSearchInterceptor', () => {
});
});
- describe('session', () => {
+ describe('session tracking', () => {
beforeEach(() => {
const responses = [
{
@@ -559,4 +572,540 @@ describe('EnhancedSearchInterceptor', () => {
expect(sessionService.trackSearch).toBeCalledTimes(0);
});
});
+
+ describe('session client caching', () => {
+ const sessionId = 'sessionId';
+ const basicReq = {
+ params: {
+ test: 1,
+ },
+ };
+
+ const basicCompleteResponse = [
+ {
+ time: 10,
+ value: {
+ isPartial: false,
+ isRunning: false,
+ id: 1,
+ rawResponse: {
+ took: 1,
+ },
+ },
+ },
+ ];
+
+ const partialCompleteResponse = [
+ {
+ time: 10,
+ value: {
+ isPartial: true,
+ isRunning: true,
+ id: 1,
+ rawResponse: {
+ took: 1,
+ },
+ },
+ },
+ {
+ time: 20,
+ value: {
+ isPartial: false,
+ isRunning: false,
+ id: 1,
+ rawResponse: {
+ took: 1,
+ },
+ },
+ },
+ ];
+
+ beforeEach(() => {
+ sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId);
+ sessionService.getSessionId.mockImplementation(() => sessionId);
+ });
+
+ test('should be disabled if there is no session', async () => {
+ mockFetchImplementation(basicCompleteResponse);
+
+ searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete });
+ expect(fetchMock).toBeCalledTimes(1);
+
+ searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete });
+ expect(fetchMock).toBeCalledTimes(2);
+ });
+
+ test('should fetch different requests in a single session', async () => {
+ mockFetchImplementation(basicCompleteResponse);
+
+ const req2 = {
+ params: {
+ test: 2,
+ },
+ };
+
+ searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+
+ searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(2);
+ });
+
+ test('should fetch the same request for two different sessions', async () => {
+ mockFetchImplementation(basicCompleteResponse);
+
+ searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+
+ searchInterceptor
+ .search(basicReq, { sessionId: 'anotherSession' })
+ .subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(2);
+ });
+
+ test('should 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 req = {
+ params: {
+ test: 200,
+ },
+ };
+
+ const response = searchInterceptor.search(req, { pollInterval: 1, sessionId });
+ const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId });
+ 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();
+ 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(next).toBeCalledTimes(4);
+ expect(error).toBeCalledTimes(0);
+ expect(complete).toBeCalledTimes(2);
+ });
+
+ test('should cache partial responses', async () => {
+ const responses = [
+ {
+ time: 10,
+ value: {
+ isPartial: true,
+ isRunning: true,
+ id: 1,
+ },
+ },
+ ];
+
+ mockFetchImplementation(responses);
+
+ searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+
+ searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+ });
+
+ test('should not cache error responses', async () => {
+ const responses = [
+ {
+ time: 10,
+ value: {
+ isPartial: true,
+ isRunning: false,
+ id: 1,
+ },
+ },
+ ];
+
+ mockFetchImplementation(responses);
+
+ searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+
+ searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(2);
+ });
+
+ test('should deliver error to all replays', async () => {
+ const responses = [
+ {
+ time: 10,
+ value: {
+ isPartial: true,
+ isRunning: false,
+ id: 1,
+ },
+ },
+ ];
+
+ mockFetchImplementation(responses);
+
+ searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete });
+ searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+ expect(error).toBeCalledTimes(2);
+ expect(error.mock.calls[0][0].message).toEqual('Received partial response');
+ expect(error.mock.calls[1][0].message).toEqual('Received partial response');
+ });
+
+ test('should ignore anything outside params when hashing', async () => {
+ mockFetchImplementation(basicCompleteResponse);
+
+ const req = {
+ something: 123,
+ params: {
+ test: 1,
+ },
+ };
+
+ const req2 = {
+ something: 321,
+ params: {
+ test: 1,
+ },
+ };
+
+ searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+
+ searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+ });
+
+ test('should ignore preference when hashing', async () => {
+ mockFetchImplementation(basicCompleteResponse);
+
+ const req = {
+ params: {
+ test: 1,
+ preference: 123,
+ },
+ };
+
+ const req2 = {
+ params: {
+ test: 1,
+ preference: 321,
+ },
+ };
+
+ searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+
+ searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+ });
+
+ test('should return from cache for identical requests in the same session', async () => {
+ mockFetchImplementation(basicCompleteResponse);
+
+ searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+
+ searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+ });
+
+ test('aborting a search that didnt get any response should retrigger search', async () => {
+ mockFetchImplementation(basicCompleteResponse);
+
+ const abortController = new AbortController();
+
+ // Start a search request
+ searchInterceptor
+ .search(basicReq, { sessionId, abortSignal: abortController.signal })
+ .subscribe({ next, error, complete });
+
+ // Abort the search request before it started
+ abortController.abort();
+
+ // Time travel to make sure nothing appens
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(0);
+ expect(next).toBeCalledTimes(0);
+ expect(error).toBeCalledTimes(1);
+ expect(complete).toBeCalledTimes(0);
+
+ const error2 = jest.fn();
+ const next2 = jest.fn();
+ const complete2 = jest.fn();
+
+ // Search for the same thing again
+ searchInterceptor
+ .search(basicReq, { sessionId })
+ .subscribe({ next: next2, error: error2, complete: complete2 });
+
+ // Should search again
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+ expect(next2).toBeCalledTimes(1);
+ expect(error2).toBeCalledTimes(0);
+ expect(complete2).toBeCalledTimes(1);
+ });
+
+ test('aborting a running first search shouldnt clear cache', async () => {
+ mockFetchImplementation(partialCompleteResponse);
+ sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId);
+ sessionService.getSessionId.mockImplementation(() => sessionId);
+
+ const untrack = jest.fn();
+ sessionService.trackSearch.mockImplementation(() => untrack);
+
+ const req = {
+ params: {
+ test: 200,
+ },
+ };
+
+ const abortController = new AbortController();
+
+ const response = searchInterceptor.search(req, {
+ pollInterval: 1,
+ sessionId,
+ abortSignal: abortController.signal,
+ });
+ response.subscribe({ next, error, complete });
+ await timeTravel(10);
+
+ expect(fetchMock).toBeCalledTimes(1);
+ expect(next).toBeCalledTimes(1);
+ 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();
+ const complete2 = jest.fn();
+ const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId });
+ response2.subscribe({ next: next2, error: error2, complete: complete2 });
+ await timeTravel(0);
+
+ abortController.abort();
+
+ await timeTravel(300);
+ // Both searches should be tracked and untracked
+ expect(sessionService.trackSearch).toBeCalledTimes(2);
+ expect(untrack).toBeCalledTimes(2);
+
+ // First search should error
+ expect(next).toBeCalledTimes(1);
+ expect(error).toBeCalledTimes(1);
+ expect(complete).toBeCalledTimes(0);
+
+ // Second search should complete
+ expect(next2).toBeCalledTimes(2);
+ expect(error2).toBeCalledTimes(0);
+ expect(complete2).toBeCalledTimes(1);
+
+ // Should be called only 2 times (once per partial response)
+ expect(fetchMock).toBeCalledTimes(2);
+ });
+
+ test('aborting a running second search shouldnt clear cache', async () => {
+ mockFetchImplementation(partialCompleteResponse);
+ sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId);
+ sessionService.getSessionId.mockImplementation(() => sessionId);
+
+ const untrack = jest.fn();
+ sessionService.trackSearch.mockImplementation(() => untrack);
+
+ const req = {
+ params: {
+ test: 200,
+ },
+ };
+
+ const abortController = new AbortController();
+
+ const response = searchInterceptor.search(req, { pollInterval: 1, sessionId });
+ response.subscribe({ next, error, complete });
+ await timeTravel(10);
+
+ expect(fetchMock).toBeCalledTimes(1);
+ expect(next).toBeCalledTimes(1);
+ 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();
+ const complete2 = jest.fn();
+ const response2 = searchInterceptor.search(req, {
+ pollInterval: 0,
+ sessionId,
+ abortSignal: abortController.signal,
+ });
+ response2.subscribe({ next: next2, error: error2, complete: complete2 });
+ await timeTravel(0);
+
+ abortController.abort();
+
+ await timeTravel(300);
+ expect(sessionService.trackSearch).toBeCalledTimes(2);
+ expect(untrack).toBeCalledTimes(2);
+
+ expect(next).toBeCalledTimes(2);
+ expect(error).toBeCalledTimes(0);
+ expect(complete).toBeCalledTimes(1);
+
+ expect(next2).toBeCalledTimes(1);
+ expect(error2).toBeCalledTimes(1);
+ expect(complete2).toBeCalledTimes(0);
+
+ // Should be called only 2 times (once per partial response)
+ expect(fetchMock).toBeCalledTimes(2);
+ });
+
+ test('aborting both requests should cancel underlaying search only once', async () => {
+ mockFetchImplementation(partialCompleteResponse);
+ sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId);
+ sessionService.getSessionId.mockImplementation(() => sessionId);
+ sessionService.trackSearch.mockImplementation(() => jest.fn());
+
+ const req = {
+ params: {
+ test: 200,
+ },
+ };
+
+ const abortController = new AbortController();
+
+ const response = searchInterceptor.search(req, {
+ pollInterval: 1,
+ sessionId,
+ abortSignal: abortController.signal,
+ });
+ response.subscribe({ next, error, complete });
+
+ const response2 = searchInterceptor.search(req, {
+ pollInterval: 1,
+ sessionId,
+ abortSignal: abortController.signal,
+ });
+ response2.subscribe({ next, error, complete });
+ await timeTravel(10);
+
+ abortController.abort();
+
+ await timeTravel(300);
+
+ expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1);
+ });
+
+ test('aborting both searches should stop searching and clear cache', async () => {
+ mockFetchImplementation(partialCompleteResponse);
+ sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId);
+ sessionService.getSessionId.mockImplementation(() => sessionId);
+
+ const untrack = jest.fn();
+ sessionService.trackSearch.mockImplementation(() => untrack);
+
+ const req = {
+ params: {
+ test: 200,
+ },
+ };
+
+ const abortController = new AbortController();
+
+ const response = searchInterceptor.search(req, {
+ pollInterval: 1,
+ sessionId,
+ abortSignal: abortController.signal,
+ });
+ response.subscribe({ next, error, complete });
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+
+ const response2 = searchInterceptor.search(req, {
+ pollInterval: 1,
+ sessionId,
+ abortSignal: abortController.signal,
+ });
+ response2.subscribe({ next, error, complete });
+ await timeTravel(0);
+ expect(fetchMock).toBeCalledTimes(1);
+
+ abortController.abort();
+
+ await timeTravel(300);
+
+ expect(next).toBeCalledTimes(2);
+ expect(error).toBeCalledTimes(2);
+ expect(complete).toBeCalledTimes(0);
+ expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError);
+ expect(error.mock.calls[1][0]).toBeInstanceOf(AbortError);
+
+ // Should be called only 1 times (one partial response)
+ expect(fetchMock).toBeCalledTimes(1);
+
+ // Clear mock and research
+ fetchMock.mockReset();
+ mockFetchImplementation(partialCompleteResponse);
+ // Run the search again to see that we don't hit the cache
+ const response3 = searchInterceptor.search(req, { pollInterval: 1, sessionId });
+ response3.subscribe({ next, error, complete });
+
+ await timeTravel(10);
+ await timeTravel(10);
+ await timeTravel(300);
+
+ // Should be called 2 times (two partial response)
+ expect(fetchMock).toBeCalledTimes(2);
+ expect(complete).toBeCalledTimes(1);
+ });
+
+ test('aborting a completed search shouldnt effect cache', async () => {
+ mockFetchImplementation(basicCompleteResponse);
+
+ const abortController = new AbortController();
+
+ // Start a search request
+ searchInterceptor
+ .search(basicReq, { sessionId, abortSignal: abortController.signal })
+ .subscribe({ next, error, complete });
+
+ // Get a final response
+ await timeTravel(10);
+ expect(fetchMock).toBeCalledTimes(1);
+
+ // Abort the search request
+ abortController.abort();
+
+ // Search for the same thing again
+ searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete });
+
+ // Get the response from cache
+ expect(fetchMock).toBeCalledTimes(1);
+ });
+ });
});
diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts
index b9d8553d3dc5a..3e7564933a0c6 100644
--- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts
+++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts
@@ -6,8 +6,19 @@
*/
import { once } from 'lodash';
-import { throwError, Subscription } from 'rxjs';
-import { tap, finalize, catchError, filter, take, skip } from 'rxjs/operators';
+import { throwError, Subscription, from, of, fromEvent, EMPTY } from 'rxjs';
+import {
+ tap,
+ finalize,
+ catchError,
+ filter,
+ take,
+ skip,
+ switchMap,
+ shareReplay,
+ map,
+ takeUntil,
+} from 'rxjs/operators';
import {
TimeoutErrorMode,
SearchInterceptor,
@@ -16,12 +27,21 @@ import {
IKibanaSearchRequest,
SearchSessionState,
} from '../../../../../src/plugins/data/public';
+import { AbortError } from '../../../../../src/plugins/kibana_utils/public';
import { ENHANCED_ES_SEARCH_STRATEGY, IAsyncSearchOptions, pollSearch } from '../../common';
+import { SearchResponseCache } from './search_response_cache';
+import { createRequestHash } from './utils';
import { SearchAbortController } from './search_abort_controller';
+const MAX_CACHE_ITEMS = 50;
+const MAX_CACHE_SIZE_MB = 10;
export class EnhancedSearchInterceptor extends SearchInterceptor {
private uiSettingsSub: Subscription;
private searchTimeout: number;
+ private readonly responseCache: SearchResponseCache = new SearchResponseCache(
+ MAX_CACHE_ITEMS,
+ MAX_CACHE_SIZE_MB
+ );
/**
* @internal
@@ -38,6 +58,7 @@ export class EnhancedSearchInterceptor extends SearchInterceptor {
}
public stop() {
+ this.responseCache.clear();
this.uiSettingsSub.unsubscribe();
}
@@ -47,19 +68,31 @@ export class EnhancedSearchInterceptor extends SearchInterceptor {
: TimeoutErrorMode.CONTACT;
}
- public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) {
- const searchOptions = {
- strategy: ENHANCED_ES_SEARCH_STRATEGY,
- ...options,
+ private createRequestHash$(request: IKibanaSearchRequest, options: IAsyncSearchOptions) {
+ const { sessionId, isRestore } = 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,
};
- const { sessionId, strategy, abortSignal } = searchOptions;
- const search = () => this.runSearch({ id, ...request }, searchOptions);
- const searchAbortController = new SearchAbortController(abortSignal, this.searchTimeout);
- this.pendingCount$.next(this.pendingCount$.getValue() + 1);
- const untrackSearch = this.deps.session.isCurrentSession(options.sessionId)
- ? this.deps.session.trackSearch({ abort: () => searchAbortController.abort() })
- : undefined;
+ return from(sessionId ? createRequestHash(hashOptions) : of(undefined));
+ }
+
+ /**
+ * @internal
+ * Creates a new pollSearch that share replays its results
+ */
+ private runSearch$(
+ { id, ...request }: IKibanaSearchRequest,
+ options: IAsyncSearchOptions,
+ searchAbortController: SearchAbortController
+ ) {
+ const search = () => this.runSearch({ id, ...request }, options);
+ const { sessionId, strategy } = options;
// 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
@@ -91,18 +124,97 @@ export class EnhancedSearchInterceptor extends SearchInterceptor {
tap((response) => (id = response.id)),
catchError((e: Error) => {
cancel();
- return throwError(this.handleSearchError(e, options, searchAbortController.isTimeout()));
+ return throwError(e);
}),
finalize(() => {
- this.pendingCount$.next(this.pendingCount$.getValue() - 1);
searchAbortController.cleanup();
- if (untrackSearch && this.deps.session.isCurrentSession(options.sessionId)) {
- // untrack if this search still belongs to current session
- untrackSearch();
- }
if (savedToBackgroundSub) {
savedToBackgroundSub.unsubscribe();
}
+ }),
+ // This observable is cached in the responseCache.
+ // Using shareReplay makes sure that future subscribers will get the final response
+
+ shareReplay(1)
+ );
+ }
+
+ /**
+ * @internal
+ * Creates a new search observable and a corresponding search abort controller
+ * If requestHash is defined, tries to return them first from cache.
+ */
+ private getSearchResponse$(
+ request: IKibanaSearchRequest,
+ options: IAsyncSearchOptions,
+ requestHash?: string
+ ) {
+ const cached = requestHash ? this.responseCache.get(requestHash) : undefined;
+
+ const searchAbortController =
+ cached?.searchAbortController || new SearchAbortController(this.searchTimeout);
+
+ // Create a new abort signal if one was not passed. This fake signal will never be aborted,
+ // So the underlaying search will not be aborted, even if the other consumers abort.
+ searchAbortController.addAbortSignal(options.abortSignal ?? new AbortController().signal);
+ const response$ = cached?.response$ || this.runSearch$(request, options, searchAbortController);
+
+ if (requestHash && !this.responseCache.has(requestHash)) {
+ this.responseCache.set(requestHash, {
+ response$,
+ searchAbortController,
+ });
+ }
+
+ return {
+ response$,
+ searchAbortController,
+ };
+ }
+
+ public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) {
+ const searchOptions = {
+ strategy: ENHANCED_ES_SEARCH_STRATEGY,
+ ...options,
+ };
+ const { sessionId, abortSignal } = searchOptions;
+
+ return this.createRequestHash$(request, searchOptions).pipe(
+ switchMap((requestHash) => {
+ const { searchAbortController, response$ } = this.getSearchResponse$(
+ request,
+ searchOptions,
+ requestHash
+ );
+
+ 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.
+ const aborted$ = (abortSignal ? fromEvent(abortSignal, 'abort') : EMPTY).pipe(
+ map(() => {
+ throw new AbortError();
+ })
+ );
+
+ return response$.pipe(
+ takeUntil(aborted$),
+ catchError((e) => {
+ return throwError(
+ this.handleSearchError(e, searchOptions, searchAbortController.isTimeout())
+ );
+ }),
+ 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();
+ }
+ })
+ );
})
);
}
diff --git a/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts b/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts
new file mode 100644
index 0000000000000..e985de5e23f7d
--- /dev/null
+++ b/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts
@@ -0,0 +1,318 @@
+/*
+ * 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 { interval, Observable, of, throwError } from 'rxjs';
+import { shareReplay, switchMap, take } from 'rxjs/operators';
+import { IKibanaSearchResponse } from 'src/plugins/data/public';
+import { SearchAbortController } from './search_abort_controller';
+import { SearchResponseCache } from './search_response_cache';
+
+describe('SearchResponseCache', () => {
+ let cache: SearchResponseCache;
+ let searchAbortController: SearchAbortController;
+ const r: Array> = [
+ {
+ isPartial: true,
+ isRunning: true,
+ rawResponse: {
+ t: 1,
+ },
+ },
+ {
+ isPartial: true,
+ isRunning: true,
+ rawResponse: {
+ t: 2,
+ },
+ },
+ {
+ isPartial: true,
+ isRunning: true,
+ rawResponse: {
+ t: 3,
+ },
+ },
+ {
+ isPartial: false,
+ isRunning: false,
+ rawResponse: {
+ t: 4,
+ },
+ },
+ ];
+
+ function getSearchObservable$(responses: Array> = r) {
+ return interval(100).pipe(
+ take(responses.length),
+ switchMap((value: number, i: number) => {
+ if (responses[i].rawResponse.throw === true) {
+ return throwError('nooo');
+ } else {
+ return of(responses[i]);
+ }
+ }),
+ shareReplay(1)
+ );
+ }
+
+ function wrapWithAbortController(response$: Observable>) {
+ return {
+ response$,
+ searchAbortController,
+ };
+ }
+
+ beforeEach(() => {
+ cache = new SearchResponseCache(3, 0.1);
+ searchAbortController = new SearchAbortController();
+ });
+
+ describe('Cache eviction', () => {
+ test('clear evicts all', () => {
+ const finalResult = r[r.length - 1];
+ cache.set('123', wrapWithAbortController(of(finalResult)));
+ cache.set('234', wrapWithAbortController(of(finalResult)));
+
+ cache.clear();
+
+ expect(cache.get('123')).toBeUndefined();
+ expect(cache.get('234')).toBeUndefined();
+ });
+
+ test('evicts searches that threw an exception', async () => {
+ const res$ = getSearchObservable$();
+ const err$ = getSearchObservable$([
+ {
+ isPartial: true,
+ isRunning: true,
+ rawResponse: {
+ t: 'a'.repeat(1000),
+ },
+ },
+ {
+ isPartial: true,
+ isRunning: true,
+ rawResponse: {
+ throw: true,
+ },
+ },
+ ]);
+ cache.set('123', wrapWithAbortController(err$));
+ cache.set('234', wrapWithAbortController(res$));
+
+ const errHandler = jest.fn();
+ await err$.toPromise().catch(errHandler);
+ await res$.toPromise().catch(errHandler);
+
+ expect(errHandler).toBeCalledTimes(1);
+ expect(cache.get('123')).toBeUndefined();
+ expect(cache.get('234')).not.toBeUndefined();
+ });
+
+ test('evicts searches that returned an error response', async () => {
+ const err$ = getSearchObservable$([
+ {
+ isPartial: true,
+ isRunning: true,
+ rawResponse: {
+ t: 1,
+ },
+ },
+ {
+ isPartial: true,
+ isRunning: false,
+ rawResponse: {
+ t: 2,
+ },
+ },
+ ]);
+ cache.set('123', wrapWithAbortController(err$));
+
+ const errHandler = jest.fn();
+ await err$.toPromise().catch(errHandler);
+
+ expect(errHandler).toBeCalledTimes(0);
+ expect(cache.get('123')).toBeUndefined();
+ });
+
+ test('evicts oldest item if has too many cached items', async () => {
+ const finalResult = r[r.length - 1];
+ cache.set('123', wrapWithAbortController(of(finalResult)));
+ cache.set('234', wrapWithAbortController(of(finalResult)));
+ cache.set('345', wrapWithAbortController(of(finalResult)));
+ cache.set('456', wrapWithAbortController(of(finalResult)));
+
+ expect(cache.get('123')).toBeUndefined();
+ expect(cache.get('234')).not.toBeUndefined();
+ expect(cache.get('345')).not.toBeUndefined();
+ expect(cache.get('456')).not.toBeUndefined();
+ });
+
+ test('evicts oldest item if cache gets bigger than max size', async () => {
+ const largeResult$ = getSearchObservable$([
+ {
+ isPartial: true,
+ isRunning: true,
+ rawResponse: {
+ t: 'a'.repeat(1000),
+ },
+ },
+ {
+ isPartial: false,
+ isRunning: false,
+ rawResponse: {
+ t: 'a'.repeat(50000),
+ },
+ },
+ ]);
+
+ cache.set('123', wrapWithAbortController(largeResult$));
+ cache.set('234', wrapWithAbortController(largeResult$));
+ cache.set('345', wrapWithAbortController(largeResult$));
+
+ await largeResult$.toPromise();
+
+ expect(cache.get('123')).toBeUndefined();
+ expect(cache.get('234')).not.toBeUndefined();
+ expect(cache.get('345')).not.toBeUndefined();
+ });
+
+ test('evicts from cache any single item that gets bigger than max size', async () => {
+ const largeResult$ = getSearchObservable$([
+ {
+ isPartial: true,
+ isRunning: true,
+ rawResponse: {
+ t: 'a'.repeat(500),
+ },
+ },
+ {
+ isPartial: false,
+ isRunning: false,
+ rawResponse: {
+ t: 'a'.repeat(500000),
+ },
+ },
+ ]);
+
+ cache.set('234', wrapWithAbortController(largeResult$));
+ await largeResult$.toPromise();
+ expect(cache.get('234')).toBeUndefined();
+ });
+
+ test('get updates the insertion time of an item', async () => {
+ const finalResult = r[r.length - 1];
+ cache.set('123', wrapWithAbortController(of(finalResult)));
+ cache.set('234', wrapWithAbortController(of(finalResult)));
+ cache.set('345', wrapWithAbortController(of(finalResult)));
+
+ cache.get('123');
+ cache.get('234');
+
+ cache.set('456', wrapWithAbortController(of(finalResult)));
+
+ expect(cache.get('123')).not.toBeUndefined();
+ expect(cache.get('234')).not.toBeUndefined();
+ expect(cache.get('345')).toBeUndefined();
+ expect(cache.get('456')).not.toBeUndefined();
+ });
+ });
+
+ describe('Observable behavior', () => {
+ test('caches a response and re-emits it', async () => {
+ const s$ = getSearchObservable$();
+ cache.set('123', wrapWithAbortController(s$));
+ const finalRes = await cache.get('123')!.response$.toPromise();
+ expect(finalRes).toStrictEqual(r[r.length - 1]);
+ });
+
+ test('cached$ should emit same as original search$', async () => {
+ const s$ = getSearchObservable$();
+ cache.set('123', wrapWithAbortController(s$));
+
+ const next = jest.fn();
+ const cached$ = cache.get('123');
+
+ cached$!.response$.subscribe({
+ next,
+ });
+
+ // wait for original search to complete
+ await s$!.toPromise();
+
+ // get final response from cached$
+ const finalRes = await cached$!.response$.toPromise();
+ expect(finalRes).toStrictEqual(r[r.length - 1]);
+ expect(next).toHaveBeenCalledTimes(4);
+ });
+
+ test('cached$ should emit only current value and keep emitting if subscribed while search$ is running', async () => {
+ const s$ = getSearchObservable$();
+ cache.set('123', wrapWithAbortController(s$));
+
+ const next = jest.fn();
+ let cached$: Observable> | undefined;
+ s$.subscribe({
+ next: (res) => {
+ if (res.rawResponse.t === 3) {
+ cached$ = cache.get('123')!.response$;
+ cached$!.subscribe({
+ next,
+ });
+ }
+ },
+ });
+
+ // wait for original search to complete
+ await s$!.toPromise();
+
+ const finalRes = await cached$!.toPromise();
+
+ expect(finalRes).toStrictEqual(r[r.length - 1]);
+ expect(next).toHaveBeenCalledTimes(2);
+ });
+
+ test('cached$ should emit only last value if subscribed after search$ was complete 1', async () => {
+ const finalResult = r[r.length - 1];
+ const s$ = wrapWithAbortController(of(finalResult));
+ cache.set('123', s$);
+
+ // wait for original search to complete
+ await s$!.response$.toPromise();
+
+ const next = jest.fn();
+ const cached$ = cache.get('123');
+ cached$!.response$.subscribe({
+ next,
+ });
+
+ const finalRes = await cached$!.response$.toPromise();
+
+ expect(finalRes).toStrictEqual(r[r.length - 1]);
+ expect(next).toHaveBeenCalledTimes(1);
+ });
+
+ test('cached$ should emit only last value if subscribed after search$ was complete', async () => {
+ const s$ = getSearchObservable$();
+ cache.set('123', wrapWithAbortController(s$));
+
+ // wait for original search to complete
+ await s$!.toPromise();
+
+ const next = jest.fn();
+ const cached$ = cache.get('123');
+ cached$!.response$.subscribe({
+ next,
+ });
+
+ const finalRes = await cached$!.response$.toPromise();
+
+ expect(finalRes).toStrictEqual(r[r.length - 1]);
+ expect(next).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts b/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts
new file mode 100644
index 0000000000000..1467e5bf234ff
--- /dev/null
+++ b/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts
@@ -0,0 +1,136 @@
+/*
+ * 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 { Observable, Subscription } from 'rxjs';
+import { IKibanaSearchResponse, isErrorResponse } from '../../../../../src/plugins/data/public';
+import { SearchAbortController } from './search_abort_controller';
+
+interface ResponseCacheItem {
+ response$: Observable>;
+ searchAbortController: SearchAbortController;
+}
+
+interface ResponseCacheItemInternal {
+ response$: Observable>;
+ searchAbortController: SearchAbortController;
+ size: number;
+ subs: Subscription;
+}
+
+export class SearchResponseCache {
+ private responseCache: Map;
+ private cacheSize = 0;
+
+ constructor(private maxItems: number, private maxCacheSizeMB: number) {
+ this.responseCache = new Map();
+ }
+
+ private byteToMb(size: number) {
+ return size / (1024 * 1024);
+ }
+
+ private deleteItem(key: string, clearSubs = true) {
+ const item = this.responseCache.get(key);
+ if (item) {
+ if (clearSubs) {
+ item.subs.unsubscribe();
+ }
+ this.cacheSize -= item.size;
+ this.responseCache.delete(key);
+ }
+ }
+
+ private setItem(key: string, item: ResponseCacheItemInternal) {
+ // The deletion of the key will move it to the end of the Map's entries.
+ this.deleteItem(key, false);
+ this.cacheSize += item.size;
+ this.responseCache.set(key, item);
+ }
+
+ public clear() {
+ this.cacheSize = 0;
+ this.responseCache.forEach((item) => {
+ item.subs.unsubscribe();
+ });
+ this.responseCache.clear();
+ }
+
+ private shrink() {
+ while (
+ this.responseCache.size > this.maxItems ||
+ this.byteToMb(this.cacheSize) > this.maxCacheSizeMB
+ ) {
+ const [key] = [...this.responseCache.keys()];
+ this.deleteItem(key);
+ }
+ }
+
+ public has(key: string) {
+ return this.responseCache.has(key);
+ }
+
+ /**
+ *
+ * @param key key to cache
+ * @param response$
+ * @returns A ReplaySubject that mimics the behavior of the original observable
+ * @throws error if key already exists
+ */
+ public set(key: string, item: ResponseCacheItem) {
+ if (this.responseCache.has(key)) {
+ throw new Error('duplicate key');
+ }
+
+ const { response$, searchAbortController } = item;
+
+ const cacheItem: ResponseCacheItemInternal = {
+ response$,
+ searchAbortController,
+ subs: new Subscription(),
+ size: 0,
+ };
+
+ this.setItem(key, cacheItem);
+
+ cacheItem.subs.add(
+ response$.subscribe({
+ next: (r) => {
+ // TODO: avoid stringiying. Get the size some other way!
+ const newSize = new Blob([JSON.stringify(r)]).size;
+ if (this.byteToMb(newSize) < this.maxCacheSizeMB && !isErrorResponse(r)) {
+ this.setItem(key, {
+ ...cacheItem,
+ size: newSize,
+ });
+ this.shrink();
+ } else {
+ // Single item is too large to be cached, or an error response returned.
+ // Evict and ignore.
+ this.deleteItem(key);
+ }
+ },
+ error: (e) => {
+ // Evict item on error
+ this.deleteItem(key);
+ },
+ })
+ );
+ this.shrink();
+ }
+
+ public get(key: string): ResponseCacheItem | undefined {
+ const item = this.responseCache.get(key);
+ if (item) {
+ // touch the item, and move it to the end of the map's entries
+ this.setItem(key, item);
+ return {
+ response$: item.response$,
+ searchAbortController: item.searchAbortController,
+ };
+ }
+ }
+}
diff --git a/x-pack/plugins/data_enhanced/public/search/utils.ts b/x-pack/plugins/data_enhanced/public/search/utils.ts
new file mode 100644
index 0000000000000..c6c648dbb5488
--- /dev/null
+++ b/x-pack/plugins/data_enhanced/public/search/utils.ts
@@ -0,0 +1,15 @@
+/*
+ * 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 stringify from 'json-stable-stringify';
+
+export async function createRequestHash(keys: Record) {
+ const msgBuffer = new TextEncoder().encode(stringify(keys));
+ const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ return hashArray.map((b) => ('00' + b.toString(16)).slice(-2)).join('');
+}
diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx
index 39163101fc7bd..8caa1737c00ad 100644
--- a/x-pack/plugins/lens/public/app_plugin/app.tsx
+++ b/x-pack/plugins/lens/public/app_plugin/app.tsx
@@ -82,6 +82,8 @@ export function App({
dashboardFeatureFlag,
} = useKibana().services;
+ const startSession = useCallback(() => data.search.session.start(), [data]);
+
const [state, setState] = useState(() => {
return {
query: data.query.queryString.getQuery(),
@@ -96,7 +98,7 @@ export function App({
isSaveModalVisible: false,
indicateNoData: false,
isSaveable: false,
- searchSessionId: data.search.session.start(),
+ searchSessionId: startSession(),
};
});
@@ -178,7 +180,7 @@ export function App({
setState((s) => ({
...s,
filters: data.query.filterManager.getFilters(),
- searchSessionId: data.search.session.start(),
+ searchSessionId: startSession(),
}));
trackUiEvent('app_filters_updated');
},
@@ -188,7 +190,7 @@ export function App({
next: () => {
setState((s) => ({
...s,
- searchSessionId: data.search.session.start(),
+ searchSessionId: startSession(),
}));
},
});
@@ -199,7 +201,7 @@ export function App({
tap(() => {
setState((s) => ({
...s,
- searchSessionId: data.search.session.start(),
+ searchSessionId: startSession(),
}));
}),
switchMap((done) =>
@@ -234,6 +236,7 @@ export function App({
data.query,
history,
initialContext,
+ startSession,
]);
useEffect(() => {
@@ -652,7 +655,7 @@ export function App({
// Time change will be picked up by the time subscription
setState((s) => ({
...s,
- searchSessionId: data.search.session.start(),
+ searchSessionId: startSession(),
}));
trackUiEvent('app_query_change');
}
diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/attach_to_case.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/attach_to_case.spec.ts
index e63ef513cc638..bdf2ab96600ea 100644
--- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/attach_to_case.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/attach_to_case.spec.ts
@@ -32,7 +32,7 @@ describe('Alerts timeline', () => {
waitForAlertsIndexToBeCreated();
createCustomRuleActivated(newRule);
refreshPage();
- waitForAlertsToPopulate();
+ waitForAlertsToPopulate(500);
// Then we login as read-only user to test.
login(ROLES.reader);
diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts
index b7c0e1c6fcd6e..741f05129f9c4 100644
--- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts
@@ -39,9 +39,9 @@ describe('Closing alerts', () => {
loginAndWaitForPage(DETECTIONS_URL);
waitForAlertsPanelToBeLoaded();
waitForAlertsIndexToBeCreated();
- createCustomRuleActivated(newRule);
+ createCustomRuleActivated(newRule, '1', '100m', 100);
refreshPage();
- waitForAlertsToPopulate();
+ waitForAlertsToPopulate(100);
deleteCustomRule();
});
diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/in_progress.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/in_progress.spec.ts
index 8efdbe82c3492..b4f890e4d8dbf 100644
--- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/in_progress.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/in_progress.spec.ts
@@ -38,7 +38,7 @@ describe('Marking alerts as in-progress', () => {
waitForAlertsIndexToBeCreated();
createCustomRuleActivated(newRule);
refreshPage();
- waitForAlertsToPopulate();
+ waitForAlertsToPopulate(500);
});
it('Mark one alert in progress when more than one open alerts are selected', () => {
diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/investigate_in_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/investigate_in_timeline.spec.ts
index bc4929cd1341d..d705cb652d2ea 100644
--- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/investigate_in_timeline.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/investigate_in_timeline.spec.ts
@@ -29,7 +29,7 @@ describe('Alerts timeline', () => {
waitForAlertsIndexToBeCreated();
createCustomRuleActivated(newRule);
refreshPage();
- waitForAlertsToPopulate();
+ waitForAlertsToPopulate(500);
});
it('Investigate alert in default timeline', () => {
diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts
index ec0923beb4c40..bc907dccd0a04 100644
--- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts
@@ -39,7 +39,7 @@ describe('Opening alerts', () => {
waitForAlertsIndexToBeCreated();
createCustomRuleActivated(newRule);
refreshPage();
- waitForAlertsToPopulate();
+ waitForAlertsToPopulate(500);
selectNumberOfAlerts(5);
cy.get(SELECTED_ALERTS).should('have.text', `Selected 5 alerts`);
diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/from_alert.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/from_alert.spec.ts
index d5e0b56b8e267..e36809380df86 100644
--- a/x-pack/plugins/security_solution/cypress/integration/exceptions/from_alert.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/from_alert.spec.ts
@@ -43,7 +43,7 @@ describe('From alert', () => {
cleanKibana();
loginAndWaitForPageWithoutDateRange(DETECTIONS_URL);
waitForAlertsIndexToBeCreated();
- createCustomRule(newRule);
+ createCustomRule(newRule, 'rule_testing', '10s');
goToManageAlertsDetectionRules();
goToRuleDetails();
diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/from_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/from_rule.spec.ts
index 148254a813b56..e0d7e5a32edfd 100644
--- a/x-pack/plugins/security_solution/cypress/integration/exceptions/from_rule.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/from_rule.spec.ts
@@ -41,7 +41,7 @@ describe('From rule', () => {
cleanKibana();
loginAndWaitForPageWithoutDateRange(DETECTIONS_URL);
waitForAlertsIndexToBeCreated();
- createCustomRule(newRule);
+ createCustomRule(newRule, 'rule_testing', '10s');
goToManageAlertsDetectionRules();
goToRuleDetails();
diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts
index f083cc5da6f53..099cd39ba2d7b 100644
--- a/x-pack/plugins/security_solution/cypress/objects/rule.ts
+++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts
@@ -185,7 +185,7 @@ export const existingRule: CustomRule = {
name: 'Rule 1',
description: 'Description for Rule 1',
index: ['auditbeat-*'],
- interval: '10s',
+ interval: '100m',
severity: 'High',
riskScore: '19',
tags: ['rule1'],
@@ -332,5 +332,5 @@ export const editedRule = {
export const expectedExportedRule = (ruleResponse: Cypress.Response) => {
const jsonrule = ruleResponse.body;
- return `{"id":"${jsonrule.id}","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","created_at":"${jsonrule.created_at}","created_by":"elastic","name":"${jsonrule.name}","tags":[],"interval":"10s","enabled":false,"description":"${jsonrule.description}","risk_score":${jsonrule.risk_score},"severity":"${jsonrule.severity}","output_index":".siem-signals-default","author":[],"false_positives":[],"from":"now-17520h","rule_id":"rule_testing","max_signals":100,"risk_score_mapping":[],"severity_mapping":[],"threat":[],"to":"now","references":[],"version":1,"exceptions_list":[],"immutable":false,"type":"query","language":"kuery","index":["exceptions-*"],"query":"${jsonrule.query}","throttle":"no_actions","actions":[]}\n{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n`;
+ return `{"id":"${jsonrule.id}","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","created_at":"${jsonrule.created_at}","created_by":"elastic","name":"${jsonrule.name}","tags":[],"interval":"100m","enabled":false,"description":"${jsonrule.description}","risk_score":${jsonrule.risk_score},"severity":"${jsonrule.severity}","output_index":".siem-signals-default","author":[],"false_positives":[],"from":"now-17520h","rule_id":"rule_testing","max_signals":100,"risk_score_mapping":[],"severity_mapping":[],"threat":[],"to":"now","references":[],"version":1,"exceptions_list":[],"immutable":false,"type":"query","language":"kuery","index":["exceptions-*"],"query":"${jsonrule.query}","throttle":"no_actions","actions":[]}\n{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n`;
};
diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts
index dd7a163d00753..b677e36ab3918 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts
@@ -35,13 +35,25 @@ export const addExceptionFromFirstAlert = () => {
};
export const closeFirstAlert = () => {
- cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true });
- cy.get(CLOSE_ALERT_BTN).click();
+ cy.get(TIMELINE_CONTEXT_MENU_BTN)
+ .first()
+ .pipe(($el) => $el.trigger('click'))
+ .should('be.visible');
+
+ cy.get(CLOSE_ALERT_BTN)
+ .pipe(($el) => $el.trigger('click'))
+ .should('not.be.visible');
};
export const closeAlerts = () => {
- cy.get(TAKE_ACTION_POPOVER_BTN).click({ force: true });
- cy.get(CLOSE_SELECTED_ALERTS_BTN).click();
+ cy.get(TAKE_ACTION_POPOVER_BTN)
+ .first()
+ .pipe(($el) => $el.trigger('click'))
+ .should('be.visible');
+
+ cy.get(CLOSE_SELECTED_ALERTS_BTN)
+ .pipe(($el) => $el.trigger('click'))
+ .should('not.be.visible');
};
export const expandFirstAlert = () => {
diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts
index 0b051f3a26581..5a816a71744cb 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts
@@ -7,7 +7,7 @@
import { CustomRule, ThreatIndicatorRule } from '../../objects/rule';
-export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing') =>
+export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing', interval = '100m') =>
cy.request({
method: 'POST',
url: 'api/detection_engine/rules',
@@ -15,7 +15,7 @@ export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing') =>
rule_id: ruleId,
risk_score: parseInt(rule.riskScore, 10),
description: rule.description,
- interval: '10s',
+ interval,
name: rule.name,
severity: rule.severity.toLocaleLowerCase(),
type: 'query',
@@ -67,7 +67,12 @@ export const createCustomIndicatorRule = (rule: ThreatIndicatorRule, ruleId = 'r
failOnStatusCode: false,
});
-export const createCustomRuleActivated = (rule: CustomRule, ruleId = '1') =>
+export const createCustomRuleActivated = (
+ rule: CustomRule,
+ ruleId = '1',
+ interval = '100m',
+ maxSignals = 500
+) =>
cy.request({
method: 'POST',
url: 'api/detection_engine/rules',
@@ -75,7 +80,7 @@ export const createCustomRuleActivated = (rule: CustomRule, ruleId = '1') =>
rule_id: ruleId,
risk_score: parseInt(rule.riskScore, 10),
description: rule.description,
- interval: '10s',
+ interval,
name: rule.name,
severity: rule.severity.toLocaleLowerCase(),
type: 'query',
@@ -85,7 +90,7 @@ export const createCustomRuleActivated = (rule: CustomRule, ruleId = '1') =>
language: 'kuery',
enabled: true,
tags: ['rule1'],
- max_signals: 500,
+ max_signals: maxSignals,
},
headers: { 'kbn-xsrf': 'cypress-creds' },
failOnStatusCode: false,
diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts
index 2b7308757f9f4..9f957a0cb9a95 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts
@@ -479,7 +479,7 @@ export const selectThresholdRuleType = () => {
cy.get(THRESHOLD_TYPE).click({ force: true });
};
-export const waitForAlertsToPopulate = async () => {
+export const waitForAlertsToPopulate = async (alertCountThreshold = 1) => {
cy.waitUntil(
() => {
refreshPage();
@@ -488,7 +488,7 @@ export const waitForAlertsToPopulate = async () => {
.invoke('text')
.then((countText) => {
const alertCount = parseInt(countText, 10) || 0;
- return alertCount > 0;
+ return alertCount >= alertCountThreshold;
});
},
{ interval: 500, timeout: 12000 }
diff --git a/x-pack/test/api_integration/apis/security_solution/feature_controls.ts b/x-pack/test/api_integration/apis/security_solution/feature_controls.ts
index 1e43fd473a38d..da28e28dae769 100644
--- a/x-pack/test/api_integration/apis/security_solution/feature_controls.ts
+++ b/x-pack/test/api_integration/apis/security_solution/feature_controls.ts
@@ -58,7 +58,8 @@ export default function ({ getService }: FtrProviderContext) {
};
};
- describe('feature controls', () => {
+ // FLAKY: https://github.com/elastic/kibana/issues/97355
+ describe.skip('feature controls', () => {
it(`APIs can't be accessed by user with no privileges`, async () => {
const username = 'logstash_read';
const roleName = 'logstash_read';
diff --git a/x-pack/test/api_integration/apis/security_solution/matrix_dns_histogram.ts b/x-pack/test/api_integration/apis/security_solution/matrix_dns_histogram.ts
index 69beb65dec670..27a7a5a539607 100644
--- a/x-pack/test/api_integration/apis/security_solution/matrix_dns_histogram.ts
+++ b/x-pack/test/api_integration/apis/security_solution/matrix_dns_histogram.ts
@@ -33,7 +33,8 @@ export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const retry = getService('retry');
- describe('Matrix DNS Histogram', () => {
+ // FIX: https://github.com/elastic/kibana/issues/97378
+ describe.skip('Matrix DNS Histogram', () => {
describe('Large data set', () => {
before(() => esArchiver.load('security_solution/matrix_dns_histogram/large_dns_query'));
after(() => esArchiver.unload('security_solution/matrix_dns_histogram/large_dns_query'));
diff --git a/x-pack/test/examples/search_examples/index.ts b/x-pack/test/examples/search_examples/index.ts
index 2cac0d1b60de7..13eac7566525e 100644
--- a/x-pack/test/examples/search_examples/index.ts
+++ b/x-pack/test/examples/search_examples/index.ts
@@ -23,6 +23,7 @@ export default function ({ getService, loadTestFile }: PluginFunctionalProviderC
await esArchiver.unload('lens/basic');
});
+ loadTestFile(require.resolve('./search_sessions_cache'));
loadTestFile(require.resolve('./search_session_example'));
});
}
diff --git a/x-pack/test/examples/search_examples/search_sessions_cache.ts b/x-pack/test/examples/search_examples/search_sessions_cache.ts
new file mode 100644
index 0000000000000..57b2d1665d901
--- /dev/null
+++ b/x-pack/test/examples/search_examples/search_sessions_cache.ts
@@ -0,0 +1,65 @@
+/*
+ * 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 '../../functional/ftr_provider_context';
+
+// eslint-disable-next-line import/no-default-export
+export default function ({ getService, getPageObjects }: FtrProviderContext) {
+ const testSubjects = getService('testSubjects');
+ const PageObjects = getPageObjects(['common']);
+ const toasts = getService('toasts');
+ const retry = getService('retry');
+
+ async function getExecutedAt() {
+ const toast = await toasts.getToastElement(1);
+ const timeElem = await testSubjects.findDescendant('requestExecutedAt', toast);
+ const text = await timeElem.getVisibleText();
+ await toasts.dismissAllToasts();
+ await retry.waitFor('toasts gone', async () => {
+ return (await toasts.getToastCount()) === 0;
+ });
+ return text;
+ }
+
+ describe.skip('Search session client side cache', () => {
+ const appId = 'searchExamples';
+
+ before(async function () {
+ await PageObjects.common.navigateToApp(appId, { insertTimestamp: false });
+ });
+
+ it('should cache responses by search session id', async () => {
+ await testSubjects.click('searchExamplesCacheSearch');
+ const noSessionExecutedAt = await getExecutedAt();
+
+ // Expect searches executed in a session to share a response
+ await testSubjects.click('searchExamplesStartSession');
+ await testSubjects.click('searchExamplesCacheSearch');
+ const withSessionExecutedAt = await getExecutedAt();
+ await testSubjects.click('searchExamplesCacheSearch');
+ const withSessionExecutedAt2 = await getExecutedAt();
+ expect(withSessionExecutedAt2).to.equal(withSessionExecutedAt);
+ expect(withSessionExecutedAt).not.to.equal(noSessionExecutedAt);
+
+ // Expect new session to run search again
+ await testSubjects.click('searchExamplesStartSession');
+ await testSubjects.click('searchExamplesCacheSearch');
+ const secondSessionExecutedAt = await getExecutedAt();
+ expect(secondSessionExecutedAt).not.to.equal(withSessionExecutedAt);
+
+ // Clear session
+ await testSubjects.click('searchExamplesClearSession');
+ await testSubjects.click('searchExamplesCacheSearch');
+ const afterClearSession1 = await getExecutedAt();
+ await testSubjects.click('searchExamplesCacheSearch');
+ const afterClearSession2 = await getExecutedAt();
+ expect(secondSessionExecutedAt).not.to.equal(afterClearSession1);
+ expect(afterClearSession2).not.to.equal(afterClearSession1);
+ });
+ });
+}