diff --git a/app/client/src/UITelemetry/generateWebWorkerTraces.ts b/app/client/src/UITelemetry/generateWebWorkerTraces.ts index c1c22d9fd97c..f677f1da611a 100644 --- a/app/client/src/UITelemetry/generateWebWorkerTraces.ts +++ b/app/client/src/UITelemetry/generateWebWorkerTraces.ts @@ -14,7 +14,7 @@ export interface WebworkerSpanData { //to regular otlp telemetry data and subsequently exported to our telemetry collector export const newWebWorkerSpanData = ( spanName: string, - attributes: SpanAttributes, + attributes: SpanAttributes = {}, ): WebworkerSpanData => { return { attributes, @@ -28,6 +28,21 @@ const addEndTimeForWebWorkerSpanData = (span: WebworkerSpanData) => { span.endTime = Date.now(); }; +export const profileAsyncFn = async ( + spanName: string, + fn: () => Promise, + allSpans: Record, + attributes: SpanAttributes = {}, +) => { + const span = newWebWorkerSpanData(spanName, attributes); + const res: T = await fn(); + + addEndTimeForWebWorkerSpanData(span); + allSpans[spanName] = span; + + return res; +}; + export const profileFn = ( spanName: string, attributes: SpanAttributes = {}, diff --git a/app/client/src/ce/workers/Evaluation/evalWorkerActions.ts b/app/client/src/ce/workers/Evaluation/evalWorkerActions.ts index ffd267a62e24..cee80158781f 100644 --- a/app/client/src/ce/workers/Evaluation/evalWorkerActions.ts +++ b/app/client/src/ce/workers/Evaluation/evalWorkerActions.ts @@ -1,6 +1,5 @@ export enum EVAL_WORKER_SYNC_ACTION { SETUP = "SETUP", - EVAL_TREE = "EVAL_TREE", EVAL_ACTION_BINDINGS = "EVAL_ACTION_BINDINGS", CLEAR_CACHE = "CLEAR_CACHE", VALIDATE_PROPERTY = "VALIDATE_PROPERTY", @@ -16,6 +15,7 @@ export enum EVAL_WORKER_SYNC_ACTION { } export enum EVAL_WORKER_ASYNC_ACTION { + EVAL_TREE = "EVAL_TREE", EVAL_TRIGGER = "EVAL_TRIGGER", EVAL_EXPRESSION = "EVAL_EXPRESSION", LOAD_LIBRARIES = "LOAD_LIBRARIES", diff --git a/app/client/src/sagas/EvaluationsSaga.test.ts b/app/client/src/sagas/EvaluationsSaga.test.ts index 57fa288ba8f0..2cad962369ad 100644 --- a/app/client/src/sagas/EvaluationsSaga.test.ts +++ b/app/client/src/sagas/EvaluationsSaga.test.ts @@ -20,6 +20,13 @@ import { } from "ee/constants/ReduxActionConstants"; import { fetchPluginFormConfigsSuccess } from "actions/pluginActions"; import { createJSCollectionSuccess } from "actions/jsActionActions"; +import { getInstanceId } from "ee/selectors/tenantSelectors"; +import { + getApplicationLastDeployedAt, + getCurrentApplicationId, + getCurrentPageId, +} from "selectors/editorSelectors"; + jest.mock("loglevel"); describe("evaluateTreeSaga", () => { @@ -40,8 +47,22 @@ describe("evaluateTreeSaga", () => { [select(getSelectedAppTheme), {}], [select(getAppMode), false], [select(getWidgetsMeta), {}], + [select(getInstanceId), "instanceId"], + [select(getCurrentApplicationId), "applicationId"], + [select(getCurrentPageId), "pageId"], + [ + select(getApplicationLastDeployedAt), + new Date("11 September 2024").toISOString(), + ], ]) .call(evalWorker.request, EVAL_WORKER_ACTIONS.EVAL_TREE, { + cacheProps: { + instanceId: "instanceId", + appId: "applicationId", + pageId: "pageId", + appMode: false, + timestamp: new Date("11 September 2024").toISOString(), + }, unevalTree: unEvalAndConfigTree, widgetTypeConfigMap: undefined, widgets: {}, @@ -71,8 +92,22 @@ describe("evaluateTreeSaga", () => { [select(getSelectedAppTheme), {}], [select(getAppMode), false], [select(getWidgetsMeta), {}], + [select(getInstanceId), "instanceId"], + [select(getCurrentApplicationId), "applicationId"], + [select(getCurrentPageId), "pageId"], + [ + select(getApplicationLastDeployedAt), + new Date("11 September 2024").toISOString(), + ], ]) .call(evalWorker.request, EVAL_WORKER_ACTIONS.EVAL_TREE, { + cacheProps: { + instanceId: "instanceId", + appId: "applicationId", + pageId: "pageId", + appMode: false, + timestamp: new Date("11 September 2024").toISOString(), + }, unevalTree: unEvalAndConfigTree, widgetTypeConfigMap: undefined, widgets: {}, @@ -111,8 +146,22 @@ describe("evaluateTreeSaga", () => { [select(getSelectedAppTheme), {}], [select(getAppMode), false], [select(getWidgetsMeta), {}], + [select(getInstanceId), "instanceId"], + [select(getCurrentApplicationId), "applicationId"], + [select(getCurrentPageId), "pageId"], + [ + select(getApplicationLastDeployedAt), + new Date("11 September 2024").toISOString(), + ], ]) .call(evalWorker.request, EVAL_WORKER_ACTIONS.EVAL_TREE, { + cacheProps: { + instanceId: "instanceId", + appId: "applicationId", + pageId: "pageId", + appMode: false, + timestamp: new Date("11 September 2024").toISOString(), + }, unevalTree: unEvalAndConfigTree, widgetTypeConfigMap: undefined, widgets: {}, diff --git a/app/client/src/sagas/EvaluationsSaga.ts b/app/client/src/sagas/EvaluationsSaga.ts index 5915b7b29a1d..eaf5d9bcabf6 100644 --- a/app/client/src/sagas/EvaluationsSaga.ts +++ b/app/client/src/sagas/EvaluationsSaga.ts @@ -111,6 +111,12 @@ import { evalErrorHandler } from "./EvalErrorHandler"; import AnalyticsUtil from "ee/utils/AnalyticsUtil"; import { endSpan, startRootSpan } from "UITelemetry/generateTraces"; import { transformTriggerEvalErrors } from "ee/sagas/helpers"; +import { + getApplicationLastDeployedAt, + getCurrentApplicationId, + getCurrentPageId, +} from "selectors/editorSelectors"; +import { getInstanceId } from "ee/selectors/tenantSelectors"; const APPSMITH_CONFIGS = getAppsmithConfigs(); @@ -261,7 +267,10 @@ export function* evaluateTreeSaga( yield select(getSelectedAppTheme); log.debug({ unevalTree, configTree: unEvalAndConfigTree.configTree }); - + const instanceId: string = yield select(getInstanceId); + const applicationId: string = yield select(getCurrentApplicationId); + const pageId: string = yield select(getCurrentPageId); + const lastDeployedAt: string = yield select(getApplicationLastDeployedAt); const appMode: ReturnType = yield select(getAppMode); const widgetsMeta: ReturnType = yield select(getWidgetsMeta); @@ -269,6 +278,13 @@ export function* evaluateTreeSaga( const shouldRespondWithLogs = log.getLevel() === log.levels.DEBUG; const evalTreeRequestData: EvalTreeRequestData = { + cacheProps: { + appMode, + appId: applicationId, + pageId, + timestamp: lastDeployedAt, + instanceId, + }, unevalTree: unEvalAndConfigTree, widgetTypeConfigMap, widgets, diff --git a/app/client/src/workers/Evaluation/__tests__/evaluation.test.ts b/app/client/src/workers/Evaluation/__tests__/evaluation.test.ts index d1f6ed7a08d9..dd25e57bb1e9 100644 --- a/app/client/src/workers/Evaluation/__tests__/evaluation.test.ts +++ b/app/client/src/workers/Evaluation/__tests__/evaluation.test.ts @@ -18,6 +18,7 @@ import WidgetFactory from "WidgetProvider/factory"; import { generateDataTreeWidget } from "entities/DataTree/dataTreeWidget"; import { sortObjectWithArray } from "../../../utils/treeUtils"; import klona from "klona"; +import { APP_MODE } from "entities/App"; const klonaFullSpy = jest.fn(); @@ -565,8 +566,19 @@ describe("DataTreeEvaluator", () => { const evaluator = new DataTreeEvaluator(WIDGET_CONFIG_MAP); - it("Checks the number of clone operations in first tree flow", () => { - evaluator.setupFirstTree(unEvalTree, configTree); + it("Checks the number of clone operations in first tree flow", async () => { + await evaluator.setupFirstTree( + unEvalTree, + configTree, + {}, + { + appId: "appId", + pageId: "pageId", + timestamp: "timestamp", + appMode: APP_MODE.PUBLISHED, + instanceId: "instanceId", + }, + ); evaluator.evalAndValidateFirstTree(); // Hard check to not regress on the number of clone operations. Try to improve this number. expect(klonaFullSpy).toBeCalledTimes(41); diff --git a/app/client/src/workers/Evaluation/evalTreeWithChanges.test.ts b/app/client/src/workers/Evaluation/evalTreeWithChanges.test.ts index 929aa0a36382..5ee2ba8b677c 100644 --- a/app/client/src/workers/Evaluation/evalTreeWithChanges.test.ts +++ b/app/client/src/workers/Evaluation/evalTreeWithChanges.test.ts @@ -9,6 +9,7 @@ import type { WidgetEntity } from "plugins/Linting/lib/entity/WidgetEntity"; import type { UpdateDataTreeMessageData } from "sagas/EvalWorkerActionSagas"; import DataTreeEvaluator from "workers/common/DataTreeEvaluator"; import * as evalTreeWithChanges from "./evalTreeWithChanges"; +import { APP_MODE } from "entities/App"; export const BASE_WIDGET = { widgetId: "randomID", widgetName: "randomWidgetName", @@ -184,9 +185,20 @@ describe("evaluateAndGenerateResponse", () => { return updates.filter((p: any) => !p.rhs.__evaluation__); }; - beforeEach(() => { + beforeEach(async () => { evaluator = new DataTreeEvaluator(WIDGET_CONFIG_MAP); - evaluator.setupFirstTree(unEvalTree, configTree); + await evaluator.setupFirstTree( + unEvalTree, + configTree, + {}, + { + appId: "appId", + pageId: "pageId", + timestamp: "timestamp", + appMode: APP_MODE.PUBLISHED, + instanceId: "instanceId", + }, + ); evaluator.evalAndValidateFirstTree(); }); diff --git a/app/client/src/workers/Evaluation/handlers/evalTree.ts b/app/client/src/workers/Evaluation/handlers/evalTree.ts index d8098a6e45e9..ee786065cba4 100644 --- a/app/client/src/workers/Evaluation/handlers/evalTree.ts +++ b/app/client/src/workers/Evaluation/handlers/evalTree.ts @@ -14,11 +14,7 @@ import { CrashingError, getSafeToRenderDataTree, } from "ee/workers/Evaluation/evaluationUtils"; -import type { - EvalTreeRequestData, - EvalTreeResponseData, - EvalWorkerSyncRequest, -} from "../types"; +import type { EvalTreeRequestData, EvalWorkerASyncRequest } from "../types"; import { clearAllIntervals } from "../fns/overrides/interval"; import JSObjectCollection from "workers/Evaluation/JSObject/Collection"; import { getJSVariableCreatedEvents } from "../JSObject/JSVariableEvents"; @@ -33,6 +29,7 @@ import { MessageType, sendMessage } from "utils/MessageUtil"; import { profileFn, newWebWorkerSpanData, + profileAsyncFn, } from "UITelemetry/generateWebWorkerTraces"; import type { SpanAttributes } from "UITelemetry/generateTraces"; import type { CanvasWidgetsReduxState } from "reducers/entityReducers/canvasWidgetsReducer"; @@ -49,9 +46,9 @@ export let canvasWidgetsMeta: Record; export let metaWidgetsCache: MetaWidgetsReduxState; export let canvasWidgets: CanvasWidgetsReduxState; -export function evalTree( - request: EvalWorkerSyncRequest, -): EvalTreeResponseData { +export async function evalTree( + request: EvalWorkerASyncRequest, +) { const { data, webworkerTelemetry } = request; webworkerTelemetry["transferDataToWorkerThread"].endTime = Date.now(); @@ -76,6 +73,7 @@ export function evalTree( affectedJSObjects, allActionValidationConfig, appMode, + cacheProps, forceEvaluation, metaWidgets, shouldReplay, @@ -109,16 +107,17 @@ export function evalTree( allActionValidationConfig, ); - const setupFirstTreeResponse = profileFn( + const setupFirstTreeResponse = await profileAsyncFn( "setupFirstTree", - { description: "during initialisation" }, + (dataTreeEvaluator as DataTreeEvaluator).setupFirstTree.bind( + dataTreeEvaluator, + unevalTree, + configTree, + webworkerTelemetry, + cacheProps, + ), webworkerTelemetry, - () => - (dataTreeEvaluator as DataTreeEvaluator).setupFirstTree( - unevalTree, - configTree, - webworkerTelemetry, - ), + { description: "during initialisation" }, ); evalOrder = setupFirstTreeResponse.evalOrder; @@ -128,8 +127,9 @@ export function evalTree( "evalAndValidateFirstTree", { description: "during initialisation" }, webworkerTelemetry, - () => - (dataTreeEvaluator as DataTreeEvaluator).evalAndValidateFirstTree(), + (dataTreeEvaluator as DataTreeEvaluator).evalAndValidateFirstTree.bind( + dataTreeEvaluator, + ), ); dataTree = makeEntityConfigsAsObjProperties(dataTreeResponse.evalTree, { @@ -160,15 +160,17 @@ export function evalTree( ); } - const setupFirstTreeResponse = profileFn( + const setupFirstTreeResponse = await profileAsyncFn( "setupFirstTree", - { description: "non-initialisation" }, + (dataTreeEvaluator as DataTreeEvaluator).setupFirstTree.bind( + dataTreeEvaluator, + unevalTree, + configTree, + webworkerTelemetry, + cacheProps, + ), webworkerTelemetry, - () => - (dataTreeEvaluator as DataTreeEvaluator).setupFirstTree( - unevalTree, - configTree, - ), + { description: "non-initialisation" }, ); isCreateFirstTree = true; diff --git a/app/client/src/workers/Evaluation/handlers/index.ts b/app/client/src/workers/Evaluation/handlers/index.ts index b2dc7d81e664..1c8a79b3f1b9 100644 --- a/app/client/src/workers/Evaluation/handlers/index.ts +++ b/app/client/src/workers/Evaluation/handlers/index.ts @@ -31,7 +31,6 @@ const syncHandlerMap: Record< (req: EvalWorkerSyncRequest) => any > = { [EVAL_WORKER_ACTIONS.EVAL_ACTION_BINDINGS]: evalActionBindings, - [EVAL_WORKER_ACTIONS.EVAL_TREE]: evalTree, [EVAL_WORKER_ACTIONS.EVAL_TREE_WITH_CHANGES]: evalTreeWithChanges, [EVAL_WORKER_ACTIONS.UNDO]: undo, [EVAL_WORKER_ACTIONS.REDO]: redo, @@ -52,6 +51,7 @@ const asyncHandlerMap: Record< // eslint-disable-next-line @typescript-eslint/no-explicit-any (req: EvalWorkerASyncRequest) => any > = { + [EVAL_WORKER_ACTIONS.EVAL_TREE]: evalTree, [EVAL_WORKER_ACTIONS.EVAL_TRIGGER]: evalTrigger, [EVAL_WORKER_ACTIONS.EVAL_EXPRESSION]: evalExpression, [EVAL_WORKER_ACTIONS.LOAD_LIBRARIES]: loadLibraries, diff --git a/app/client/src/workers/Evaluation/types.ts b/app/client/src/workers/Evaluation/types.ts index bb6cdebfd93b..5fcd231b003c 100644 --- a/app/client/src/workers/Evaluation/types.ts +++ b/app/client/src/workers/Evaluation/types.ts @@ -18,6 +18,7 @@ import type { APP_MODE } from "entities/App"; import type { WebworkerSpanData } from "UITelemetry/generateWebWorkerTraces"; import type { SpanAttributes } from "UITelemetry/generateTraces"; import type { AffectedJSObjects } from "sagas/EvaluationsSagaUtils"; +import type { ICacheProps } from "../common/AppComputationCache/types"; // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -34,6 +35,7 @@ export type EvalWorkerASyncRequest = WorkerRequest< export type EvalWorkerResponse = EvalTreeResponseData | boolean | unknown; export interface EvalTreeRequestData { + cacheProps: ICacheProps; unevalTree: unEvalAndConfigTree; widgetTypeConfigMap: WidgetTypeConfigMap; widgets: CanvasWidgetsReduxState; diff --git a/app/client/src/workers/common/AppComputationCache/AppComputationCache.test.ts b/app/client/src/workers/common/AppComputationCache/AppComputationCache.test.ts new file mode 100644 index 000000000000..e3a837485c9c --- /dev/null +++ b/app/client/src/workers/common/AppComputationCache/AppComputationCache.test.ts @@ -0,0 +1,519 @@ +import { EComputationCacheName, type ICacheProps } from "./types"; +import { APP_MODE } from "entities/App"; +import localforage from "localforage"; +import loglevel from "loglevel"; +import { AppComputationCache } from "./index"; + +jest.useFakeTimers(); + +// Mock functions for the main store +const setItemMock = jest.fn(); +const getItemMock = jest.fn(); +const keysMock = jest.fn(); +const removeItemMock = jest.fn(); + +// Mock functions for the cache logs store +const setItemMockLogs = jest.fn(); +const getItemMockLogs = jest.fn(); +const keysMockLogs = jest.fn(); +const removeItemMockLogs = jest.fn(); + +// Override the localforage driver to mock the local storage +localforage.defineDriver({ + _driver: localforage.LOCALSTORAGE, + _initStorage: jest.fn(), + + // These methods will be used by the instances created in AppComputationCache + keys: keysMock, + getItem: getItemMock, + setItem: setItemMock, + removeItem: removeItemMock, + + iterate: jest.fn(), + key: jest.fn(), + length: jest.fn(), + clear: jest.fn(), +}); + +// Mock localforage.createInstance to return our mocks +jest.spyOn(localforage, "createInstance").mockImplementation((options) => { + if (options.storeName === "cachedResults") { + return { + ...localforage, + setItem: setItemMock, + getItem: getItemMock, + keys: keysMock, + removeItem: removeItemMock, + }; + } else if (options.storeName === "cacheMetadataStore") { + return { + ...localforage, + setItem: setItemMockLogs, + getItem: getItemMockLogs, + keys: keysMockLogs, + removeItem: removeItemMockLogs, + }; + } else { + throw new Error("Unknown store"); + } +}); + +describe("AppComputationCache", () => { + let appComputationCache: AppComputationCache; + + beforeEach(() => { + jest.clearAllMocks(); + AppComputationCache.resetInstance(); + + // Now instantiate the singleton after mocks are set up + appComputationCache = AppComputationCache.getInstance(); + }); + + describe("generateCacheKey", () => { + test("should generate the correct cache key", () => { + const cacheProps: ICacheProps = { + appMode: APP_MODE.PUBLISHED, + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const cacheKey = appComputationCache.generateCacheKey({ + cacheName, + cacheProps, + }); + + expect(cacheKey).toBe( + `${cacheProps.instanceId}>${cacheProps.appId}>${cacheProps.pageId}>${cacheProps.appMode}>${new Date( + cacheProps.timestamp, + ).getTime()}>${cacheName}`, + ); + }); + }); + + describe("isComputationCached", () => { + test("should return false for EDIT mode", () => { + const cacheProps: ICacheProps = { + appMode: APP_MODE.EDIT, + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const result = appComputationCache.isComputationCached({ + cacheName, + cacheProps, + }); + + expect(result).toBe(false); + }); + + test("should return true for PUBLISHED mode", () => { + const cacheProps: ICacheProps = { + appMode: APP_MODE.PUBLISHED, + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const result = appComputationCache.isComputationCached({ + cacheName, + cacheProps, + }); + + expect(result).toBe(true); + }); + + test("should return false if appMode is undefined", () => { + const cacheProps: ICacheProps = { + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const result = appComputationCache.isComputationCached({ + cacheName, + cacheProps, + }); + + expect(result).toBe(false); + }); + + test("should return false if timestamp is undefined", () => { + const cacheProps: ICacheProps = { + appMode: APP_MODE.PUBLISHED, + timestamp: "", + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const result = appComputationCache.isComputationCached({ + cacheName, + cacheProps, + }); + + expect(result).toBe(false); + }); + }); + + describe("getCachedComputationResult", () => { + test("should call getItemMock and return null if cache miss", async () => { + const cacheProps: ICacheProps = { + appMode: APP_MODE.PUBLISHED, + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const cacheKey = appComputationCache.generateCacheKey({ + cacheName, + cacheProps, + }); + + getItemMock.mockResolvedValue(null); + keysMock.mockResolvedValue([]); + + const result = await appComputationCache.getCachedComputationResult({ + cacheName, + cacheProps, + }); + + expect(getItemMock).toHaveBeenCalledWith(cacheKey); + expect(result).toBe(null); + + jest.advanceTimersByTime(5000); + + expect(keysMock).toHaveBeenCalledTimes(1); + }); + + test("should call deleteInvalidCacheEntries on cache miss after 10 seconds", async () => { + const cacheProps: ICacheProps = { + appMode: APP_MODE.PUBLISHED, + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const cacheKey = appComputationCache.generateCacheKey({ + cacheName, + cacheProps, + }); + + getItemMock.mockResolvedValue(null); + keysMock.mockResolvedValue([]); + + const result = await appComputationCache.getCachedComputationResult({ + cacheName, + cacheProps, + }); + + expect(getItemMock).toHaveBeenCalledWith(cacheKey); + expect(result).toBe(null); + + jest.advanceTimersByTime(2500); + expect(keysMock).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(2500); + jest.runAllTimers(); + + expect(keysMock).toHaveBeenCalledTimes(1); + }); + + test("should call getItemMock and return cached value if cache hit", async () => { + const cacheProps: ICacheProps = { + appMode: APP_MODE.PUBLISHED, + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const cacheKey = appComputationCache.generateCacheKey({ + cacheName, + cacheProps, + }); + + getItemMock.mockResolvedValue({ value: "cachedValue" }); + + const result = await appComputationCache.getCachedComputationResult({ + cacheName, + cacheProps, + }); + + expect(getItemMock).toHaveBeenCalledWith(cacheKey); + expect(result).toBe("cachedValue"); + }); + }); + + describe("cacheComputationResult", () => { + test("should store computation result and call trackCacheUsage when shouldCache is true", async () => { + const cacheProps: ICacheProps = { + appMode: APP_MODE.PUBLISHED, + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const cacheKey = appComputationCache.generateCacheKey({ + cacheName, + cacheProps, + }); + + const computationResult = "computedValue"; + + const trackCacheUsageSpy = jest.spyOn( + appComputationCache, + "trackCacheUsage", + ); + + await appComputationCache.cacheComputationResult({ + cacheName, + cacheProps, + computationResult, + }); + + expect(setItemMock).toHaveBeenCalledWith(cacheKey, { + value: computationResult, + }); + expect(trackCacheUsageSpy).toHaveBeenCalledWith(cacheKey); + + trackCacheUsageSpy.mockRestore(); + }); + + test("should not store computation result when shouldCache is false", async () => { + const cacheProps: ICacheProps = { + appMode: APP_MODE.EDIT, + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const computationResult = "computedValue"; + + await appComputationCache.cacheComputationResult({ + cacheName, + cacheProps, + computationResult, + }); + + expect(setItemMock).not.toHaveBeenCalled(); + }); + }); + + describe("fetchOrCompute", () => { + test("should return cached result if available", async () => { + const cacheProps: ICacheProps = { + appMode: APP_MODE.PUBLISHED, + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const cacheKey = appComputationCache.generateCacheKey({ + cacheName, + cacheProps, + }); + + getItemMock.mockResolvedValue({ value: "cachedValue" }); + + const computeFn = jest.fn(() => "computedValue"); + + const result = await appComputationCache.fetchOrCompute({ + cacheName, + cacheProps, + computeFn, + }); + + expect(getItemMock).toHaveBeenCalledWith(cacheKey); + expect(computeFn).not.toHaveBeenCalled(); + expect(result).toBe("cachedValue"); + }); + + test("should compute, cache, and return result if not in cache", async () => { + getItemMock.mockResolvedValue(null); + + const cacheProps: ICacheProps = { + appMode: APP_MODE.PUBLISHED, + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const cacheKey = appComputationCache.generateCacheKey({ + cacheName, + cacheProps, + }); + + const computationResult = "computedValue"; + + const computeFn = jest.fn(() => computationResult); + + const cacheComputationResultSpy = jest.spyOn( + appComputationCache, + "cacheComputationResult", + ); + + const result = await appComputationCache.fetchOrCompute({ + cacheName, + cacheProps, + computeFn, + }); + + expect(getItemMock).toHaveBeenCalledWith(cacheKey); + expect(computeFn).toHaveBeenCalled(); + expect(cacheComputationResultSpy).toHaveBeenCalledWith({ + cacheName, + cacheProps, + computationResult, + }); + expect(result).toBe(computationResult); + + cacheComputationResultSpy.mockRestore(); + }); + + test("should handle cache errors and compute result", async () => { + getItemMock.mockRejectedValue(new Error("Cache access error")); + + const defaultLogLevel = loglevel.getLevel(); + + loglevel.setLevel("SILENT"); + + const cacheProps: ICacheProps = { + appMode: APP_MODE.PUBLISHED, + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const cacheName = EComputationCacheName.ALL_KEYS; + + const computationResult = "computedValue"; + + const computeFn = jest.fn(() => computationResult); + + const cacheComputationResultSpy = jest.spyOn( + appComputationCache, + "cacheComputationResult", + ); + + const result = await appComputationCache.fetchOrCompute({ + cacheName, + cacheProps, + computeFn, + }); + + expect(getItemMock).toHaveBeenCalled(); + expect(computeFn).toHaveBeenCalled(); + expect(cacheComputationResultSpy).toHaveBeenCalledWith({ + cacheName, + cacheProps, + computationResult, + }); + expect(result).toBe(computationResult); + + cacheComputationResultSpy.mockRestore(); + loglevel.setLevel(defaultLogLevel); + }); + }); + + describe("deleteInvalidCacheEntries", () => { + test("should delete old cache entries", async () => { + const cacheProps: ICacheProps = { + appMode: APP_MODE.PUBLISHED, + timestamp: new Date("11 September 2024").toISOString(), + appId: "appId", + instanceId: "instanceId", + pageId: "pageId", + }; + + const currentTimestamp = new Date(cacheProps.timestamp).getTime(); + + const currentCacheKey = [ + cacheProps.instanceId, + cacheProps.appId, + cacheProps.pageId, + cacheProps.appMode, + currentTimestamp, + EComputationCacheName.ALL_KEYS, + ].join(">"); + + const oldTimestamp = new Date("10 September 2024").getTime(); + + const oldCacheKey = [ + cacheProps.instanceId, + cacheProps.appId, + cacheProps.pageId, + cacheProps.appMode, + oldTimestamp, + EComputationCacheName.ALL_KEYS, + ].join(">"); + + keysMock.mockResolvedValue([currentCacheKey, oldCacheKey]); + + await appComputationCache.deleteInvalidCacheEntries(cacheProps); + + expect(keysMock).toHaveBeenCalled(); + + expect(removeItemMock).toHaveBeenCalledWith(oldCacheKey); + expect(removeItemMock).not.toHaveBeenCalledWith(currentCacheKey); + }); + }); + + describe("trackCacheUsage", () => { + test("should update cache log", async () => { + const cacheKey = "someCacheKey"; + + const existingCacheLog = { + lastAccessedAt: Date.now() - 1000, + createdAt: Date.now() - 2000, + }; + + getItemMockLogs.mockResolvedValue(existingCacheLog); + + await appComputationCache.trackCacheUsage(cacheKey); + + expect(getItemMockLogs).toHaveBeenCalledWith(cacheKey); + + expect(setItemMockLogs).toHaveBeenCalledWith(cacheKey, { + lastAccessedAt: expect.any(Number), + createdAt: existingCacheLog.createdAt, + }); + }); + }); +}); diff --git a/app/client/src/workers/common/AppComputationCache/index.ts b/app/client/src/workers/common/AppComputationCache/index.ts new file mode 100644 index 000000000000..a7e70ad36512 --- /dev/null +++ b/app/client/src/workers/common/AppComputationCache/index.ts @@ -0,0 +1,305 @@ +import { APP_MODE } from "entities/App"; +import localforage from "localforage"; +import isNull from "lodash/isNull"; +import loglevel from "loglevel"; +import { EComputationCacheName, type ICacheProps } from "./types"; +import debounce from "lodash/debounce"; + +interface ICachedData { + value: T; +} + +interface ICacheLog { + lastAccessedAt: number; + createdAt: number | null; +} + +export class AppComputationCache { + // Singleton instance + private static instance: AppComputationCache | null = null; + private static CACHE_KEY_DELIMITER = ">"; + + // The cache store for computation results + private readonly store: LocalForage; + + // The cache store for cache event logs + private readonly cacheLogsStore: LocalForage; + + // The app mode configuration for each cache type. This determines which app modes + // the cache should be enabled for + private readonly appModeConfig = { + [EComputationCacheName.DEPENDENCY_MAP]: [APP_MODE.PUBLISHED], + [EComputationCacheName.ALL_KEYS]: [APP_MODE.PUBLISHED], + }; + + constructor() { + this.store = localforage.createInstance({ + name: "AppComputationCache", + storeName: "cachedResults", + }); + + this.cacheLogsStore = localforage.createInstance({ + name: "AppComputationCache", + storeName: "cacheMetadataStore", + }); + } + + static getInstance(): AppComputationCache { + if (!AppComputationCache.instance) { + AppComputationCache.instance = new AppComputationCache(); + } + + return AppComputationCache.instance; + } + + debouncedDeleteInvalidCacheEntries = debounce( + this.deleteInvalidCacheEntries, + 5000, + ); + + /** + * Check if the computation result should be cached based on the app mode configuration + * @returns - A boolean indicating whether the cache should be enabled for the given app mode + */ + isComputationCached({ + cacheName, + cacheProps, + }: { + cacheName: EComputationCacheName; + cacheProps: ICacheProps; + }) { + const { appMode, timestamp } = cacheProps; + + if (!appMode || !timestamp) { + return false; + } + + return this.appModeConfig[cacheName].includes(appMode); + } + + /** + * Checks if the value should be cached based on the app mode configuration and + * caches the computation result if it should be cached. It also tracks the cache usage + * @returns - A promise that resolves when the computation result is cached + */ + async cacheComputationResult({ + cacheName, + cacheProps, + computationResult, + }: { + cacheProps: ICacheProps; + cacheName: EComputationCacheName; + computationResult: T; + }) { + const shouldCache = this.isComputationCached({ + cacheName, + cacheProps, + }); + + if (!shouldCache) { + return; + } + + const cacheKey = this.generateCacheKey({ cacheProps, cacheName }); + + try { + await this.store.setItem>(cacheKey, { + value: computationResult, + }); + + await this.trackCacheUsage(cacheKey); + } catch (error) { + loglevel.debug("Error caching computation result:", error); + } + } + + /** + * Gets the cached computation result if it exists and is valid + * @returns - A promise that resolves with the cached computation result or null if it does not exist + */ + async getCachedComputationResult({ + cacheName, + cacheProps, + }: { + cacheProps: ICacheProps; + cacheName: EComputationCacheName; + }): Promise { + const shouldCache = this.isComputationCached({ + cacheName, + cacheProps, + }); + + if (!shouldCache) { + return null; + } + + const cacheKey = this.generateCacheKey({ + cacheProps, + cacheName, + }); + + try { + const cached = await this.store.getItem>(cacheKey); + + if (isNull(cached)) { + // Cache miss + // Delete invalid cache entries when thread is idle + setTimeout(async () => { + await this.debouncedDeleteInvalidCacheEntries(cacheProps); + }, 0); + + return null; + } + + await this.trackCacheUsage(cacheKey); + + return cached.value; + } catch (error) { + loglevel.error("Error getting cache result:", error); + + return null; + } + } + + /** + * Generates a cache key from the index parts + * @returns - The generated cache key + */ + generateCacheKey({ + cacheName, + cacheProps, + }: { + cacheProps: ICacheProps; + cacheName: EComputationCacheName; + }) { + const { appId, appMode, instanceId, pageId, timestamp } = cacheProps; + + const timeStampEpoch = new Date(timestamp).getTime(); + const cacheKeyParts = [ + instanceId, + appId, + pageId, + appMode, + timeStampEpoch, + cacheName, + ]; + + return cacheKeyParts.join(AppComputationCache.CACHE_KEY_DELIMITER); + } + + /** + * Fetches the computation result from the cache or computes it if it does not exist + * @returns - A promise that resolves with the computation result + * @throws - Logs an error if the computation result cannot be fetched or computed and returns the computed fallback result + */ + async fetchOrCompute({ + cacheName, + cacheProps, + computeFn, + }: { + cacheProps: ICacheProps; + computeFn: () => Promise | T; + cacheName: EComputationCacheName; + }) { + const shouldCache = this.isComputationCached({ + cacheName, + cacheProps, + }); + + if (!shouldCache) { + return computeFn(); + } + + try { + const cachedResult = await this.getCachedComputationResult({ + cacheProps, + cacheName, + }); + + if (cachedResult) { + return cachedResult; + } + + const computationResult = await computeFn(); + + await this.cacheComputationResult({ + cacheProps, + computationResult, + cacheName, + }); + + return computationResult; + } catch (error) { + loglevel.error("Error getting cache result:", error); + const fallbackResult = await computeFn(); + + return fallbackResult; + } + } + + /** + * Tracks the cache usage by updating the last accessed timestamp of the cache + * @param name - The name of the cache + * @returns - A promise that resolves when the cache usage is tracked + * @throws - Logs an error if the cache usage cannot be tracked + */ + async trackCacheUsage(name: string) { + try { + const currentLog = await this.cacheLogsStore.getItem(name); + + await this.cacheLogsStore.setItem(name, { + lastAccessedAt: Date.now(), + createdAt: currentLog?.createdAt || Date.now(), + }); + } catch (error) { + loglevel.error("Error tracking cache usage:", error); + } + } + + /** + * Delete invalid cache entries + * @returns - A promise that resolves when the invalid cache entries are deleted + */ + + async deleteInvalidCacheEntries(cacheProps: ICacheProps) { + try { + // Get previous entry keys + const cacheKeys = await this.store.keys(); + + // Get invalid cache keys + const invalidCacheKeys = cacheKeys.filter((key) => { + const keyParts = key.split(AppComputationCache.CACHE_KEY_DELIMITER); + const cacheKeyTimestamp = parseInt(keyParts[4], 10); + + return ( + keyParts[0] === cacheProps.instanceId && + keyParts[1] === cacheProps.appId && + keyParts[3] === cacheProps.appMode && + cacheKeyTimestamp !== new Date(cacheProps.timestamp).getTime() + ); + }); + + // Delete invalid cache entries + await Promise.all( + invalidCacheKeys.map(async (key) => this.store.removeItem(key)), + ); + + // Delete invalid cache logs + await Promise.all( + invalidCacheKeys.map(async (key) => + this.cacheLogsStore.removeItem(key), + ), + ); + } catch (error) { + loglevel.error("Error deleting invalid cache entries:", error); + } + } + + static resetInstance() { + AppComputationCache.instance = null; + } +} + +export const appComputationCache = AppComputationCache.getInstance(); + +export default appComputationCache; diff --git a/app/client/src/workers/common/AppComputationCache/types.ts b/app/client/src/workers/common/AppComputationCache/types.ts new file mode 100644 index 000000000000..5ae66f61039b --- /dev/null +++ b/app/client/src/workers/common/AppComputationCache/types.ts @@ -0,0 +1,14 @@ +import type { APP_MODE } from "entities/App"; + +export enum EComputationCacheName { + DEPENDENCY_MAP = "DEPENDENCY_MAP", + ALL_KEYS = "ALL_KEYS", +} + +export interface ICacheProps { + appId: string; + pageId: string; + appMode?: APP_MODE; + timestamp: string; + instanceId: string; +} diff --git a/app/client/src/workers/common/DataTreeEvaluator/index.ts b/app/client/src/workers/common/DataTreeEvaluator/index.ts index c9b4c3031277..d874741e483b 100644 --- a/app/client/src/workers/common/DataTreeEvaluator/index.ts +++ b/app/client/src/workers/common/DataTreeEvaluator/index.ts @@ -136,12 +136,18 @@ import DataStore from "workers/Evaluation/dataStore"; import { updateTreeWithData } from "workers/Evaluation/dataStore/utils"; import microDiff from "microdiff"; import { + profileAsyncFn, profileFn, type WebworkerSpanData, } from "UITelemetry/generateWebWorkerTraces"; import type { SpanAttributes } from "UITelemetry/generateTraces"; import type { AffectedJSObjects } from "sagas/EvaluationsSagaUtils"; import generateOverrideContext from "ee/workers/Evaluation/generateOverrideContext"; +import appComputationCache from "../AppComputationCache"; +import { + EComputationCacheName, + type ICacheProps, +} from "../AppComputationCache/types"; type SortedDependencies = Array; export interface EvalProps { @@ -235,16 +241,14 @@ export default class DataTreeEvaluator { * Method to create all data required for linting and * evaluation of the first tree */ - setupFirstTree( + async setupFirstTree( // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any unEvalTree: any, configTree: ConfigTree, webworkerTelemetry: Record = {}, - ): { - jsUpdates: Record; - evalOrder: string[]; - } { + cacheProps: ICacheProps, + ) { this.setConfigTree(configTree); const totalFirstTreeSetupStartTime = performance.now(); @@ -287,19 +291,29 @@ export default class DataTreeEvaluator { ); const allKeysGenerationStartTime = performance.now(); - this.allKeys = getAllPaths(unEvalTreeWithStrigifiedJSFunctions); + this.allKeys = await appComputationCache.fetchOrCompute({ + cacheProps, + cacheName: EComputationCacheName.ALL_KEYS, + computeFn: () => getAllPaths(unEvalTreeWithStrigifiedJSFunctions), + }); + const allKeysGenerationEndTime = performance.now(); const createDependencyMapStartTime = performance.now(); - const { dependencies, inverseDependencies } = profileFn( + const { dependencies, inverseDependencies } = await profileAsyncFn( "createDependencyMap", - undefined, + async () => + createDependencyMap( + this, + localUnEvalTree, + configTree, + cacheProps, + webworkerTelemetry, + ), webworkerTelemetry, - () => { - return createDependencyMap(this, localUnEvalTree, configTree); - }, ); + const createDependencyMapEndTime = performance.now(); this.dependencies = dependencies; diff --git a/app/client/src/workers/common/DataTreeEvaluator/test.ts b/app/client/src/workers/common/DataTreeEvaluator/test.ts index f71e77021ea2..8c77aeaea266 100644 --- a/app/client/src/workers/common/DataTreeEvaluator/test.ts +++ b/app/client/src/workers/common/DataTreeEvaluator/test.ts @@ -26,6 +26,7 @@ import { } from "constants/AppsmithActionConstants/ActionConstants"; import generateOverrideContext from "ee/workers/Evaluation/generateOverrideContext"; import { klona } from "klona"; +import { APP_MODE } from "entities/App"; const widgetConfigMap: Record< string, @@ -278,10 +279,18 @@ describe("DataTreeEvaluator", () => { }); describe("test updateDependencyMap", () => { - beforeEach(() => { - dataTreeEvaluator.setupFirstTree( + beforeEach(async () => { + await dataTreeEvaluator.setupFirstTree( unEvalTree as unknown as DataTree, configTree as unknown as ConfigTree, + {}, + { + appId: "appId", + pageId: "pageId", + timestamp: "timestamp", + appMode: APP_MODE.PUBLISHED, + instanceId: "instanceId", + }, ); dataTreeEvaluator.evalAndValidateFirstTree(); }); @@ -373,10 +382,18 @@ describe("DataTreeEvaluator", () => { describe("array accessor dependency handling", () => { const dataTreeEvaluator = new DataTreeEvaluator(widgetConfigMap); - beforeEach(() => { - dataTreeEvaluator.setupFirstTree( + beforeEach(async () => { + await dataTreeEvaluator.setupFirstTree( nestedArrayAccessorCyclicDependency.initUnEvalTree, nestedArrayAccessorCyclicDependencyConfig.initConfigTree, + {}, + { + appId: "appId", + pageId: "pageId", + timestamp: new Date().toISOString(), + appMode: APP_MODE.PUBLISHED, + instanceId: "instanceId", + }, ); dataTreeEvaluator.evalAndValidateFirstTree(); }); diff --git a/app/client/src/workers/common/DependencyMap/index.ts b/app/client/src/workers/common/DependencyMap/index.ts index 07de9cec8f10..420284ce29bb 100644 --- a/app/client/src/workers/common/DependencyMap/index.ts +++ b/app/client/src/workers/common/DependencyMap/index.ts @@ -29,46 +29,91 @@ import { } from "ee/workers/Evaluation/Actions"; import { isWidgetActionOrJsObject } from "ee/entities/DataTree/utils"; import { getValidEntityType } from "workers/common/DataTreeEvaluator/utils"; +import appComputationCache from "../AppComputationCache"; +import { + EComputationCacheName, + type ICacheProps, +} from "../AppComputationCache/types"; import type DependencyMap from "entities/DependencyMap"; +import { + profileFn, + type WebworkerSpanData, +} from "UITelemetry/generateWebWorkerTraces"; +import type { SpanAttributes } from "UITelemetry/generateTraces"; -export function createDependencyMap( +export async function createDependencyMap( dataTreeEvalRef: DataTreeEvaluator, unEvalTree: DataTree, configTree: ConfigTree, + cacheProps: ICacheProps, + webworkerSpans: Record = {}, ) { const { allKeys, dependencyMap } = dataTreeEvalRef; + const allAppsmithInternalFunctions = convertArrayToObject( AppsmithFunctionsWithFields, ); const setterFunctions = getAllSetterFunctions(unEvalTree, configTree); - dependencyMap.addNodes( - { ...allKeys, ...allAppsmithInternalFunctions, ...setterFunctions }, - false, - ); - - Object.keys(configTree).forEach((entityName) => { - const entity = unEvalTree[entityName]; - const entityConfig = configTree[entityName]; - const entityDependencies = getEntityDependencies( - entity as DataTreeEntityObject, - entityConfig, - allKeys, + profileFn("createDependencyMap.addNodes", {}, webworkerSpans, async () => { + dependencyMap.addNodes( + { ...allKeys, ...allAppsmithInternalFunctions, ...setterFunctions }, + false, ); + }); + + const dependencyMapCache = + await appComputationCache.getCachedComputationResult< + Record + >({ + cacheProps, + cacheName: EComputationCacheName.DEPENDENCY_MAP, + }); - for (const path of Object.keys(entityDependencies)) { - const pathDependencies = entityDependencies[path]; - const { errors, references } = extractInfoFromBindings( - pathDependencies, + if (dependencyMapCache) { + profileFn("createDependencyMap.addDependency", {}, webworkerSpans, () => { + Object.entries(dependencyMapCache).forEach(([path, references]) => { + dependencyMap.addDependency(path, references); + }); + }); + } else { + let shouldCache = true; + + Object.keys(configTree).forEach((entityName) => { + const entity = unEvalTree[entityName]; + const entityConfig = configTree[entityName]; + const entityDependencies = getEntityDependencies( + entity as DataTreeEntityObject, + entityConfig, allKeys, ); - dependencyMap.addDependency(path, references); - dataTreeEvalRef.errors.push(...errors); - } - }); + for (const path of Object.keys(entityDependencies)) { + const pathDependencies = entityDependencies[path]; + const { errors, references } = extractInfoFromBindings( + pathDependencies, + allKeys, + ); + + dependencyMap.addDependency(path, references); + dataTreeEvalRef.errors.push(...errors); - DependencyMapUtils.makeParentsDependOnChildren(dependencyMap); + if (errors.length) { + shouldCache = false; + } + } + }); + + DependencyMapUtils.makeParentsDependOnChildren(dependencyMap); + + if (shouldCache) { + await appComputationCache.cacheComputationResult({ + cacheProps, + cacheName: EComputationCacheName.DEPENDENCY_MAP, + computationResult: dependencyMap.dependencies, + }); + } + } return { dependencies: dependencyMap.dependencies,