UNPKG

@finos/legend-application-studio

Version:
356 lines 22.8 kB
/** * Copyright (c) 2020-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { isElementReadOnly, Package, GRAPH_MANAGER_EVENT, EngineError, GraphBuilderError, CompilationError, extractSourceInformationCoordinates, reportGraphAnalytics, } from '@finos/legend-graph'; import { isNonNullable, assertErrorThrown, LogEvent, ActionState, assertType, guaranteeNonNullable, StopWatch, } from '@finos/legend-shared'; import { flowResult } from 'mobx'; import { FormLocalChangesState } from './sidebar-state/LocalChangesState.js'; import { GlobalTestRunnerState } from './sidebar-state/testable/GlobalTestRunnerState.js'; import { LEGEND_STUDIO_APP_EVENT } from '../../__lib__/LegendStudioEvent.js'; import { GraphCompilationOutcome } from './EditorGraphState.js'; import { GRAPH_EDITOR_MODE, PANEL_MODE } from './EditorConfig.js'; import { graph_addElement, graph_deleteElement, graph_deleteOwnElement, graph_dispose, graph_renameElement, } from '../graph-modifier/GraphModifierHelper.js'; import { ElementEditorState } from './editor-state/element-editor-state/ElementEditorState.js'; import { LegendStudioTelemetryHelper } from '../../__lib__/LegendStudioTelemetryHelper.js'; import { GraphEditorMode } from './GraphEditorMode.js'; import { GlobalBulkServiceRegistrationState } from './sidebar-state/BulkServiceRegistrationState.js'; export class GraphEditFormModeState extends GraphEditorMode { *initialize() { this.editorStore.localChangesState = new FormLocalChangesState(this.editorStore, this.editorStore.sdlcState); this.editorStore.graphState.clearProblems(); if (this.editorStore.graphState.mostRecentCompilationOutcome === GraphCompilationOutcome.SUCCEEDED) { yield flowResult(this.editorStore.graphEditorMode.updateGraphAndApplication(this.editorStore.graphState.compilationResultEntities)); this.editorStore.graphState.setMostRecentCompilationGraphHash(this.editorStore.graphEditorMode.getCurrentGraphHash()); this.editorStore.graphState.compilationResultEntities = []; if (this.editorStore.tabManagerState.currentTab) { this.editorStore.tabManagerState.openTab(this.editorStore.tabManagerState.currentTab); } } } *addElement(element, packagePath, openAfterCreate) { graph_addElement(this.editorStore.graphManagerState.graph, element, packagePath, this.editorStore.changeDetectionState.observerContext); this.editorStore.explorerTreeState.reprocess(); if (openAfterCreate) { this.openElement(element); } } *deleteElement(element) { if (this.editorStore.graphState.checkIfApplicationUpdateOperationIsRunning() || isElementReadOnly(element)) { return; } const generatedChildrenElements = (this.editorStore.graphState.graphGenerationState.generatedEntities.get(element.path) ?? []) .map((genChildEntity) => this.editorStore.graphManagerState.graph.generationModel.allOwnElements.find((genElement) => genElement.path === genChildEntity.path)) .filter(isNonNullable); const elementsToDelete = [element, ...generatedChildrenElements]; this.editorStore.tabManagerState.tabs = this.editorStore.tabManagerState.tabs.filter((elementState) => { if (elementState instanceof ElementEditorState) { if (elementState === this.editorStore.tabManagerState.currentTab) { // avoid closing the current editor state as this will be taken care of // by the `closeState()` call later return true; } return !elementsToDelete.includes(elementState.element); } return true; }); if (this.editorStore.tabManagerState.currentTab && this.editorStore.tabManagerState.currentTab instanceof ElementEditorState && elementsToDelete.includes(this.editorStore.tabManagerState.currentTab.element)) { this.editorStore.tabManagerState.closeTab(this.editorStore.tabManagerState.currentTab); } // remove/retire the element's generated children before remove the element itself generatedChildrenElements.forEach((el) => graph_deleteOwnElement(this.editorStore.graphManagerState.graph.generationModel, el)); graph_deleteElement(this.editorStore.graphManagerState.graph, element); const extraElementEditorPostDeleteActions = this.editorStore.pluginManager .getApplicationPlugins() .flatMap((plugin) => plugin.getExtraElementEditorPostDeleteActions?.() ?? []); for (const postDeleteAction of extraElementEditorPostDeleteActions) { postDeleteAction(this.editorStore, element); } // reprocess project explorer tree this.editorStore.explorerTreeState.reprocess(); // recompile yield flowResult(this.globalCompile({ message: `Can't compile graph after deletion and error cannot be located in form mode. Redirected to text mode for debugging`, })); } *renameElement(element, newPath) { if (isElementReadOnly(element)) { return; } graph_renameElement(this.editorStore.graphManagerState.graph, element, newPath, this.editorStore.changeDetectionState.observerContext); const extraElementEditorPostRenameActions = this.editorStore.pluginManager .getApplicationPlugins() .flatMap((plugin) => plugin.getExtraElementEditorPostRenameActions?.() ?? []); for (const postRenameAction of extraElementEditorPostRenameActions) { postRenameAction(this.editorStore, element); } // reprocess project explorer tree this.editorStore.explorerTreeState.reprocess(); if (element instanceof Package) { this.editorStore.explorerTreeState.openNode(element); } else if (element.package) { this.editorStore.explorerTreeState.openNode(element.package); } // recompile yield flowResult(this.globalCompile({ message: `Can't compile graph after renaming and error cannot be located in form mode. Redirected to text mode for debugging`, })); } getCurrentGraphHash() { return this.editorStore.changeDetectionState.currentGraphHash; } get mode() { return GRAPH_EDITOR_MODE.FORM; } /** * NOTE: IMPORTANT! This method is both a savior and a sinner. It helps reprocessing the graph state to use a new graph * built from the new model context data, it resets the graph properly. The bane here is that resetting the graph properly is * not trivial, for example, in the cleanup phase, there are things we want to re-use, such as the one-time processed system * metamodels or the `reusable` metamodels from project dependencies. There are also explorer states like the package tree, * opened tabs, change detection, etc. to take care of. There are a lot of potential pitfalls. For these, we will add the * marker: * * @risk memory-leak * * to indicate we should check carefully these pieces when we detect memory issue as it might still * be referring to the old graph * * In the past, we have found that there are a few potential root causes for memory leak: * 1. State management Mobx allows references, as such, it is sometimes hard to trace down which references can cause problem * We have to understand that the behind this updater is very simple (replace), yet to do it cleanly is not easy, since * so far it is tempting to refer to elements in the graph from various editor state. On top of that, change detection * sometimes obfuscate the investigation but we have cleared it out with explicit disposing of reaction * 2. Reusable models, at this point in time, we haven't completed stabilize the logic for handling generated models, as well * as dependencies, we intended to save computation time by reusing these while updating the graph. This can pose potential * danger as well. Beware the way when we start to make system/project dependencies references elements of current graph * e.g. when we have a computed value in a immutable class that get all subclasses, etc. * 3. We reprocess editor states to ensure good UX, e.g. find tabs to keep open, find tree nodes to expand, etc. * after updating the graph. These in our experience is the **MOST COMMON** source of memory leak. It is actually * quite predictable since structures like tabs and tree node embeds graph data, which are references to the old graph * * NOTE: One big obfuscating factor is overlapping graph refresh. Sometimes, we observed that calling this update graph * method multiple times can throws Mobx off and causes reusing change detection state to cause memory-leak. As such, * we have blocked the possibility of calling compilation/graph-update/generation simultaneously * * A note on how to debug memory-leak issue: * 1. Open browser Memory monitor * 2. Go to text mode and compile multiple times (triggering graph update) * 3. Try to force garbage collection, if we see memory goes up after while, it's pretty clear that this is memory-leak * (note that since we disallow stacking multiple compilation and graph update, we have simplify the detection a lot) * See https://auth0.com/blog/four-types-of-leaks-in-your-javascript-code-and-how-to-get-rid-of-them/ */ *updateGraphAndApplication(entities) { const startTime = Date.now(); this.editorStore.graphState.isUpdatingApplication = true; this.editorStore.graphState.isUpdatingGraph = true; try { const newGraph = this.editorStore.graphManagerState.createNewGraph(); yield flowResult(this.editorStore.graphState.rebuildDependencies(newGraph)); /** * We remove the current editor state so that we no longer let React displays the element that belongs to the old graph * NOTE: this causes an UI flash, but this is in many way, acceptable since the user probably should know that we are * refreshing the memory graph anyway. * * If this is really bothering, we can handle it by building mocked replica of the current editor state using stub element * e.g. if the current editor is a class, we stub the class, create a new class editor state around it and copy over * navigation information, etc. */ if (this.editorStore.tabManagerState.tabs.length) { this.editorStore.tabManagerState.cacheAndClose(); } this.editorStore.changeDetectionState.stop(); // stop change detection before disposing hash yield flowResult(graph_dispose(this.editorStore.graphManagerState.graph)); const graphBuildState = ActionState.create(); yield this.editorStore.graphManagerState.graphManager.buildGraph(newGraph, entities, graphBuildState, { TEMPORARY__preserveSectionIndex: this.editorStore.applicationStore.config.options .TEMPORARY__preserveSectionIndex, strict: this.editorStore.graphState.enableStrictMode, }); // Activity States this.editorStore.globalTestRunnerState = new GlobalTestRunnerState(this.editorStore, this.editorStore.sdlcState); this.editorStore.globalBulkServiceRegistrationState = new GlobalBulkServiceRegistrationState(this.editorStore, this.editorStore.sdlcState); // NOTE: build model generation entities every-time we rebuild the graph - should we do this? const generationsBuildState = ActionState.create(); yield this.editorStore.graphManagerState.graphManager.buildGenerations(newGraph, this.editorStore.graphState.graphGenerationState.generatedEntities, generationsBuildState); this.editorStore.graphManagerState.graph = newGraph; // NOTE: here we don't want to modify the current graph build state directly // instead, we quietly run this in the background and then sync it with the current build state this.editorStore.graphManagerState.graphBuildState.sync(graphBuildState); this.editorStore.graphManagerState.generationsBuildState.sync(generationsBuildState); this.editorStore.explorerTreeState.reprocess(); this.editorStore.applicationStore.logService.info(LogEvent.create(GRAPH_MANAGER_EVENT.UPDATE_AND_REBUILD_GRAPH__SUCCESS), '[TOTAL]', Date.now() - startTime, 'ms'); this.editorStore.graphState.isUpdatingGraph = false; // ======= (RE)START CHANGE DETECTION ======= yield flowResult(this.editorStore.changeDetectionState.observeGraph()); yield this.editorStore.changeDetectionState.preComputeGraphElementHashes(); this.editorStore.changeDetectionState.start(); this.editorStore.applicationStore.logService.info(LogEvent.create(LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_RESTART__SUCCESS), '[ASYNC]'); // ======= FINISHED (RE)START CHANGE DETECTION ======= /** * Re-build the editor states which were opened before from the information we have stored before * creating the new graph. * NOTE: We must recover the tabs after we have called observeGraph above. Otherwise, the tab states * will be recreated using the graph elements before they get observed, and this will cause the editor * components to not update when users make changes, since mobx will not be able to detect the changes. */ this.editorStore.tabManagerState.recoverTabs(); } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.logService.error(LogEvent.create(GRAPH_MANAGER_EVENT.GRAPH_BUILDER_FAILURE), error); this.editorStore.changeDetectionState.stop(true); // force stop change detection this.editorStore.graphState.isUpdatingGraph = false; // Note: in the future this function will probably be ideal to refactor when we have different classes for each mode // as we would handle this error differently in `text` mode and `form` mode. if (error instanceof GraphBuilderError) { this.editorStore.applicationStore.alertService.setBlockingAlert({ message: `Can't build graph: ${error.message}`, prompt: 'Refreshing full application...', showLoading: true, }); this.editorStore.tabManagerState.closeAllTabs(); this.editorStore.cleanUp(); yield flowResult(this.editorStore.buildGraph(entities)); } } finally { this.editorStore.graphState.isUpdatingApplication = false; this.editorStore.applicationStore.alertService.setBlockingAlert(undefined); } } // TODO: when we support showing multiple notifications, we can take this options out as the only users of this // is delete element flow, where we want to say `re-compiling graph after deletion`, but because sometimes, compilation // is so fast, the message flashes, so we want to combine with the message in this method *globalCompile(options) { if (this.editorStore.graphState.checkIfApplicationUpdateOperationIsRunning()) { this.editorStore.graphState.setMostRecentCompilationOutcome(GraphCompilationOutcome.SKIPPED); return; } const stopWatch = new StopWatch(); const report = reportGraphAnalytics(this.editorStore.graphManagerState.graph); LegendStudioTelemetryHelper.logEvent_GraphCompilationLaunched(this.editorStore.applicationStore.telemetryService); const currentGraphHash = this.getCurrentGraphHash(); try { this.editorStore.graphState.isRunningGlobalCompile = true; this.editorStore.graphState.clearProblems(); if (options?.openConsole) { this.editorStore.setActivePanelMode(PANEL_MODE.CONSOLE); } // NOTE: here we always keep the source information while compiling in form mode // so that the form parts where the user interacted with (i.e. where the lamdbas source // information are populated), can reveal compilation error. If compilation errors // show up in other parts, the user will get redirected to text-mode const compilationResult = (yield this.editorStore.graphManagerState.graphManager.compileGraph(this.editorStore.graphManagerState.graph, { keepSourceInformation: true, }, report)); this.editorStore.graphState.warnings = compilationResult.warnings ? this.editorStore.graphState.TEMPORARY__removeDependencyProblems(compilationResult.warnings) : []; this.editorStore.graphState.setMostRecentCompilationGraphHash(currentGraphHash); if (!options?.disableNotificationOnSuccess) { if (this.editorStore.graphState.warnings.length) { this.editorStore.applicationStore.notificationService.notifyWarning(`Compilation succeeded with warnings`); } else { if (!options?.disableNotificationOnSuccess) { this.editorStore.applicationStore.notificationService.notifySuccess('Compiled successfully'); } } } report.timings = this.editorStore.applicationStore.timeService.finalizeTimingsRecord(stopWatch, report.timings); LegendStudioTelemetryHelper.logEvent_GraphCompilationSucceeded(this.editorStore.applicationStore.telemetryService, report); this.editorStore.graphState.setMostRecentCompilationOutcome(GraphCompilationOutcome.SUCCEEDED); } catch (error) { assertErrorThrown(error); // TODO: we probably should make this pattern of error the handling for all other exceptions in the codebase // i.e. there should be a catch-all handler (we can use if-else construct to check error types) assertType(error, EngineError, `Unhandled exception:\n${error}`); this.editorStore.applicationStore.logService.error(LogEvent.create(GRAPH_MANAGER_EVENT.COMPILATION_FAILURE), error); this.editorStore.graphState.setMostRecentCompilationGraphHash(currentGraphHash); let fallbackToTextModeForDebugging = true; // if compilation failed, we try to reveal the error in form mode, // if even this fail, we will fall back to show it in text mode if (error instanceof CompilationError) { const errorCoordinates = extractSourceInformationCoordinates(error.sourceInformation); if (errorCoordinates) { const element = this.editorStore.graphManagerState.graph.getNullableElement(guaranteeNonNullable(errorCoordinates[0], `Can't reveal compilation error: element path is missing`), false); if (element) { this.openElement(element); if (this.editorStore.tabManagerState.currentTab instanceof ElementEditorState) { // check if we can reveal the error in the element editor state fallbackToTextModeForDebugging = !this.editorStore.tabManagerState.currentTab.revealCompilationError(error); } } } } // decide if we need to fall back to text mode for debugging if (fallbackToTextModeForDebugging) { // TODO: when we support showing multiple notifications, we can split this into 2 this.editorStore.applicationStore.notificationService.notifyWarning(options?.message ?? 'Compilation failed and error cannot be located in form mode. Redirected to text mode for debugging.'); this.editorStore.graphState.setMostRecentCompilationOutcome(GraphCompilationOutcome.FAILED); yield flowResult(this.editorStore.switchModes(GRAPH_EDITOR_MODE.GRAMMAR_TEXT, { isCompilationFailure: true, })); } else { this.editorStore.graphState.error = error; this.editorStore.applicationStore.notificationService.notifyWarning(`Compilation failed: ${error.message}`); this.editorStore.graphState.setMostRecentCompilationOutcome(GraphCompilationOutcome.FAILED); } } finally { this.editorStore.graphState.isRunningGlobalCompile = false; } } goToProblem(problem) { return; } *onLeave() { this.editorStore.sqlPlaygroundState.setConnection(undefined); this.editorStore.tabManagerState.cacheAndClose(); } *cleanupBeforeEntering(fallbackOptions) { return; } *handleCleanupFailure(error) { return; } openElement(element) { if (!(element instanceof Package)) { const existingElementState = this.editorStore.tabManagerState.tabs.find((state) => state instanceof ElementEditorState && state.element === element); const newTab = existingElementState ?? this.editorStore.tabManagerState.createElementEditorState(element); if (newTab) { this.editorStore.tabManagerState.openTab(newTab); } else { this.editorStore.applicationStore.notificationService.notifyWarning(`Can't open editor for element '${element.path}'`); } } } } //# sourceMappingURL=GraphEditFormModeState.js.map