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

fix(sqllab): infinite running state on disconnect #23669

Merged
merged 1 commit into from
Apr 18, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions superset-frontend/src/SqlLab/actions/sqlLab.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const STOP_QUERY = 'STOP_QUERY';
export const REQUEST_QUERY_RESULTS = 'REQUEST_QUERY_RESULTS';
export const QUERY_SUCCESS = 'QUERY_SUCCESS';
export const QUERY_FAILED = 'QUERY_FAILED';
export const CLEAR_INACTIVE_QUERIES = 'CLEAR_INACTIVE_QUERIES';
export const CLEAR_QUERY_RESULTS = 'CLEAR_QUERY_RESULTS';
export const REMOVE_DATA_PREVIEW = 'REMOVE_DATA_PREVIEW';
export const CHANGE_DATA_PREVIEW_ID = 'CHANGE_DATA_PREVIEW_ID';
Expand Down Expand Up @@ -219,6 +220,10 @@ export function estimateQueryCost(queryEditor) {
};
}

export function clearInactiveQueries() {
return { type: CLEAR_INACTIVE_QUERIES };
}

export function startQuery(query) {
Object.assign(query, {
id: query.id ? query.id : shortid.generate(),
Expand Down
3 changes: 1 addition & 2 deletions superset-frontend/src/SqlLab/components/App/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,15 +168,14 @@ class App extends React.PureComponent {
}

render() {
const { queries, actions, queriesLastUpdate } = this.props;
const { queries, queriesLastUpdate } = this.props;
if (this.state.hash && this.state.hash === '#search') {
return window.location.replace('/superset/sqllab/history/');
}
return (
<SqlLabStyles data-test="SqlLabApp" className="App SqlLab">
<QueryAutoRefresh
queries={queries}
refreshQueries={actions?.refreshQueries}
queriesLastUpdate={queriesLastUpdate}
/>
<TabbedSqlEditors />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,26 @@
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import React from 'react';
import { render } from '@testing-library/react';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { render, waitFor } from 'spec/helpers/testing-library';
import {
CLEAR_INACTIVE_QUERIES,
REFRESH_QUERIES,
} from 'src/SqlLab/actions/sqlLab';
import QueryAutoRefresh, {
isQueryRunning,
shouldCheckForQueries,
QUERY_UPDATE_FREQ,
} from 'src/SqlLab/components/QueryAutoRefresh';
import { successfulQuery, runningQuery } from 'src/SqlLab/fixtures';
import { QueryDictionary } from 'src/SqlLab/types';

const middlewares = [thunk];
const mockStore = configureStore(middlewares);

// NOTE: The uses of @ts-ignore in this file is to enable testing of bad inputs to verify the
// function / component handles bad data elegantly
describe('QueryAutoRefresh', () => {
Expand All @@ -34,10 +45,14 @@ describe('QueryAutoRefresh', () => {
const successfulQueries: QueryDictionary = {};
successfulQueries[successfulQuery.id] = successfulQuery;

const refreshQueries = jest.fn();

const queriesLastUpdate = Date.now();

const refreshApi = 'glob:*/api/v1/query/updated_since?*';

afterEach(() => {
fetchMock.reset();
});

it('isQueryRunning returns true for valid running query', () => {
const running = isQueryRunning(runningQuery);
expect(running).toBe(true);
Expand Down Expand Up @@ -91,43 +106,119 @@ describe('QueryAutoRefresh', () => {
).toBe(false);
});

it('Attempts to refresh when given pending query', () => {
it('Attempts to refresh when given pending query', async () => {
const store = mockStore();
fetchMock.get(refreshApi, {
result: [
{
id: runningQuery.id,
status: 'success',
},
],
});

render(
<QueryAutoRefresh
queries={runningQueries}
refreshQueries={refreshQueries}
queriesLastUpdate={queriesLastUpdate}
/>,
{ useRedux: true, store },
);
await waitFor(
() =>
expect(store.getActions()).toContainEqual(
expect.objectContaining({
type: REFRESH_QUERIES,
}),
),
{ timeout: QUERY_UPDATE_FREQ + 100 },
);
setTimeout(() => {
expect(refreshQueries).toHaveBeenCalled();
}, 1000);
});

it('Does not fail and attempts to refresh when given pending query and invlaid query', () => {
it('Attempts to clear inactive queries when updated queries are empty', async () => {
const store = mockStore();
fetchMock.get(refreshApi, {
result: [],
});

render(
<QueryAutoRefresh
queries={runningQueries}
queriesLastUpdate={queriesLastUpdate}
/>,
{ useRedux: true, store },
);
await waitFor(
() =>
expect(store.getActions()).toContainEqual(
expect.objectContaining({
type: CLEAR_INACTIVE_QUERIES,
}),
),
{ timeout: QUERY_UPDATE_FREQ + 100 },
);
expect(
store.getActions().filter(({ type }) => type === REFRESH_QUERIES),
).toHaveLength(0);
expect(fetchMock.calls(refreshApi)).toHaveLength(1);
});

it('Does not fail and attempts to refresh when given pending query and invlaid query', async () => {
const store = mockStore();
fetchMock.get(refreshApi, {
result: [
{
id: runningQuery.id,
status: 'success',
},
],
});

render(
<QueryAutoRefresh
// @ts-ignore
queries={{ ...runningQueries, g324t: null }}
refreshQueries={refreshQueries}
queriesLastUpdate={queriesLastUpdate}
/>,
{ useRedux: true, store },
);
await waitFor(
() =>
expect(store.getActions()).toContainEqual(
expect.objectContaining({
type: REFRESH_QUERIES,
}),
),
{ timeout: QUERY_UPDATE_FREQ + 100 },
);
setTimeout(() => {
expect(refreshQueries).toHaveBeenCalled();
}, 1000);
});

it('Does NOT Attempt to refresh when given only completed queries', () => {
it('Does NOT Attempt to refresh when given only completed queries', async () => {
const store = mockStore();
fetchMock.get(refreshApi, {
result: [
{
id: runningQuery.id,
status: 'success',
},
],
});
render(
<QueryAutoRefresh
queries={successfulQueries}
refreshQueries={refreshQueries}
queriesLastUpdate={queriesLastUpdate}
/>,
{ useRedux: true, store },
);
await waitFor(
() =>
expect(store.getActions()).toContainEqual(
expect.objectContaining({
type: CLEAR_INACTIVE_QUERIES,
}),
),
{ timeout: QUERY_UPDATE_FREQ + 100 },
);
setTimeout(() => {
expect(refreshQueries).not.toHaveBeenCalled();
}, 1000);
expect(fetchMock.calls(refreshApi)).toHaveLength(0);
});
});
46 changes: 28 additions & 18 deletions superset-frontend/src/SqlLab/components/QueryAutoRefresh/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import { useRef } from 'react';
import { useDispatch } from 'react-redux';
import { isObject } from 'lodash';
import rison from 'rison';
import {
Expand All @@ -27,19 +28,18 @@ import {
} from '@superset-ui/core';
import { QueryDictionary } from 'src/SqlLab/types';
import useInterval from 'src/SqlLab/utils/useInterval';
import {
refreshQueries,
clearInactiveQueries,
} from 'src/SqlLab/actions/sqlLab';

const QUERY_UPDATE_FREQ = 2000;
export const QUERY_UPDATE_FREQ = 2000;
const QUERY_UPDATE_BUFFER_MS = 5000;
const MAX_QUERY_AGE_TO_POLL = 21600000;
const QUERY_TIMEOUT_LIMIT = 10000;

interface RefreshQueriesFunc {
(alteredQueries: any): any;
}

export interface QueryAutoRefreshProps {
queries: QueryDictionary;
refreshQueries: RefreshQueriesFunc;
queriesLastUpdate: number;
}

Expand All @@ -61,40 +61,50 @@ export const shouldCheckForQueries = (queryList: QueryDictionary): boolean => {

function QueryAutoRefresh({
queries,
refreshQueries,
queriesLastUpdate,
}: QueryAutoRefreshProps) {
// We do not want to spam requests in the case of slow connections and potentially receive responses out of order
// pendingRequest check ensures we only have one active http call to check for query statuses
const [pendingRequest, setPendingRequest] = useState(false);
const pendingRequestRef = useRef(false);
const cleanInactiveRequestRef = useRef(false);
const dispatch = useDispatch();

const checkForRefresh = () => {
if (!pendingRequest && shouldCheckForQueries(queries)) {
const shouldRequestChecking = shouldCheckForQueries(queries);
if (!pendingRequestRef.current && shouldRequestChecking) {
const params = rison.encode({
last_updated_ms: queriesLastUpdate - QUERY_UPDATE_BUFFER_MS,
});

setPendingRequest(true);
pendingRequestRef.current = true;
SupersetClient.get({
endpoint: `/api/v1/query/updated_since?q=${params}`,
timeout: QUERY_TIMEOUT_LIMIT,
})
.then(({ json }) => {
if (json) {
const jsonPayload = json as { result?: QueryResponse[] };
const queries =
jsonPayload?.result?.reduce((acc, current) => {
acc[current.id] = current;
return acc;
}, {}) ?? {};
refreshQueries?.(queries);
if (jsonPayload?.result?.length) {
const queries =
jsonPayload?.result?.reduce((acc, current) => {
acc[current.id] = current;
return acc;
}, {}) ?? {};
dispatch(refreshQueries(queries));
} else {
dispatch(clearInactiveQueries());
}
}
})
.catch(() => {})
.finally(() => {
setPendingRequest(false);
pendingRequestRef.current = false;
});
}
if (!cleanInactiveRequestRef.current && !shouldRequestChecking) {
dispatch(clearInactiveQueries());
cleanInactiveRequestRef.current = true;
}
};

// Solves issue where direct usage of setInterval in function components
Expand Down
15 changes: 15 additions & 0 deletions superset-frontend/src/SqlLab/reducers/sqlLab.js
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,21 @@ export default function sqlLabReducer(state = {}, action) {
}
return { ...state, queries: newQueries, queriesLastUpdate };
},
[actions.CLEAR_INACTIVE_QUERIES]() {
const { queries } = state;
const cleanedQueries = Object.fromEntries(
Object.entries(queries).filter(([, query]) => {
if (
['running', 'pending'].includes(query.state) &&
query.progress === 0
) {
return false;
}
return true;
}),
);
return { ...state, queries: cleanedQueries };
},
[actions.SET_USER_OFFLINE]() {
return { ...state, offline: action.offline };
},
Expand Down