Skip to content

Commit

Permalink
Add empty engine polling behavior
Browse files Browse the repository at this point in the history
- Now that both Engines Overview, Documents, and Search UI views have empty states that are hooked up to EngineLogic, this poll will update all of the above pages automatically when a new document comes in!
  • Loading branch information
cee-chen committed Jun 23, 2021
1 parent c6da0d8 commit fba82ef
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 11 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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 { i18n } from '@kbn/i18n';

export const POLLING_DURATION = 5000;

export const POLLING_ERROR_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.pollingErrorMessage',
{ defaultMessage: 'Could not fetch engine data' }
);

export const POLLING_ERROR_TEXT = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.pollingErrorDescription',
{ defaultMessage: 'Please check your connection or manually reload the page.' }
);
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
* 2.0.
*/

import { LogicMounter, mockHttpValues } from '../../../__mocks__/kea_logic';
import {
LogicMounter,
mockHttpValues,
mockFlashMessageHelpers,
} from '../../../__mocks__/kea_logic';

import { nextTick } from '@kbn/test/jest';

Expand All @@ -17,8 +21,9 @@ import { EngineTypes } from './types';
import { EngineLogic } from './';

describe('EngineLogic', () => {
const { mount } = new LogicMounter(EngineLogic);
const { mount, unmount } = new LogicMounter(EngineLogic);
const { http } = mockHttpValues;
const { flashErrorToast } = mockFlashMessageHelpers;

const mockEngineData = {
name: 'some-engine',
Expand Down Expand Up @@ -53,6 +58,7 @@ describe('EngineLogic', () => {
hasUnconfirmedSchemaFields: false,
engineNotFound: false,
searchKey: '',
intervalId: null,
};

const DEFAULT_VALUES_WITH_ENGINE = {
Expand Down Expand Up @@ -164,6 +170,34 @@ describe('EngineLogic', () => {
});
});
});

describe('onPollStart', () => {
it('should set intervalId', () => {
mount({ intervalId: null });
EngineLogic.actions.onPollStart(123);

expect(EngineLogic.values).toEqual({
...DEFAULT_VALUES,
intervalId: 123,
});
});

describe('onPollStop', () => {
// Note: This does have to be a separate action following stopPolling(), rather
// than using stopPolling: () => null as a reducer. If you do that, then the ID
// gets cleared before the actual poll interval does & the poll interval never clears :doh:

it('should reset intervalId', () => {
mount({ intervalId: 123 });
EngineLogic.actions.onPollStop();

expect(EngineLogic.values).toEqual({
...DEFAULT_VALUES,
intervalId: null,
});
});
});
});
});

