Skip to content

Commit

Permalink
feat: machine requests cache MAASENG-1834
Browse files Browse the repository at this point in the history
  • Loading branch information
petermakowski committed Jun 28, 2023
1 parent 707684e commit 49282b6
Show file tree
Hide file tree
Showing 28 changed files with 969 additions and 971 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ module.exports = {
},
],
"@typescript-eslint/consistent-type-imports": 2,
"@typescript-eslint/explicit-module-boundary-types": ["error"],
"@typescript-eslint/explicit-module-boundary-types": ["off"],
"import/namespace": "off",
"import/no-named-as-default": 0,
"import/order": [
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"@canonical/react-components": "0.38.0",
"@reduxjs/toolkit": "1.9.3",
"@sentry/browser": "5.30.0",
"@tanstack/query-core": "4.29.11",
"@tanstack/react-query": "4.29.13",
"classnames": "2.3.2",
"clone-deep": "4.0.1",
"date-fns": "2.29.3",
Expand Down
38 changes: 21 additions & 17 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { useEffect } from "react";
import { Notification } from "@canonical/react-components";
import { usePrevious } from "@canonical/react-components/dist/hooks";
import * as Sentry from "@sentry/browser";
import { QueryClientProvider } from "@tanstack/react-query";
import { useDispatch, useSelector } from "react-redux";

import packageInfo from "../../package.json";

import NavigationBanner from "./base/components/AppSideNavigation/NavigationBanner";
import PageContent from "./base/components/PageContent/PageContent";
import SectionHeader from "./base/components/SectionHeader";
import { queryClient } from "./base/sagas/websockets/handlers/queryCache";
import ThemePreviewContextProvider from "./base/theme-context";
import { MAAS_UI_ID } from "./constants";
import { formatErrors } from "./utils";
Expand Down Expand Up @@ -147,24 +149,26 @@ export const App = (): JSX.Element => {

return (
<div className="l-application" id={MAAS_UI_ID}>
<ThemePreviewContextProvider>
{connected && authLoaded && authenticated ? (
<AppSideNavigation />
) : (
<header className="l-navigation-bar is-pinned">
<div className="p-panel is-dark is-maas-default">
<div className="p-panel__header">
<NavigationBanner />
<QueryClientProvider client={queryClient}>
<ThemePreviewContextProvider>
{connected && authLoaded && authenticated ? (
<AppSideNavigation />
) : (
<header className="l-navigation-bar is-pinned">
<div className="p-panel is-dark is-maas-default">
<div className="p-panel__header">
<NavigationBanner />
</div>
</div>
</div>
</header>
)}

{content}
<aside className="l-status">
<StatusBar />
</aside>
</ThemePreviewContextProvider>
</header>
)}

{content}
<aside className="l-status">
<StatusBar />
</aside>
</ThemePreviewContextProvider>
</QueryClientProvider>
</div>
);
};
Expand Down
129 changes: 129 additions & 0 deletions src/app/base/sagas/websockets/handlers/queryCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { PayloadAction } from "@reduxjs/toolkit";
import { QueryClient, QueryCache } from "@tanstack/react-query";
import type { SagaGenerator } from "typed-redux-saga";
import { takeEvery, call, put, race, take } from "typed-redux-saga/macro";

import { actions as machineActions } from "app/store/machine";
import type { FetchResponse } from "app/store/machine/types";
import type {
WebSocketAction,
WebSocketRequest,
WebSocketResponseResult,
} from "websocket-client";

export const queryCache = new QueryCache();

export const queryClient = new QueryClient({
queryCache,
defaultOptions: {
queries: {
staleTime: 500,
},
},
});

export function* updateQueryCache(
action: PayloadAction<{ meta: { callId: string }; payload: FetchResponse }>
): SagaGenerator<void> {
// const queryKey = [callId].filter(Boolean);
// console.warn("action", action.meta, action.payload);
const { meta, model, callId } = action?.meta || {};

const queryKey = [meta, model, callId];
queryClient.setQueryData(queryKey, action.payload);
yield* put({ type: "queryCacheUpdated" });
}

export function* watchQueryCache(): SagaGenerator<void> {
yield* takeEvery(
[
"machine/deleteNotify",
"machine/updateNotify",
"resourcepool/updateNotify",
],
() => queryClient.invalidateQueries(["machine", "list"])
);
}

// A store of websocket requests that need to store their responses in the query cache. The map is between request id and redux action object.
export const queryCacheRequests = new Map<
WebSocketRequest["request_id"],
WebSocketAction
>();

/**
* Store the actions that need to store files in the file context.
*
* @param {Object} action - A Redux action.
* @param {Array} requestIDs - A list of ids for the requests associated with
* this action.
*/
export function storeQueryCacheActions(
action: WebSocketAction,
requestIDs: WebSocketRequest["request_id"][]
): void {
if (action?.meta?.callId) {
requestIDs.forEach((id) => {
queryCacheRequests.set(id, action);
});
}
}

/**
* Handle storing the result in query cache, if required.
*
* @param {Object} response - A websocket response.
*/
export function* handleQueryCacheRequest({
request_id,
result,
}: WebSocketResponseResult<string>): SagaGenerator<boolean> {
const queryCacheRequest = yield* call(
[queryCacheRequests, queryCacheRequests.get],
request_id
);
if (queryCacheRequest?.meta.callId) {
const { model, method } = queryCacheRequest.meta;
const queryKey = [model, method, JSON.stringify(queryCacheRequest.payload)];
queryClient.setQueryData(queryKey, result);

// dispatch success with "from cache" or something like that
yield* put({
type: "queryCacheUpdated",
payload: queryKey,
data: queryClient.getQueryData(queryKey),
});
// Clean up the previous request.
queryCacheRequests.delete(request_id);
}
return !!queryCacheRequest;
}

function* fetchMachinesAndResolveSaga(action) {
const { resolve, reject, callId } = action?.meta || {};

// Wait for either the success or failure action to be dispatched
const { success, failure } = yield race({
success: take(
(action) =>
action.meta?.callId === callId && action.type.endsWith("Success")
),
failure: take(
(action) =>
action.meta?.callId === callId && action.type.endsWith("Error")
),
});

if (success) {
resolve(success.payload); // resolve with the payload from fetchSuccess
} else {
reject(new Error(failure.payload)); // reject with the payload from fetchFailure wrapped in an Error object
}
}

export function* watchFetchMachinesAndResolve() {
yield takeEvery(
[machineActions.fetch.type, machineActions.count.type],
fetchMachinesAndResolveSaga
);
}
11 changes: 9 additions & 2 deletions src/app/base/sagas/websockets/websockets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ import {
isStartPollingAction,
isStopPollingAction,
} from "./handlers/polling-requests";
import { watchFetchMachinesAndResolve } from "./handlers/queryCache";
import { handleUnsubscribe, isUnsubscribeAction } from "./handlers/unsubscribe";

import type { MessageHandler, NextActionCreator } from "app/base/sagas/actions";
import {
type MessageHandler,
type NextActionCreator,
} from "app/base/sagas/actions";
import type { GenericMeta } from "app/store/utils/slice";
import { WebSocketMessageType } from "websocket-client";

Expand Down Expand Up @@ -204,6 +208,7 @@ export function* handleMessage(
handleFileContextRequest,
response
);

if (!action) {
return;
}
Expand Down Expand Up @@ -289,7 +294,7 @@ export function* sendMessage(
): SagaGenerator<void> {
const { meta, payload, type } = action;
const params = payload ? payload.params : null;
const { cache, identifier, method, model, nocache } = meta;
const { cache, identifier, method, model, nocache, callId } = meta;

Check warning on line 297 in src/app/base/sagas/websockets/websockets.ts

View workflow job for this annotation

GitHub Actions / Lint (16.x)

'callId' is assigned a value but never used. Allowed unused vars must match /^_/u
const endpoint = `${model}.${method}`;
const hasMultipleDispatches = meta.dispatchMultiple && Array.isArray(params);
// If method is 'list' and data has loaded/is loading, do not fetch again
Expand All @@ -305,6 +310,7 @@ export function* sendMessage(
if (isLoaded(endpoint)) {
return;
}

setLoaded(endpoint);
}
yield* put<Action & { meta: GenericMeta }>({
Expand Down Expand Up @@ -391,6 +397,7 @@ export function* setupWebSocket({
isStartPollingAction,
handlePolling
),
watchFetchMachinesAndResolve(),
// Take actions that should unsubscribe from entities.
takeEvery<WebSocketAction, (action: WebSocketAction) => void>(
isUnsubscribeAction,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Spinner } from "@canonical/react-components";
import { useQueryClient } from "@tanstack/react-query";
import { useDispatch } from "react-redux";

import CloneForm from "./CloneForm";
Expand Down Expand Up @@ -51,6 +52,7 @@ export const MachineActionFormWrapper = ({
setSearchFilter,
viewingDetails,
}: Props): JSX.Element | null => {
const queryClient = useQueryClient();
const onRenderRef = useScrollOnRender<HTMLDivElement>();
const dispatch = useDispatch();
const {
Expand Down Expand Up @@ -82,8 +84,7 @@ export const MachineActionFormWrapper = ({
selectedCountLoading,
};
const clearSelectedMachines = () => {
dispatch(machineActions.setSelected(null));
dispatch(machineActions.invalidateQueries());
queryClient.invalidateQueries(["machine", "list"]);
};

const filter = selectedToFilters(selectedMachines || null);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import reduxToolkit from "@reduxjs/toolkit";
import { QueryClientProvider } from "@tanstack/react-query";
import { Formik } from "formik";
import { Provider } from "react-redux";
import { MemoryRouter } from "react-router-dom";
Expand All @@ -9,6 +10,7 @@ import { Label as TagFormChangesLabel } from "../TagFormChanges/TagFormChanges";

import TagFormFields, { Label } from "./TagFormFields";

import { queryClient } from "app/base/sagas/websockets/handlers/queryCache";
import type { RootState } from "app/store/root/types";
import type { Tag, TagMeta } from "app/store/tag/types";
import { Label as AddTagFormLabel } from "app/tags/components/AddTagForm/AddTagForm";
Expand Down Expand Up @@ -163,21 +165,23 @@ it("updates the new tags after creating a tag", async () => {
const setNewTags = jest.fn();
const Form = ({ tags }: { tags: Tag[TagMeta.PK][] }) => (
<Provider store={store}>
<MemoryRouter>
<CompatRouter>
<Formik
initialValues={{ added: tags, removed: [] }}
onSubmit={jest.fn()}
>
<TagFormFields
machines={state.machine.items}
newTags={[]}
selectedCount={state.machine.items.length}
setNewTags={setNewTags}
/>
</Formik>
</CompatRouter>
</MemoryRouter>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CompatRouter>
<Formik
initialValues={{ added: tags, removed: [] }}
onSubmit={jest.fn()}
>
<TagFormFields
machines={state.machine.items}
newTags={[]}
selectedCount={state.machine.items.length}
setNewTags={setNewTags}
/>
</Formik>
</CompatRouter>
</MemoryRouter>
</QueryClientProvider>
</Provider>
);
const { rerender } = render(<Form tags={[]} />);
Expand Down
2 changes: 1 addition & 1 deletion src/app/machines/views/MachineList/MachineList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ describe("MachineList", () => {
loaded: true,
groups: [
machineStateListGroupFactory({
items: [machines[0].system_id],
items: [machines[0].system_id, machines[2].system_id],
name: "Deployed",
value: FetchNodeStatus.DEPLOYED,
}),
Expand Down
Loading

0 comments on commit 49282b6

Please sign in to comment.