From fd9134001422bd2bbbf9ce05856f2700e4b5f389 Mon Sep 17 00:00:00 2001 From: Joel Mut Date: Wed, 19 Jul 2023 15:23:57 +0200 Subject: [PATCH 1/5] Add MapOptimizer to reduce memory usage. --- Composer/packages/client/src/App.tsx | 16 ++- .../src/recoilModel/parsers/lgWorker.ts | 33 ++++++ .../client/src/recoilModel/parsers/types.ts | 4 + .../parsers/workers/lgParser.worker.ts | 25 ++++- .../src/recoilModel/utils/mapOptimizer.ts | 100 ++++++++++++++++++ 5 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts diff --git a/Composer/packages/client/src/App.tsx b/Composer/packages/client/src/App.tsx index 0db7f8b42f..5765fe59aa 100644 --- a/Composer/packages/client/src/App.tsx +++ b/Composer/packages/client/src/App.tsx @@ -2,17 +2,19 @@ // Licensed under the MIT License. import React, { Fragment, useEffect, useState } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useRecoilValue, useRecoilCallback, CallbackInterface } from 'recoil'; import { Header } from './components/Header'; import { Announcement } from './components/AppComponents/Announcement'; import { MainContainer } from './components/AppComponents/MainContainer'; -import { dispatcherState, userSettingsState } from './recoilModel'; +import { dispatcherState, userSettingsState, lgFileState } from './recoilModel'; import { loadLocale } from './utils/fileUtil'; import { useInitializeLogger } from './telemetry/useInitializeLogger'; import { setupIcons } from './setupIcons'; import { setOneAuthEnabled } from './utils/oneAuthUtil'; import { LoadingSpinner } from './components/LoadingSpinner'; +import lgWorker from './recoilModel/parsers/lgWorker'; +import { LgEventType } from './recoilModel/parsers/types'; setupIcons(); @@ -34,11 +36,21 @@ export const App: React.FC = () => { performAppCleanupOnQuit, setMachineInfo, } = useRecoilValue(dispatcherState); + const updateFile = useRecoilCallback((callbackHelpers: CallbackInterface) => async ({ projectId, value }) => { + callbackHelpers.set(lgFileState({ projectId, lgFileId: value.id }), value); + }); useEffect(() => { loadLocale(appLocale); }, [appLocale]); + useEffect(() => { + lgWorker.listen(LgEventType.OnUpdateLgFile, msg => { + const { projectId, payload } = msg.data; + updateFile({ projectId, value: payload }); + }) + }); + useEffect(() => { checkNodeVersion(); fetchExtensions(); diff --git a/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts b/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts index 83a246df1a..13b4b84d07 100644 --- a/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts +++ b/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts @@ -6,6 +6,7 @@ import Worker from './workers/lgParser.worker.ts'; import { BaseWorker } from './baseWorker'; import { LgActionType, + LgEventType, LgParsePayload, LgUpdateTemplatePayload, LgCreateTemplatePayload, @@ -20,6 +21,38 @@ import { // Wrapper class class LgWorker extends BaseWorker { + private listeners = new Map void)[]>(); + + constructor(worker: Worker) { + super(worker); + + worker.onmessage = (msg) => { + const { type } = msg.data; + + if (type === LgEventType.OnUpdateLgFile) { + this.listeners.get(type)?.forEach((cb) => cb(msg)); + } else { + this.handleMsg(msg); + } + }; + } + + listen(action: LgEventType, callback: (msg: MessageEvent) => void) { + if (this.listeners.has(action)) { + this.listeners.get(action)!.push(callback); + } else { + this.listeners.set(action, [callback]); + } + } + + async flush(): Promise { + return new Promise(async (resolve) => { + this.listeners.clear(); + const result = await super.flush(); + resolve(result); + }); + } + addProject(projectId: string) { return this.sendMsg(LgActionType.NewCache, { projectId }); } diff --git a/Composer/packages/client/src/recoilModel/parsers/types.ts b/Composer/packages/client/src/recoilModel/parsers/types.ts index 488d9b89e5..1e2e1b243b 100644 --- a/Composer/packages/client/src/recoilModel/parsers/types.ts +++ b/Composer/packages/client/src/recoilModel/parsers/types.ts @@ -156,6 +156,10 @@ export enum LgActionType { ParseAll = 'parse-all', } +export enum LgEventType { + OnUpdateLgFile = 'on-update-lgfile', +} + export enum IndexerActionType { Index = 'index', } diff --git a/Composer/packages/client/src/recoilModel/parsers/workers/lgParser.worker.ts b/Composer/packages/client/src/recoilModel/parsers/workers/lgParser.worker.ts index b24ece64c1..e78b98770b 100644 --- a/Composer/packages/client/src/recoilModel/parsers/workers/lgParser.worker.ts +++ b/Composer/packages/client/src/recoilModel/parsers/workers/lgParser.worker.ts @@ -5,6 +5,7 @@ import { lgImportResolverGenerator, LgFile } from '@bfc/shared'; import { LgActionType, + LgEventType, LgParsePayload, LgUpdateTemplatePayload, LgCreateTemplatePayload, @@ -16,6 +17,7 @@ import { LgCleanCachePayload, LgParseAllPayload, } from '../types'; +import { MapOptimizer } from '../../utils/mapOptimizer'; const ctx: Worker = self as any; @@ -197,6 +199,11 @@ export const handleMessage = (msg: LgMessageEvent) => { case LgActionType.Parse: { const { id, content, lgFiles, projectId } = msg.payload; + const cachedFile = cache.get(projectId, id); + if (cachedFile?.isContentUnparsed === false && cachedFile?.content === content) { + return filterParseResult(cachedFile); + } + const lgFile = lgUtil.parse(id, content, lgFiles); cache.set(projectId, lgFile); payload = filterParseResult(lgFile); @@ -206,12 +213,20 @@ export const handleMessage = (msg: LgMessageEvent) => { case LgActionType.ParseAll: { const { lgResources, projectId } = msg.payload; // We'll do the parsing when the file is required. Save empty LG instead. - payload = lgResources.map(({ id, content }) => { - const emptyLg = emptyLgFile(id, content); - cache.set(projectId, emptyLg); - return filterParseResult(emptyLg); + payload = lgResources.map(({ id, content }) => [id, emptyLgFile(id, content)]); + const resources = new Map(payload); + cache.projects.set(projectId, resources); + + const optimizer = new MapOptimizer(10, resources); + optimizer.onUpdate((_, value, ctx) => { + const refs = value.parseResult?.references?.map(({ name }) => name); + ctx.setReferences(refs); + }); + optimizer.onDelete((_, value) => { + const lgFile = emptyLgFile(value.id, value.content); + cache.set(projectId, lgFile); + ctx.postMessage({ type: LgEventType.OnUpdateLgFile, projectId, payload: lgFile }); }); - break; } diff --git a/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts b/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts new file mode 100644 index 0000000000..dcf9d856c2 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts @@ -0,0 +1,100 @@ +interface MapOptimizerTree { + timestamp: number; + references: Key[]; +} + +interface OnUpdateMapOptimizerContext { + setReferences(references: Key[]): void; +} + +export class MapOptimizer { + public tree = new Map>(); + private skipOptimize = new Set(); + + opUpdateCallback?: (key: Key, value: Value, ctx: OnUpdateMapOptimizerContext) => void; + onDeleteCallback?: (key: Key, value: Value) => void; + + constructor(private capacity: number, public list: Map) { + this.attach(); + } + + onUpdate(callback: (key: Key, value: Value, ctx: OnUpdateMapOptimizerContext) => void) { + this.opUpdateCallback = callback; + } + + onDelete(callback: (key: Key, value: Value) => void) { + this.onDeleteCallback = callback; + } + + private attach() { + const set = this.list.set; + this.list.set = (key, value) => { + if (!this.skipOptimize.has(key)) { + this.optimize(key, value); + } + const result = set.apply(this.list, [key, value]); + return result; + }; + } + + private optimize(keyToAdd: Key, valueToAdd: Value) { + const exists = this.tree.has(keyToAdd); + const context: MapOptimizerTree = { timestamp: Date.now(), references: [] }; + this.opUpdateCallback?.(keyToAdd, valueToAdd, { + setReferences: (references) => (context.references = references || []), + }); + this.tree.set(keyToAdd, context); + + if (exists) { + return; + } + + let processed: [Key, MapOptimizerTree][] = []; + const itemsToRemove = Array.from(this.tree.entries()) + .filter(([key]) => key !== keyToAdd) + .sort(([, v1], [, v2]) => v2.timestamp - v1.timestamp); + + while (this.capacity < this.tree.size) { + const itemToRemove = itemsToRemove.pop(); + if (!itemToRemove) { + break; + } + + const [key, { references }] = itemToRemove; + const ids = this.identify([key, ...references]); + + // Re-process previous items if an item gets deleted. + processed.push(itemToRemove); + if (ids.length > 0) { + itemsToRemove.push(...processed); + processed = []; + } + + for (const id of ids) { + this.tree.delete(id); + const listItem = this.list.get(id)!; + this.skipOptimize.add(id); + this.onDeleteCallback ? this.onDeleteCallback(id, listItem) : this.list.delete(id); + this.skipOptimize.delete(id); + } + } + } + + private identify(references: Key[], memo: Key[] = []) { + for (const reference of references) { + const found = this.tree.get(reference); + const existsOnMemo = () => memo.some((e) => found!.references.includes(e)); + const existsOnReferences = () => + Array.from(this.tree.values()).some(({ references }) => references.includes(reference)); + + if (!found || existsOnMemo() || existsOnReferences()) { + continue; + } + + memo.push(reference); + this.identify(found.references, memo); + } + + return memo; + } +} From 1d2bfa2362d516d972fc395ede77a4f441e87ca9 Mon Sep 17 00:00:00 2001 From: Joel Mut Date: Wed, 19 Jul 2023 16:18:27 +0200 Subject: [PATCH 2/5] Fix lint issues and add new file header --- Composer/packages/client/src/App.tsx | 4 ++-- .../packages/client/src/recoilModel/parsers/lgWorker.ts | 9 +++------ .../client/src/recoilModel/utils/mapOptimizer.ts | 9 ++++++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Composer/packages/client/src/App.tsx b/Composer/packages/client/src/App.tsx index 5765fe59aa..8fc8e14b27 100644 --- a/Composer/packages/client/src/App.tsx +++ b/Composer/packages/client/src/App.tsx @@ -45,10 +45,10 @@ export const App: React.FC = () => { }, [appLocale]); useEffect(() => { - lgWorker.listen(LgEventType.OnUpdateLgFile, msg => { + lgWorker.listen(LgEventType.OnUpdateLgFile, (msg) => { const { projectId, payload } = msg.data; updateFile({ projectId, value: payload }); - }) + }); }); useEffect(() => { diff --git a/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts b/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts index 13b4b84d07..4a0c02c6cf 100644 --- a/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts +++ b/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts @@ -45,12 +45,9 @@ class LgWorker extends BaseWorker { } } - async flush(): Promise { - return new Promise(async (resolve) => { - this.listeners.clear(); - const result = await super.flush(); - resolve(result); - }); + flush(): Promise { + this.listeners.clear(); + return super.flush(); } addProject(projectId: string) { diff --git a/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts b/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts index dcf9d856c2..3900de55c0 100644 --- a/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts +++ b/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + interface MapOptimizerTree { timestamp: number; references: Key[]; @@ -11,7 +14,7 @@ export class MapOptimizer { public tree = new Map>(); private skipOptimize = new Set(); - opUpdateCallback?: (key: Key, value: Value, ctx: OnUpdateMapOptimizerContext) => void; + onUpdateCallback?: (key: Key, value: Value, ctx: OnUpdateMapOptimizerContext) => void; onDeleteCallback?: (key: Key, value: Value) => void; constructor(private capacity: number, public list: Map) { @@ -19,7 +22,7 @@ export class MapOptimizer { } onUpdate(callback: (key: Key, value: Value, ctx: OnUpdateMapOptimizerContext) => void) { - this.opUpdateCallback = callback; + this.onUpdateCallback = callback; } onDelete(callback: (key: Key, value: Value) => void) { @@ -40,7 +43,7 @@ export class MapOptimizer { private optimize(keyToAdd: Key, valueToAdd: Value) { const exists = this.tree.has(keyToAdd); const context: MapOptimizerTree = { timestamp: Date.now(), references: [] }; - this.opUpdateCallback?.(keyToAdd, valueToAdd, { + this.onUpdateCallback?.(keyToAdd, valueToAdd, { setReferences: (references) => (context.references = references || []), }); this.tree.set(keyToAdd, context); From 44d3d00d3fe094507425abf74554fcd2ca0cebba Mon Sep 17 00:00:00 2001 From: Joel Mut Date: Tue, 25 Jul 2023 23:05:51 +0200 Subject: [PATCH 3/5] Add MapOptimizer doc and destroy listener --- Composer/packages/client/src/App.tsx | 17 ++++++--- .../src/recoilModel/parsers/lgWorker.ts | 4 ++ .../src/recoilModel/utils/mapOptimizer.ts | 38 +++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/Composer/packages/client/src/App.tsx b/Composer/packages/client/src/App.tsx index 8fc8e14b27..3aa3632134 100644 --- a/Composer/packages/client/src/App.tsx +++ b/Composer/packages/client/src/App.tsx @@ -15,6 +15,7 @@ import { setOneAuthEnabled } from './utils/oneAuthUtil'; import { LoadingSpinner } from './components/LoadingSpinner'; import lgWorker from './recoilModel/parsers/lgWorker'; import { LgEventType } from './recoilModel/parsers/types'; +import { useMount, useUnmount } from '@fluentui/react-hooks'; setupIcons(); @@ -28,6 +29,7 @@ export const App: React.FC = () => { const { appLocale } = useRecoilValue(userSettingsState); const [isClosing, setIsClosing] = useState(false); + const [listener, setListener] = useState<{ destroy(): boolean }>({} as any); const { fetchExtensions, @@ -40,17 +42,20 @@ export const App: React.FC = () => { callbackHelpers.set(lgFileState({ projectId, lgFileId: value.id }), value); }); - useEffect(() => { - loadLocale(appLocale); - }, [appLocale]); - - useEffect(() => { - lgWorker.listen(LgEventType.OnUpdateLgFile, (msg) => { + useMount(() => { + const listener = lgWorker.listen(LgEventType.OnUpdateLgFile, (msg) => { const { projectId, payload } = msg.data; updateFile({ projectId, value: payload }); }); + setListener(listener); }); + useUnmount(() => listener.destroy()); + + useEffect(() => { + loadLocale(appLocale); + }, [appLocale]); + useEffect(() => { checkNodeVersion(); fetchExtensions(); diff --git a/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts b/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts index 4a0c02c6cf..564ec0f71f 100644 --- a/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts +++ b/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts @@ -43,6 +43,10 @@ class LgWorker extends BaseWorker { } else { this.listeners.set(action, [callback]); } + + return { + destroy: () => this.listeners.delete(action), + }; } flush(): Promise { diff --git a/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts b/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts index 3900de55c0..cf265cc0d1 100644 --- a/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts +++ b/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts @@ -1,15 +1,28 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +/** + * Internal tree structure to track the oldest elements and their references. + */ interface MapOptimizerTree { timestamp: number; references: Key[]; } +/** + * Context for the MapOptimizer.onUpdate event. + */ interface OnUpdateMapOptimizerContext { + /** + * Sets the related Map keys references of an element, these references are take into account on the delete event. + * @param references The Map keys of a related element. + */ setReferences(references: Key[]): void; } +/** + * Class to optimize a Map object by deleting the oldest elements of the collection based on a capacity limit. + */ export class MapOptimizer { public tree = new Map>(); private skipOptimize = new Set(); @@ -17,18 +30,35 @@ export class MapOptimizer { onUpdateCallback?: (key: Key, value: Value, ctx: OnUpdateMapOptimizerContext) => void; onDeleteCallback?: (key: Key, value: Value) => void; + /** + * Initializes a new instance of the MapOptimizer class. + * @param capacity The capacity limit to trigger the optimization steps. + * @param list The Map object to optimize. + */ constructor(private capacity: number, public list: Map) { this.attach(); } + /** + * Event triggered when an element is added or updated in the Map object. + * @param callback Exposes the element's Key, Value and Context to perform operations. + */ onUpdate(callback: (key: Key, value: Value, ctx: OnUpdateMapOptimizerContext) => void) { this.onUpdateCallback = callback; } + /** + * Event triggered when an element is marked for deletion. + * @param callback Exposes the element's Key, Value. + */ onDelete(callback: (key: Key, value: Value) => void) { this.onDeleteCallback = callback; } + /** + * @private + * Attaches the "set" method to the Map object to listen and trigger the optimization. + */ private attach() { const set = this.list.set; this.list.set = (key, value) => { @@ -40,6 +70,10 @@ export class MapOptimizer { }; } + /** + * @private + * Optimizes the Map object by performing the onDelete event callback on the oldest element in the collection. + */ private optimize(keyToAdd: Key, valueToAdd: Value) { const exists = this.tree.has(keyToAdd); const context: MapOptimizerTree = { timestamp: Date.now(), references: [] }; @@ -83,6 +117,10 @@ export class MapOptimizer { } } + /** + * @private + * Identifies all the keys that are available to delete. + */ private identify(references: Key[], memo: Key[] = []) { for (const reference of references) { const found = this.tree.get(reference); From f495324993a083b41d6a5e6ace9d5c3244528603 Mon Sep 17 00:00:00 2001 From: Joel Mut Date: Wed, 26 Jul 2023 14:44:08 +0200 Subject: [PATCH 4/5] Fix typo --- Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts b/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts index cf265cc0d1..a17d3b397d 100644 --- a/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts +++ b/Composer/packages/client/src/recoilModel/utils/mapOptimizer.ts @@ -14,7 +14,7 @@ interface MapOptimizerTree { */ interface OnUpdateMapOptimizerContext { /** - * Sets the related Map keys references of an element, these references are take into account on the delete event. + * Sets the related Map keys references of an element, these references are taken into account on the delete event. * @param references The Map keys of a related element. */ setReferences(references: Key[]): void; From 5c73b9ddeb72cacce852c0886a39610d95bc4175 Mon Sep 17 00:00:00 2001 From: Joel Mut Date: Thu, 27 Jul 2023 08:51:17 +0200 Subject: [PATCH 5/5] Fix imports --- Composer/packages/client/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Composer/packages/client/src/App.tsx b/Composer/packages/client/src/App.tsx index 3aa3632134..618acc44a9 100644 --- a/Composer/packages/client/src/App.tsx +++ b/Composer/packages/client/src/App.tsx @@ -3,6 +3,7 @@ import React, { Fragment, useEffect, useState } from 'react'; import { useRecoilValue, useRecoilCallback, CallbackInterface } from 'recoil'; +import { useMount, useUnmount } from '@fluentui/react-hooks'; import { Header } from './components/Header'; import { Announcement } from './components/AppComponents/Announcement'; @@ -15,7 +16,6 @@ import { setOneAuthEnabled } from './utils/oneAuthUtil'; import { LoadingSpinner } from './components/LoadingSpinner'; import lgWorker from './recoilModel/parsers/lgWorker'; import { LgEventType } from './recoilModel/parsers/types'; -import { useMount, useUnmount } from '@fluentui/react-hooks'; setupIcons();