describe('listeners', () => {
Expand All @@ -180,16 +214,100 @@ describe('EngineLogic', () => {
expect(EngineLogic.actions.setEngineData).toHaveBeenCalledWith(mockEngineData);
});

it('handles errors', async () => {
it('handles 4xx errors', async () => {
mount();
jest.spyOn(EngineLogic.actions, 'setEngineNotFound');
http.get.mockReturnValue(Promise.reject('An error occured'));
http.get.mockReturnValue(Promise.reject({ response: { status: 404 } }));

EngineLogic.actions.initializeEngine();
await nextTick();

expect(EngineLogic.actions.setEngineNotFound).toHaveBeenCalledWith(true);
});

it('handles 5xx errors', async () => {
mount();
jest.spyOn(EngineLogic.actions, 'setEngineNotFound');
http.get.mockReturnValue(Promise.reject('An error occured'));

EngineLogic.actions.initializeEngine();
await nextTick();

expect(flashErrorToast).toHaveBeenCalledWith('Could not fetch engine data', {
text: expect.stringContaining('Please check your connection'),
toastLifeTimeMs: 3750,
});
});
});

describe('pollEmptyEngine', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.clearAllTimers());

it('starts a poll', () => {
mount();
jest.spyOn(global, 'setInterval');
jest.spyOn(EngineLogic.actions, 'onPollStart');

EngineLogic.actions.pollEmptyEngine();

expect(global.setInterval).toHaveBeenCalled();
expect(EngineLogic.actions.onPollStart).toHaveBeenCalled();
});

it('polls for engine data if the current engine is empty', () => {
mount({ engine: {} });
jest.spyOn(EngineLogic.actions, 'initializeEngine');

EngineLogic.actions.pollEmptyEngine();

jest.advanceTimersByTime(5000);
expect(EngineLogic.actions.initializeEngine).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(5000);
expect(EngineLogic.actions.initializeEngine).toHaveBeenCalledTimes(2);
});

it('cancels the poll if the current engine changed from empty to non-empty', () => {
mount({ engine: mockEngineData });
jest.spyOn(EngineLogic.actions, 'stopPolling');
jest.spyOn(EngineLogic.actions, 'initializeEngine');

EngineLogic.actions.pollEmptyEngine();

jest.advanceTimersByTime(5000);
expect(EngineLogic.actions.stopPolling).toHaveBeenCalled();
expect(EngineLogic.actions.initializeEngine).not.toHaveBeenCalled();
});

it('does not create new polls if one already exists', () => {
jest.spyOn(global, 'setInterval');
mount({ intervalId: 123 });

EngineLogic.actions.pollEmptyEngine();

expect(global.setInterval).not.toHaveBeenCalled();
});
});

describe('stopPolling', () => {
it('clears the poll interval and unsets the intervalId', () => {
jest.spyOn(global, 'clearInterval');
mount({ intervalId: 123 });

EngineLogic.actions.stopPolling();

expect(global.clearInterval).toHaveBeenCalledWith(123);
expect(EngineLogic.values.intervalId).toEqual(null);
});

it('does not clearInterval if a poll has not been started', () => {
jest.spyOn(global, 'clearInterval');
mount({ intervalId: null });

EngineLogic.actions.stopPolling();

expect(global.clearInterval).not.toHaveBeenCalled();
});
});
});

Expand Down Expand Up @@ -373,4 +491,15 @@ describe('EngineLogic', () => {
});
});
});

