Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Discover] Replace RxJS firstValueFrom with RxJS lastValueFrom to prevent problem with partial results #134682

Merged
merged 7 commits into from
Jun 20, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 52 additions & 7 deletions src/plugins/discover/public/hooks/use_es_doc_search.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,17 @@
* Side Public License, v 1.
*/

import { renderHook } from '@testing-library/react-hooks';
import { renderHook, act } from '@testing-library/react-hooks';
import { buildSearchBody, useEsDocSearch } from './use_es_doc_search';
import { Observable } from 'rxjs';
import { Subject } from 'rxjs';
import { DataView } from '@kbn/data-views-plugin/public';
import { DocProps } from '../application/doc/components/doc';
import { ElasticRequestState } from '../application/doc/types';
import { SEARCH_FIELDS_FROM_SOURCE as mockSearchFieldsFromSource } from '../../common';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import React from 'react';

const mockSearchResult = new Observable();

const mockSearchResult = new Subject();
const services = {
data: {
search: {
Expand Down Expand Up @@ -171,23 +170,69 @@ describe('Test of <Doc /> helper / hook', () => {
`);
});

test('useEsDocSearch', async () => {
test('useEsDocSearch loading', async () => {
const indexPattern = {
getComputedFields: () => [],
};
const props = {
id: '1',
index: 'index1',
indexPattern,
} as unknown as DocProps;

const hook = renderHook((p: DocProps) => useEsDocSearch(p), {
initialProps: props,
wrapper: ({ children }) => (
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
),
});

expect(hook.result.current.slice(0, 2)).toEqual([ElasticRequestState.Loading, null]);
});

test('useEsDocSearch ignore partial results', async () => {
const indexPattern = {
getComputedFields: () => [],
};

const record = { test: 1 };

const props = {
id: '1',
index: 'index1',
indexPattern,
} as unknown as DocProps;

const { result } = renderHook((p: DocProps) => useEsDocSearch(p), {
const hook = renderHook((p: DocProps) => useEsDocSearch(p), {
initialProps: props,
wrapper: ({ children }) => (
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
),
});

expect(result.current.slice(0, 2)).toEqual([ElasticRequestState.Loading, null]);
await act(async () => {
mockSearchResult.next({
isPartial: true,
isRunning: false,
rawResponse: {
hits: {
hits: [],
},
},
});
mockSearchResult.next({
isPartial: false,
isRunning: false,
rawResponse: {
hits: {
hits: [record],
},
},
});
Comment on lines +214 to +231
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dear @Dosant , if you have minute, could you have a look at the unit tests, they work, but I would love to know, if I could make them better ❤️

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that will do it 👍 should capture the flow well.

Theoretically it is possible to make them behave more real-world by adding delay+fake timers between emits. Maybe even use rxjs-marbles for it.
But I think that's already good enough for the current use case

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thx @Dosant , for confirming 👍

mockSearchResult.complete();
await hook.waitForNextUpdate();
});

expect(hook.result.current.slice(0, 2)).toEqual([ElasticRequestState.Found, record]);
});
});
87 changes: 44 additions & 43 deletions src/plugins/discover/public/hooks/use_es_doc_search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { useCallback, useEffect, useMemo, useState } from 'react';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { firstValueFrom } from 'rxjs';
import { lastValueFrom } from 'rxjs';
import { DataView } from '@kbn/data-views-plugin/public';
import { DocProps } from '../application/doc/components/doc';
import { ElasticRequestState } from '../application/doc/types';
Expand All @@ -18,47 +18,6 @@ import { useDiscoverServices } from './use_discover_services';

type RequestBody = Pick<estypes.SearchRequest, 'body'>;

/**
* helper function to build a query body for Elasticsearch
* https://www.elastic.co/guide/en/elasticsearch/reference/current//query-dsl-ids-query.html
*/
export function buildSearchBody(
id: string,
indexPattern: DataView,
useNewFieldsApi: boolean,
requestAllFields?: boolean
): RequestBody | undefined {
const computedFields = indexPattern.getComputedFields();
const runtimeFields = computedFields.runtimeFields as estypes.MappingRuntimeFields;
const request: RequestBody = {
body: {
query: {
ids: {
values: [id],
},
},
stored_fields: computedFields.storedFields,
script_fields: computedFields.scriptFields,
version: true,
},
};
if (!request.body) {
return undefined;
}
if (useNewFieldsApi) {
// @ts-expect-error
request.body.fields = [{ field: '*', include_unmapped: 'true' }];
request.body.runtime_mappings = runtimeFields ? runtimeFields : {};
if (requestAllFields) {
request.body._source = true;
}
} else {
request.body._source = true;
}
request.body.fields = [...(request.body?.fields || []), ...(computedFields.docvalueFields || [])];
return request;
}

/**
* Custom react hook for querying a single doc in ElasticSearch
*/
Expand All @@ -75,14 +34,15 @@ export function useEsDocSearch({

const requestData = useCallback(async () => {
try {
const { rawResponse } = await firstValueFrom(
const result = await lastValueFrom(
data.search.search({
params: {
index,
body: buildSearchBody(id, indexPattern, useNewFieldsApi, requestSource)?.body,
},
})
);
const rawResponse = result.rawResponse;

const hits = rawResponse.hits;

Expand All @@ -109,3 +69,44 @@ export function useEsDocSearch({

return [status, hit, requestData];
}

/**
* helper function to build a query body for Elasticsearch
* https://www.elastic.co/guide/en/elasticsearch/reference/current//query-dsl-ids-query.html
*/
export function buildSearchBody(
Copy link
Member Author

@kertal kertal Jun 20, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a little code style change, moving the helper function to the bottom, so when scrolling the doc, the important function is displayed first

id: string,
indexPattern: DataView,
useNewFieldsApi: boolean,
requestAllFields?: boolean
): RequestBody | undefined {
const computedFields = indexPattern.getComputedFields();
const runtimeFields = computedFields.runtimeFields as estypes.MappingRuntimeFields;
const request: RequestBody = {
body: {
query: {
ids: {
values: [id],
},
},
stored_fields: computedFields.storedFields,
script_fields: computedFields.scriptFields,
version: true,
},
};
if (!request.body) {
return undefined;
}
if (useNewFieldsApi) {
// @ts-expect-error
request.body.fields = [{ field: '*', include_unmapped: 'true' }];
request.body.runtime_mappings = runtimeFields ? runtimeFields : {};
if (requestAllFields) {
request.body._source = true;
}
} else {
request.body._source = true;
}
request.body.fields = [...(request.body?.fields || []), ...(computedFields.docvalueFields || [])];
return request;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import React from 'react';
import 'brace';
import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { act } from 'react-dom/test-utils';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
Expand Down Expand Up @@ -220,6 +220,42 @@ describe('EsQueryAlertTypeExpression', () => {
);
});

test('should show success message if Test Query is successful (with partial result)', async () => {
const partial = {
isRunning: true,
isPartial: true,
};
const complete = {
isRunning: false,
isPartial: false,
rawResponse: {
hits: {
total: 1234,
},
},
};
const searchResponseMock$ = new Subject();
dataMock.search.search.mockImplementation(() => searchResponseMock$);
const wrapper = await setup(defaultEsQueryExpressionParams);
const testQueryButton = wrapper.find('EuiButton[data-test-subj="testQuery"]');

testQueryButton.simulate('click');
expect(dataMock.search.search).toHaveBeenCalled();
await act(async () => {
searchResponseMock$.next(partial);
searchResponseMock$.next(complete);
searchResponseMock$.complete();
await nextTick();
wrapper.update();
});

expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeFalsy();
expect(wrapper.find('EuiText[data-test-subj="testQuerySuccess"]').text()).toEqual(
`Query matched 1234 documents in the last 15s.`
);
});

test('should show error message if Test Query is throws error', async () => {
dataMock.search.search.mockImplementation(() => {
throw new Error('What is this query');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import React, { useState, Fragment, useEffect, useCallback } from 'react';
import { firstValueFrom } from 'rxjs';
import { lastValueFrom } from 'rxjs';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';

Expand Down Expand Up @@ -141,7 +141,7 @@ export const EsQueryExpression = ({
const timeWindow = parseDuration(window);
const parsedQuery = JSON.parse(esQuery);
const now = Date.now();
const { rawResponse } = await firstValueFrom(
const { rawResponse } = await lastValueFrom(
data.search.search({
params: buildSortedEventsQuery({
index,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import { EsQueryAlertParams, SearchType } from '../types';
import { SearchSourceExpression } from './search_source_expression';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { act } from 'react-dom/test-utils';
import { of } from 'rxjs';
import { IKibanaSearchResponse, ISearchSource } from '@kbn/data-plugin/common';
import { Subject } from 'rxjs';
import { ISearchSource } from '@kbn/data-plugin/common';
import { IUiSettingsClient } from '@kbn/core/public';
import { findTestSubject } from '@elastic/eui/lib/test';
import { EuiLoadingSpinner } from '@elastic/eui';
Expand All @@ -40,6 +40,20 @@ const defaultSearchSourceExpressionParams: EsQueryAlertParams<SearchType.searchS
searchConfiguration: {},
};

const mockSearchResult = new Subject();
const testResultComplete = {
rawResponse: {
hits: {
total: 1234,
},
},
};

const testResultPartial = {
partial: true,
running: true,
};

const searchSourceMock = {
id: 'data_source6',
fields: {
Expand Down Expand Up @@ -67,13 +81,7 @@ const searchSourceMock = {
return searchSourceMock;
}),
fetch$: jest.fn(() => {
return of<IKibanaSearchResponse>({
rawResponse: {
hits: {
total: 1234,
},
},
});
return mockSearchResult;
}),
} as unknown as ISearchSource;

Expand Down Expand Up @@ -143,6 +151,7 @@ describe('SearchSourceAlertTypeExpression', () => {
wrapper = await wrapper.update();
expect(findTestSubject(wrapper, 'thresholdExpression')).toBeTruthy();
});

test('should show success message if Test Query is successful', async () => {
let wrapper = setup(defaultSearchSourceExpressionParams);
await act(async () => {
Expand All @@ -156,6 +165,9 @@ describe('SearchSourceAlertTypeExpression', () => {
wrapper = await wrapper.update();

await act(async () => {
mockSearchResult.next(testResultPartial);
mockSearchResult.next(testResultComplete);
mockSearchResult.complete();
await nextTick();
wrapper.update();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import React, { Fragment, useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import deepEqual from 'fast-deep-equal';
import { firstValueFrom } from 'rxjs';
import { lastValueFrom } from 'rxjs';
import { Filter } from '@kbn/es-query';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiSpacer, EuiTitle } from '@elastic/eui';
Expand Down Expand Up @@ -183,7 +183,7 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
'filter',
timeFilter ? [timeFilter, ...ruleConfiguration.filter] : ruleConfiguration.filter
);
const { rawResponse } = await firstValueFrom(testSearchSource.fetch$());
const { rawResponse } = await lastValueFrom(testSearchSource.fetch$());
return { nrOfDocs: totalHitsToNumber(rawResponse.hits.total), timeWindow };
}, [searchSource, timeWindowSize, timeWindowUnit, ruleConfiguration]);

Expand Down