describe('events', () => {
it('calls stopPolling before unmount', () => {
mount();
// Has to be a const to check state after unmount
const stopPollingSpy = jest.spyOn(EngineLogic.actions, 'stopPolling');

unmount();
expect(stopPollingSpy).toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

import { kea, MakeLogicType } from 'kea';

import { flashErrorToast } from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
import { ApiTokenTypes } from '../credentials/constants';
import { ApiToken } from '../credentials/types';

import { POLLING_DURATION, POLLING_ERROR_TITLE, POLLING_ERROR_TEXT } from './constants';
import { EngineDetails, EngineTypes } from './types';

interface EngineValues {
Expand All @@ -26,6 +28,7 @@ interface EngineValues {
hasUnconfirmedSchemaFields: boolean;
engineNotFound: boolean;
searchKey: string;
intervalId: number | null;
}

interface EngineActions {
Expand All @@ -34,6 +37,10 @@ interface EngineActions {
setEngineNotFound(notFound: boolean): { notFound: boolean };
clearEngine(): void;
initializeEngine(): void;
pollEmptyEngine(): void;
onPollStart(intervalId: number): { intervalId: number };
stopPolling(): void;
onPollStop(): void;
}

export const EngineLogic = kea<MakeLogicType<EngineValues, EngineActions>>({
Expand All @@ -44,6 +51,10 @@ export const EngineLogic = kea<MakeLogicType<EngineValues, EngineActions>>({
setEngineNotFound: (notFound) => ({ notFound }),
clearEngine: true,
initializeEngine: true,
pollEmptyEngine: true,
onPollStart: (intervalId) => ({ intervalId }),
stopPolling: true,
onPollStop: true,
},
reducers: {
dataLoading: [
Expand Down Expand Up @@ -74,6 +85,13 @@ export const EngineLogic = kea<MakeLogicType<EngineValues, EngineActions>>({
clearEngine: () => false,
},
],
intervalId: [
null,
{
onPollStart: (_, { intervalId }) => intervalId,
onPollStop: () => null,
},
],
},
selectors: ({ selectors }) => ({
isEngineEmpty: [() => [selectors.engine], (engine) => !engine.document_count],
Expand Down Expand Up @@ -107,16 +125,49 @@ export const EngineLogic = kea<MakeLogicType<EngineValues, EngineActions>>({
],
}),
listeners: ({ actions, values }) => ({
initializeEngine: async () => {
initializeEngine: async (_, breakpoint) => {
breakpoint(); // Prevents errors if logic unmounts while fetching

const { engineName } = values;
const { http } = HttpLogic.values;

try {
const response = await http.get(`/api/app_search/engines/${engineName}`);
actions.setEngineData(response);
} catch (error) {
actions.setEngineNotFound(true);
if (error?.response?.status >= 400 && error?.response?.status < 500) {
actions.setEngineNotFound(true);
} else {
flashErrorToast(POLLING_ERROR_TITLE, {
text: POLLING_ERROR_TEXT,
toastLifeTimeMs: POLLING_DURATION * 0.75,
});
}
}
},
pollEmptyEngine: () => {
if (values.intervalId) return; // Ensure we only have one poll at a time

const id = window.setInterval(() => {
if (values.isEngineEmpty && values.isEngineSchemaEmpty) {
actions.initializeEngine(); // Re-fetch engine data when engine is empty
} else {
actions.stopPolling();
}
}, POLLING_DURATION);

actions.onPollStart(id);
},
stopPolling: () => {
if (values.intervalId !== null) {
clearInterval(values.intervalId);
actions.onPollStop();
}
},
}),
events: ({ actions }) => ({
beforeUnmount: () => {
actions.stopPolling();
},
}),
});
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ describe('EngineRouter', () => {
engineNotFound: false,
myRole: {},
};
const actions = { setEngineName: jest.fn(), initializeEngine: jest.fn(), clearEngine: jest.fn() };
const actions = {
setEngineName: jest.fn(),
initializeEngine: jest.fn(),
pollEmptyEngine: jest.fn(),
stopPolling: jest.fn(),
clearEngine: jest.fn(),
};

beforeEach(() => {
setMockValues(values);
Expand All @@ -58,12 +64,14 @@ describe('EngineRouter', () => {
expect(actions.setEngineName).toHaveBeenCalledWith('some-engine');
});

it('initializes/fetches engine API data', () => {
it('initializes/fetches engine API data and starts a poll for empty engines', () => {
expect(actions.initializeEngine).toHaveBeenCalled();
expect(actions.pollEmptyEngine).toHaveBeenCalled();
});

it('clears engine on unmount and on update', () => {
it('clears engine and stops polling on unmount / on engine change', () => {
unmountHandler();
expect(actions.stopPolling).toHaveBeenCalled();
expect(actions.clearEngine).toHaveBeenCalled();
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,19 @@ export const EngineRouter: React.FC = () => {

const { engineName: engineNameFromUrl } = useParams() as { engineName: string };
const { engineName, dataLoading, engineNotFound } = useValues(EngineLogic);
const { setEngineName, initializeEngine, clearEngine } = useActions(EngineLogic);
const { setEngineName, initializeEngine, pollEmptyEngine, stopPolling, clearEngine } = useActions(
EngineLogic
);

useEffect(() => {
setEngineName(engineNameFromUrl);
initializeEngine();
return clearEngine;
pollEmptyEngine();

return () => {
stopPolling();
clearEngine();
};
}, [engineNameFromUrl]);

if (engineNotFound) {
Expand Down

0 comments on commit fba82ef

Please sign in to comment.