@finos/legend-studio
Version:
1,224 lines (1,177 loc) • 50.7 kB
text/typescript
/**
* 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 { action, computed, flowResult, makeAutoObservable } from 'mobx';
import { CHANGE_DETECTION_EVENT } from './ChangeDetectionEvent.js';
import { GRAPH_EDITOR_MODE, AUX_PANEL_MODE } from './EditorConfig.js';
import {
type GeneratorFn,
type PlainObject,
LogEvent,
assertType,
UnsupportedOperationError,
assertErrorThrown,
assertTrue,
isNonNullable,
NetworkClientError,
guaranteeNonNullable,
StopWatch,
filterByType,
ActionState,
} from '@finos/legend-shared';
import type { EditorStore } from './EditorStore.js';
import { ElementEditorState } from './editor-state/element-editor-state/ElementEditorState.js';
import { GraphGenerationState } from './editor-state/GraphGenerationState.js';
import { MODEL_UPDATER_INPUT_TYPE } from './editor-state/ModelLoaderState.js';
import type { DSL_LegendStudioApplicationPlugin_Extension } from './LegendStudioApplicationPlugin.js';
import type { Entity } from '@finos/legend-model-storage';
import {
type EntityChange,
type ProjectDependency,
EntityChangeType,
ProjectConfiguration,
applyEntityChanges,
} from '@finos/legend-server-sdlc';
import {
ProjectVersionEntities,
ProjectData,
ProjectDependencyCoordinates,
generateGAVCoordinates,
} from '@finos/legend-server-depot';
import {
type SetImplementation,
type PackageableElement,
GRAPH_MANAGER_EVENT,
CompilationError,
EngineError,
extractSourceInformationCoordinates,
Package,
PureInstanceSetImplementation,
Profile,
OperationSetImplementation,
PrimitiveType,
Enumeration,
Class,
Association,
Mapping,
ConcreteFunctionDefinition,
Service,
FlatData,
FlatDataInstanceSetImplementation,
EmbeddedFlatDataPropertyMapping,
PackageableConnection,
PackageableRuntime,
FileGenerationSpecification,
GenerationSpecification,
Measure,
Unit,
Database,
SectionIndex,
RootRelationalInstanceSetImplementation,
EmbeddedRelationalInstanceSetImplementation,
AggregationAwareSetImplementation,
DependencyGraphBuilderError,
GraphDataDeserializationError,
GraphBuilderError,
type GraphBuilderReport,
GraphManagerTelemetry,
DataElement,
} from '@finos/legend-graph';
import {
type LambdaEditorState,
ActionAlertActionType,
ActionAlertType,
} from '@finos/legend-application';
import { CONFIGURATION_EDITOR_TAB } from './editor-state/ProjectConfigurationEditorState.js';
import type { DSLMapping_LegendStudioApplicationPlugin_Extension } from './DSLMapping_LegendStudioApplicationPlugin_Extension.js';
import { graph_dispose } from './graphModifier/GraphModifierHelper.js';
import {
PACKAGEABLE_ELEMENT_TYPE,
SET_IMPLEMENTATION_TYPE,
} from './shared/ModelUtil.js';
import { GlobalTestRunnerState } from './sidebar-state/testable/GlobalTestRunnerState.js';
export enum GraphBuilderStatus {
SUCCEEDED = 'SUCCEEDED',
FAILED = 'FAILED',
REDIRECTED_TO_TEXT_MODE = 'REDIRECTED_TO_TEXT_MODE',
}
export interface GraphBuilderResult {
status: GraphBuilderStatus;
error?: Error;
}
export class EditorGraphState {
editorStore: EditorStore;
graphGenerationState: GraphGenerationState;
isInitializingGraph = false;
isRunningGlobalCompile = false;
isRunningGlobalGenerate = false;
isApplicationLeavingTextMode = false;
isUpdatingGraph = false; // critical synchronous update to refresh the graph
isUpdatingApplication = false; // including graph update and async operations such as change detection
constructor(editorStore: EditorStore) {
makeAutoObservable(this, {
editorStore: false,
graphGenerationState: false,
getPackageableElementType: false,
getSetImplementationType: false,
hasCompilationError: computed,
clearCompilationError: action,
});
this.editorStore = editorStore;
this.graphGenerationState = new GraphGenerationState(this.editorStore);
}
get hasCompilationError(): boolean {
return (
Boolean(this.editorStore.grammarTextEditorState.error) ||
this.editorStore.openedEditorStates
.filter(filterByType(ElementEditorState))
.some((editorState) => editorState.hasCompilationError)
);
}
clearCompilationError(): void {
this.editorStore.grammarTextEditorState.setError(undefined);
this.editorStore.openedEditorStates
.filter(filterByType(ElementEditorState))
.forEach((editorState) => editorState.clearCompilationError());
}
get isApplicationUpdateOperationIsRunning(): boolean {
return (
this.isRunningGlobalCompile ||
this.isRunningGlobalGenerate ||
this.isApplicationLeavingTextMode ||
this.isUpdatingApplication ||
this.isInitializingGraph
);
}
checkIfApplicationUpdateOperationIsRunning(): boolean {
if (this.isRunningGlobalGenerate) {
this.editorStore.applicationStore.notifyWarning(
'Please wait for model generation to complete',
);
return true;
}
if (this.isRunningGlobalCompile) {
this.editorStore.applicationStore.notifyWarning(
'Please wait for graph compilation to complete',
);
return true;
}
if (this.isApplicationLeavingTextMode) {
this.editorStore.applicationStore.notifyWarning(
'Please wait for editor to leave text mode completely',
);
return true;
}
if (this.isUpdatingApplication) {
this.editorStore.applicationStore.notifyWarning(
'Please wait for editor state to rebuild',
);
return true;
}
if (this.isInitializingGraph) {
this.editorStore.applicationStore.notifyWarning(
'Please wait for editor initialization to complete',
);
return true;
}
return false;
}
*buildGraph(entities: Entity[]): GeneratorFn<GraphBuilderResult> {
try {
this.isInitializingGraph = true;
const stopWatch = new StopWatch();
// reset
this.editorStore.graphManagerState.resetGraph();
// fetch and build dependencies
stopWatch.record();
const dependencyManager =
this.editorStore.graphManagerState.createEmptyDependencyManager();
this.editorStore.graphManagerState.graph.dependencyManager =
dependencyManager;
this.editorStore.graphManagerState.dependenciesBuildState.setMessage(
`Fetching dependencies...`,
);
const dependencyEntitiesIndex = (yield flowResult(
this.getIndexedDependencyEntities(),
)) as Map<string, Entity[]>;
stopWatch.record(GRAPH_MANAGER_EVENT.GRAPH_DEPENDENCIES_FETCHED);
const dependency_buildReport =
(yield this.editorStore.graphManagerState.graphManager.buildDependencies(
this.editorStore.graphManagerState.coreModel,
this.editorStore.graphManagerState.systemModel,
dependencyManager,
dependencyEntitiesIndex,
this.editorStore.graphManagerState.dependenciesBuildState,
)) as GraphBuilderReport;
dependency_buildReport.timings[
GRAPH_MANAGER_EVENT.GRAPH_DEPENDENCIES_FETCHED
] = stopWatch.getRecord(GRAPH_MANAGER_EVENT.GRAPH_DEPENDENCIES_FETCHED);
// build graph
const graph_buildReport =
(yield this.editorStore.graphManagerState.graphManager.buildGraph(
this.editorStore.graphManagerState.graph,
entities,
this.editorStore.graphManagerState.graphBuildState,
{
TEMPORARY__preserveSectionIndex:
this.editorStore.applicationStore.config.options
.TEMPORARY__preserveSectionIndex,
},
)) as GraphBuilderReport;
// build generations
const generation_buildReport =
(yield this.editorStore.graphManagerState.graphManager.buildGenerations(
this.editorStore.graphManagerState.graph,
this.graphGenerationState.generatedEntities,
this.editorStore.graphManagerState.generationsBuildState,
)) as GraphBuilderReport;
// report
stopWatch.record(GRAPH_MANAGER_EVENT.GRAPH_INITIALIZED);
const graphBuilderReportData = {
timings: {
[GRAPH_MANAGER_EVENT.GRAPH_INITIALIZED]: stopWatch.getRecord(
GRAPH_MANAGER_EVENT.GRAPH_INITIALIZED,
),
},
dependencies: dependency_buildReport,
graph: graph_buildReport,
generations: generation_buildReport,
};
this.editorStore.applicationStore.log.info(
LogEvent.create(GRAPH_MANAGER_EVENT.GRAPH_INITIALIZED),
graphBuilderReportData,
);
GraphManagerTelemetry.logEvent_GraphInitialized(
this.editorStore.applicationStore.telemetryService,
graphBuilderReportData,
);
// add generation specification if model generation elements exists in graph and no generation specification
yield flowResult(
this.graphGenerationState.possiblyAddMissingGenerationSpecifications(),
);
return {
status: GraphBuilderStatus.SUCCEEDED,
};
} catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.log.error(
LogEvent.create(GRAPH_MANAGER_EVENT.GRAPH_BUILDER_FAILURE),
error,
);
if (error instanceof DependencyGraphBuilderError) {
this.editorStore.graphManagerState.graphBuildState.fail();
// no recovery if dependency models cannot be built, this makes assumption that all dependencies models are compiled successfully
// TODO: we might want to handle this more gracefully when we can show people the dependency model element in the future
this.editorStore.applicationStore.notifyError(
`Can't initialize dependency models. Error: ${error.message}`,
);
const projectConfigurationEditorState =
this.editorStore.projectConfigurationEditorState;
projectConfigurationEditorState.setSelectedTab(
CONFIGURATION_EDITOR_TAB.PROJECT_DEPENDENCIES,
);
this.editorStore.setCurrentEditorState(projectConfigurationEditorState);
} else if (error instanceof GraphDataDeserializationError) {
// if something goes wrong with de-serialization, redirect to model loader to fix
this.redirectToModelLoaderForDebugging(error);
} else if (error instanceof NetworkClientError) {
this.editorStore.graphManagerState.graphBuildState.fail();
this.editorStore.applicationStore.notifyWarning(
`Can't build graph. Error: ${error.message}`,
);
} else {
// TODO: we should split this into 2 notifications when we support multiple notifications
this.editorStore.applicationStore.notifyError(
`Can't build graph. Redirected to text mode for debugging. Error: ${error.message}`,
);
try {
const editorGrammar =
(yield this.editorStore.graphManagerState.graphManager.entitiesToPureCode(
entities,
)) as string;
yield flowResult(
this.editorStore.grammarTextEditorState.setGraphGrammarText(
editorGrammar,
),
);
} catch (error2) {
assertErrorThrown(error2);
this.editorStore.applicationStore.log.error(
LogEvent.create(GRAPH_MANAGER_EVENT.GRAPH_BUILDER_FAILURE),
error2,
);
if (error2 instanceof NetworkClientError) {
// in case the server cannot even transform the JSON due to corrupted protocol, we can redirect to model loader
this.redirectToModelLoaderForDebugging(error2);
return {
status: GraphBuilderStatus.FAILED,
error: error2,
};
}
}
this.editorStore.setGraphEditMode(GRAPH_EDITOR_MODE.GRAMMAR_TEXT);
yield flowResult(
this.globalCompileInTextMode({
ignoreBlocking: true,
suppressCompilationFailureMessage: true,
}),
);
return {
status: GraphBuilderStatus.REDIRECTED_TO_TEXT_MODE,
error,
};
}
return {
status: GraphBuilderStatus.FAILED,
error,
};
} finally {
this.isInitializingGraph = false;
}
}
private redirectToModelLoaderForDebugging(error: Error): void {
if (this.editorStore.isInConflictResolutionMode) {
this.editorStore.setBlockingAlert({
message: `Can't de-serialize graph model from entities`,
prompt: `Please refresh the application and abort conflict resolution`,
});
return;
}
this.editorStore.applicationStore.notifyWarning(
`Can't de-serialize graph model from entities. Redirected to model loader for debugging. Error: ${error.message}`,
);
this.editorStore.modelLoaderState.setCurrentModelLoadType(
MODEL_UPDATER_INPUT_TYPE.ENTITIES,
);
// Making an async call
this.editorStore.modelLoaderState.loadCurrentProjectEntities();
this.editorStore.openState(this.editorStore.modelLoaderState);
}
/**
* Get entitiy changes to prepare for syncing
*/
computeLocalEntityChanges(): EntityChange[] {
const baseHashesIndex = this.editorStore.isInConflictResolutionMode
? this.editorStore.changeDetectionState
.conflictResolutionHeadRevisionState.entityHashesIndex
: this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState
.entityHashesIndex;
const originalPaths = new Set(Array.from(baseHashesIndex.keys()));
const entityChanges: EntityChange[] = [];
this.editorStore.graphManagerState.graph.allOwnElements.forEach(
(element) => {
const elementPath = element.path;
if (baseHashesIndex.get(elementPath) !== element.hashCode) {
const entity =
this.editorStore.graphManagerState.graphManager.elementToEntity(
element,
{
pruneSourceInformation: true,
},
);
entityChanges.push({
classifierPath: entity.classifierPath,
entityPath: element.path,
content: entity.content,
type:
baseHashesIndex.get(elementPath) !== undefined
? EntityChangeType.MODIFY
: EntityChangeType.CREATE,
});
}
originalPaths.delete(elementPath);
},
);
Array.from(originalPaths).forEach((path) => {
entityChanges.push({
type: EntityChangeType.DELETE,
entityPath: path,
});
});
return entityChanges;
}
/**
* Loads entity changes to graph and updates application.
*/
*loadEntityChangesToGraph(
changes: EntityChange[],
baseEntities: Entity[] | undefined,
): GeneratorFn<void> {
try {
assertTrue(
this.editorStore.isInFormMode,
`Can't apply entity changes: operation only supported in form mode`,
);
const entities =
baseEntities ??
this.editorStore.graphManagerState.graph.allOwnElements.map((element) =>
this.editorStore.graphManagerState.graphManager.elementToEntity(
element,
),
);
const modifiedEntities = applyEntityChanges(entities, changes);
yield flowResult(this.updateGraphAndApplication(modifiedEntities));
} catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.notifyError(
`Can't load entity changes: ${error.message}`,
);
}
}
// 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 compilation
// sometimes is so fast, the message flashes, so we want to combine with the message in this method
*globalCompileInFormMode(options?: {
message?: string;
disableNotificationOnSuccess?: boolean;
openConsole?: boolean;
}): GeneratorFn<void> {
assertTrue(
this.editorStore.isInFormMode,
'Editor must be in form mode to call this method',
);
if (this.checkIfApplicationUpdateOperationIsRunning()) {
return;
}
this.isRunningGlobalCompile = true;
try {
this.clearCompilationError();
if (options?.openConsole) {
this.editorStore.setActiveAuxPanelMode(AUX_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
yield this.editorStore.graphManagerState.graphManager.compileGraph(
this.editorStore.graphManagerState.graph,
{
keepSourceInformation: true,
},
);
if (!options?.disableNotificationOnSuccess) {
this.editorStore.applicationStore.notifySuccess(
'Compiled successfully',
);
}
} 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.log.error(
LogEvent.create(GRAPH_MANAGER_EVENT.COMPILATION_FAILURE),
error,
);
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.editorStore.openElement(element);
if (
this.editorStore.currentEditorState instanceof ElementEditorState
) {
// check if we can reveal the error in the element editor state
fallbackToTextModeForDebugging =
!this.editorStore.currentEditorState.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.notifyWarning(
options?.message ??
'Compilation failed and error cannot be located in form mode. Redirected to text mode for debugging.',
);
try {
const code =
(yield this.editorStore.graphManagerState.graphManager.graphToPureCode(
this.editorStore.graphManagerState.graph,
)) as string;
this.editorStore.grammarTextEditorState.setGraphGrammarText(code);
} catch (error2) {
assertErrorThrown(error2);
this.editorStore.applicationStore.notifyWarning(
`Can't enter text mode. Transformation to grammar text failed: ${error2.message}`,
);
return;
}
this.editorStore.setGraphEditMode(GRAPH_EDITOR_MODE.GRAMMAR_TEXT);
yield flowResult(
this.globalCompileInTextMode({
ignoreBlocking: true,
suppressCompilationFailureMessage: true,
}),
);
} else {
this.editorStore.applicationStore.notifyWarning(
`Compilation failed: ${error.message}`,
);
}
} finally {
this.isRunningGlobalCompile = false;
}
}
// TODO: when we support showing multiple notifications, we can take this `suppressCompilationFailureMessage` out as
// we can show the transition between form mode and text mode warning and the compilation failure warning at the same time
*globalCompileInTextMode(options?: {
ignoreBlocking?: boolean;
suppressCompilationFailureMessage?: boolean;
openConsole?: boolean;
}): GeneratorFn<void> {
assertTrue(
this.editorStore.isInGrammarTextMode,
'Editor must be in text mode to call this method',
);
if (
!options?.ignoreBlocking &&
this.checkIfApplicationUpdateOperationIsRunning()
) {
return;
}
try {
this.isRunningGlobalCompile = true;
this.clearCompilationError();
if (options?.openConsole) {
this.editorStore.setActiveAuxPanelMode(AUX_PANEL_MODE.CONSOLE);
}
const entities =
(yield this.editorStore.graphManagerState.graphManager.compileText(
this.editorStore.grammarTextEditorState.graphGrammarText,
this.editorStore.graphManagerState.graph,
)) as Entity[];
this.editorStore.applicationStore.notifySuccess('Compiled successfully');
yield flowResult(this.updateGraphAndApplication(entities));
} catch (error) {
assertErrorThrown(error);
if (error instanceof EngineError) {
this.editorStore.grammarTextEditorState.setError(error);
}
this.editorStore.applicationStore.log.error(
LogEvent.create(GRAPH_MANAGER_EVENT.COMPILATION_FAILURE),
'Compilation failed:',
error,
);
if (
!this.editorStore.applicationStore.notification ||
!options?.suppressCompilationFailureMessage
) {
this.editorStore.applicationStore.notifyWarning(
`Compilation failed: ${error.message}`,
);
}
} finally {
this.isRunningGlobalCompile = false;
}
}
*leaveTextMode(): GeneratorFn<void> {
assertTrue(
this.editorStore.isInGrammarTextMode,
'Editor must be in text mode to call this method',
);
if (this.checkIfApplicationUpdateOperationIsRunning()) {
return;
}
try {
this.isApplicationLeavingTextMode = true;
this.clearCompilationError();
this.editorStore.setBlockingAlert({
message: 'Compiling graph before leaving text mode...',
showLoading: true,
});
try {
const entities =
(yield this.editorStore.graphManagerState.graphManager.compileText(
this.editorStore.grammarTextEditorState.graphGrammarText,
this.editorStore.graphManagerState.graph,
// surpress the modal to reveal error properly in the text editor
// if the blocking modal is not dismissed, the edior will not be able to gain focus as modal has a focus trap
// therefore, the editor will not be able to get the focus
{ onError: () => this.editorStore.setBlockingAlert(undefined) },
)) as Entity[];
this.editorStore.setBlockingAlert({
message: 'Leaving text mode and rebuilding graph...',
showLoading: true,
});
yield flowResult(this.updateGraphAndApplication(entities));
this.editorStore.grammarTextEditorState.setGraphGrammarText('');
this.editorStore.grammarTextEditorState.resetCurrentElementLabelRegexString();
this.editorStore.setGraphEditMode(GRAPH_EDITOR_MODE.FORM);
if (this.editorStore.currentEditorState) {
this.editorStore.openState(this.editorStore.currentEditorState);
}
} catch (error) {
assertErrorThrown(error);
if (error instanceof EngineError) {
this.editorStore.grammarTextEditorState.setError(error);
}
this.editorStore.applicationStore.log.error(
LogEvent.create(GRAPH_MANAGER_EVENT.COMPILATION_FAILURE),
'Compilation failed:',
error,
);
if (this.editorStore.graphManagerState.graphBuildState.hasFailed) {
// TODO: when we support showing multiple notification, we can split this into 2 messages
this.editorStore.applicationStore.notifyWarning(
`Can't build graph, please resolve compilation error before leaving text mode. Compilation failed with error: ${error.message}`,
);
} else {
this.editorStore.applicationStore.notifyWarning(
`Compilation failed: ${error.message}`,
);
this.editorStore.setActionAlertInfo({
message: 'Project is not in a compiled state',
prompt:
'All changes made since the last time the graph was built successfully will be lost',
type: ActionAlertType.CAUTION,
onEnter: (): void => this.editorStore.setBlockGlobalHotkeys(true),
onClose: (): void => this.editorStore.setBlockGlobalHotkeys(false),
actions: [
{
label: 'Discard Changes',
handler: (): void =>
this.editorStore.setGraphEditMode(GRAPH_EDITOR_MODE.FORM),
type: ActionAlertActionType.PROCEED_WITH_CAUTION,
},
{
label: 'Stay',
default: true,
type: ActionAlertActionType.PROCEED,
},
],
});
}
}
} catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.log.error(
LogEvent.create(GRAPH_MANAGER_EVENT.COMPILATION_FAILURE),
error,
);
} finally {
this.isApplicationLeavingTextMode = false;
this.editorStore.setBlockingAlert(undefined);
}
}
/**
* This function is used in lambda editor in form mode when user try to do an action that involves the lambda being edited, it takes an action
* and proceeds with a parsing check for the current lambda before executing the action. This prevents case where user quickly type something
* that does not parse and hit compile or generate right away.
*/
*checkLambdaParsingError(
lambdaHolderElement: LambdaEditorState,
checkParsingError: boolean,
onSuccess: () => Promise<void>,
): GeneratorFn<void> {
this.clearCompilationError();
lambdaHolderElement.clearErrors();
if (checkParsingError) {
yield flowResult(
lambdaHolderElement.convertLambdaGrammarStringToObject(),
);
// abort action if parser error occurred
if (lambdaHolderElement.parserError) {
return;
}
}
yield onSuccess();
}
/**
* 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 depdendencies, 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/
*/
private *updateGraphAndApplication(entities: Entity[]): GeneratorFn<void> {
const startTime = Date.now();
this.isUpdatingApplication = true;
this.isUpdatingGraph = true;
try {
const newGraph = this.editorStore.graphManagerState.createEmptyGraph();
/**
* NOTE: this can post memory-leak issue if we start having immutable elements referencing current graph elements:
* e.g. subclass analytics on the immutable class, etc.
*
* @risk memory-leak
*/
if (
this.editorStore.graphManagerState.dependenciesBuildState.hasSucceeded
) {
newGraph.dependencyManager =
this.editorStore.graphManagerState.graph.dependencyManager;
} else {
this.editorStore.projectConfigurationEditorState.setProjectConfiguration(
ProjectConfiguration.serialization.fromJson(
(yield this.editorStore.sdlcServerClient.getConfiguration(
this.editorStore.sdlcState.activeProject.projectId,
this.editorStore.sdlcState.activeWorkspace,
)) as PlainObject<ProjectConfiguration>,
),
);
const dependencyManager =
this.editorStore.graphManagerState.createEmptyDependencyManager();
newGraph.dependencyManager = dependencyManager;
const dependenciesBuildState = ActionState.create();
yield this.editorStore.graphManagerState.graphManager.buildDependencies(
this.editorStore.graphManagerState.coreModel,
this.editorStore.graphManagerState.systemModel,
dependencyManager,
(yield flowResult(this.getIndexedDependencyEntities())) as Map<
string,
Entity[]
>,
dependenciesBuildState,
);
this.editorStore.graphManagerState.dependenciesBuildState =
dependenciesBuildState;
}
/**
* Backup and editor states info before resetting
*
* @risk memory-leak
*/
const openedEditorStates = this.editorStore.openedEditorStates;
const currentEditorState = this.editorStore.currentEditorState;
/**
* 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.
*/
this.editorStore.closeAllEditorTabs();
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,
},
);
// Activity States
this.editorStore.globalTestRunnerState = new GlobalTestRunnerState(
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.graphGenerationState.generatedEntities,
generationsBuildState,
);
this.editorStore.graphManagerState.graph = newGraph;
this.editorStore.graphManagerState.graphBuildState = graphBuildState;
this.editorStore.graphManagerState.generationsBuildState =
generationsBuildState;
/**
* Reprocess explorer tree which might still hold references to old graph
*
* FIXME: we allow this so the UX stays the same but this can cause memory leak
* we could consider doing this properly using node IDs
*
* @risk memory-leak
*/
this.editorStore.explorerTreeState.reprocess();
// this.editorStore.explorerTreeState = new ExplorerTreeState(this.applicationStore, this.editorStore);
// this.editorStore.explorerTreeState.buildImmutableModelTrees();
// this.editorStore.explorerTreeState.build();
/**
* Reprocess editor states which might still hold references to old graph
*
* FIXME: we allow this so the UX stays the same but this can cause memory leak
* we should change `reprocess` model to do something like having source information
* on the form to navigate to it properly so that information is not dependent on the
* graph, but on the component itself, with IDs and such.
*
* @risk memory-leak
*/
this.editorStore.openedEditorStates = openedEditorStates
.map((editorState) =>
this.editorStore.reprocessElementEditorState(editorState),
)
.filter(isNonNullable);
this.editorStore.setCurrentEditorState(
this.editorStore.findCurrentEditorState(currentEditorState),
);
this.editorStore.applicationStore.log.info(
LogEvent.create(GRAPH_MANAGER_EVENT.GRAPH_UPDATED_AND_REBUILT),
'[TOTAL]',
Date.now() - startTime,
'ms',
);
this.isUpdatingGraph = false;
// ======= (RE)START CHANGE DETECTION =======
yield flowResult(this.editorStore.changeDetectionState.observeGraph());
yield this.editorStore.changeDetectionState.preComputeGraphElementHashes();
this.editorStore.changeDetectionState.start();
this.editorStore.applicationStore.log.info(
LogEvent.create(CHANGE_DETECTION_EVENT.CHANGE_DETECTION_RESTARTED),
'[ASYNC]',
);
// ======= FINISHED (RE)START CHANGE DETECTION =======
} catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.log.error(
LogEvent.create(GRAPH_MANAGER_EVENT.GRAPH_BUILDER_FAILURE),
error,
);
this.editorStore.changeDetectionState.stop(true); // force stop change detection
this.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.isInFormMode) {
this.editorStore.applicationStore.setBlockingAlert({
message: `Can't build graph: ${error.message}`,
prompt: 'Refreshing full application...',
showLoading: true,
});
this.editorStore.closeAllEditorTabs();
this.editorStore.cleanUp();
yield flowResult(this.editorStore.buildGraph(entities));
} else {
this.editorStore.applicationStore.notifyError(
`Can't build graph: ${error.message}`,
);
}
} finally {
this.isUpdatingApplication = false;
this.editorStore.applicationStore.setBlockingAlert(undefined);
}
}
/**
* Used to update generation model and generation graph using the generated entities
* does not alter the main or dependency model
*/
*updateGenerationGraphAndApplication(): GeneratorFn<void> {
assertTrue(
this.editorStore.graphManagerState.graphBuildState.hasSucceeded &&
this.editorStore.graphManagerState.dependenciesBuildState.hasSucceeded,
'Both main model and dependencies must be processed to built generation graph',
);
this.isUpdatingApplication = true;
try {
/**
* Backup and editor states info before resetting
*
* @risk memory-leak
*/
const openedEditorStates = this.editorStore.openedEditorStates;
const currentEditorState = this.editorStore.currentEditorState;
this.editorStore.closeAllEditorTabs();
yield flowResult(
this.editorStore.graphManagerState.graph.generationModel.dispose(),
);
// we reset the generation model
this.editorStore.graphManagerState.graph.generationModel =
this.editorStore.graphManagerState.createEmptyGenerationModel();
yield this.editorStore.graphManagerState.graphManager.buildGenerations(
this.editorStore.graphManagerState.graph,
this.graphGenerationState.generatedEntities,
this.editorStore.graphManagerState.generationsBuildState,
);
/**
* Reprocess explorer tree which might still hold references to old graph
*
* FIXME: we allow this so the UX stays the same but this can cause memory leak
* we could consider doing this properly using node IDs
*
* @risk memory-leak
*/
this.editorStore.explorerTreeState.reprocess();
// so that information is not dependent on the graph, but on the component itself, with IDs and such.
this.editorStore.openedEditorStates = openedEditorStates
.map((editorState) =>
this.editorStore.reprocessElementEditorState(editorState),
)
.filter(isNonNullable);
this.editorStore.setCurrentEditorState(
this.editorStore.findCurrentEditorState(currentEditorState),
);
} catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.log.error(
LogEvent.create(GRAPH_MANAGER_EVENT.GRAPH_BUILDER_FAILURE),
error,
);
this.editorStore.applicationStore.notifyError(
`Can't build graph: ${error.message}`,
);
} finally {
this.isUpdatingApplication = false;
}
}
*getIndexedDependencyEntities(): GeneratorFn<Map<string, Entity[]>> {
const dependencyEntitiesIndex = new Map<string, Entity[]>();
const currentConfiguration =
this.editorStore.projectConfigurationEditorState
.currentProjectConfiguration;
try {
if (currentConfiguration.projectDependencies.length) {
const dependencyCoordinates = (yield flowResult(
this.buildProjectDependencyCoordinates(
currentConfiguration.projectDependencies,
),
)) as ProjectDependencyCoordinates[];
// NOTE: if A@v1 is transitive dependencies of 2 or more
// direct dependencies, metadata server will take care of deduplication
const dependencyEntitiesJson =
(yield this.editorStore.depotServerClient.collectDependencyEntities(
dependencyCoordinates.map((e) =>
ProjectDependencyCoordinates.serialization.toJson(e),
),
true,
true,
)) as PlainObject<ProjectVersionEntities>[];
const dependencyEntities = dependencyEntitiesJson.map((e) =>
ProjectVersionEntities.serialization.fromJson(e),
);
const dependencyProjects = new Set<string>();
dependencyEntities.forEach((dependencyInfo) => {
const projectId = dependencyInfo.id;
// There are a few validations that must be done:
// 1. Unlike above, if in the depdendency graph, we have both A@v1 and A@v2
// then we need to throw. Both SDLC and metadata server should handle this
// validation, but haven't, so for now, we can do that in Studio.
// 2. Same as the previous case, but for version-to-version transformation
// This is a special case that needs handling, right now, SDLC does auto
// healing, by scanning all the path and convert them into versioned path
// e.g. model::someClass -> project1::v1_0_0::model::someClass
// But this is a rare and advanced use-case which we will not attempt to handle now.
if (dependencyProjects.has(projectId)) {
const projectVersions = dependencyEntities
.filter((e) => e.id === projectId)
.map((e) => e.versionId);
throw new UnsupportedOperationError(
`Depending on multiple versions of a project is not supported. Found dependency on project '${projectId}' with versions: ${projectVersions.join(
', ',
)}.`,
);
}
dependencyEntitiesIndex.set(
dependencyInfo.id,
dependencyInfo.entities,
);
dependencyProjects.add(dependencyInfo.id);
});
}
} catch (error) {
assertErrorThrown(error);
const message = `Can't acquire dependency entitites. Error: ${error.message}`;
this.editorStore.applicationStore.log.error(
LogEvent.create(GRAPH_MANAGER_EVENT.GRAPH_BUILDER_FAILURE),
message,
);
this.editorStore.applicationStore.notifyError(error);
throw new DependencyGraphBuilderError(error);
}
return dependencyEntitiesIndex;
}
*buildProjectDependencyCoordinates(
projectDependencies: ProjectDependency[],
): GeneratorFn<ProjectDependencyCoordinates[]> {
return (yield Promise.all(
projectDependencies.map((dep) => {
// legacyDependencies
// We do this for backward compatible reasons as we expect current dependency ids to be in the format of {groupId}:{artifactId}.
// For the legacy dependency we must fetch the corresponding coordinates (group, artifact ids) from the depot server
if (dep.isLegacyDependency) {
return this.editorStore.depotServerClient
.getProjectById(dep.projectId)
.then((projects) => {
const projectsData = projects.map((p) =>
ProjectData.serialization.fromJson(p),
);
if (projectsData.length !== 1) {
throw new Error(
`Expected 1 project for project ID '${dep.projectId}'. Got ${
projectsData.length
} projects with coordinates ${projectsData
.map(
(i) =>
`'${generateGAVCoordinates(
i.groupId,
i.artifactId,
undefined,
)}'`,
)
.join(', ')}.`,
);
}
const project = projectsData[0] as ProjectData;
return new ProjectDependencyCoordinates(
project.groupId,
project.artifactId,
dep.versionId,
);
});
} else {
return Promise.resolve(
new ProjectDependencyCoordinates(
guaranteeNonNullable(dep.groupId),
guaranteeNonNullable(dep.artifactId),
dep.versionId,
),
);
}
}),
)) as ProjectDependencyCoordinates[];
}
// -------------------------------------------------- UTILITIES -----------------------------------------------------
/**
* NOTE: Notice how this utility draws resources from all of metamodels and uses `instanceof` to classify behavior/response.
* As such, methods in this utility cannot be placed in place they should belong to.
*
* For example: `getSetImplemetnationType` cannot be placed in `SetImplementation` because of circular module dependency
* So this utility is born for such purpose, to avoid circular module dependency, and it should just be used for only that
* Other utilities that really should reside in the domain-specific meta model should be placed in the meta model module.
*
* NOTE: We expect the need for these methods will eventually go away as we complete modularization. But we need these
* methods here so that we can load plugins.
*/
getPackageableElementType(element: PackageableElement): string {
if (element instanceof PrimitiveType) {
return PACKAGEABLE_ELEMENT_TYPE.PRIMITIVE;
} else if (element instanceof Package) {
return PACKAGEABLE_ELEMENT_TYPE.PACKAGE;
} else if (element instanceof Class) {
return PACKAGEABLE_ELEMENT_TYPE.CLASS;
} else if (element instanceof Association) {
return PACKAGEABLE_ELEMENT_TYPE.ASSOCIATION;
} else if (element instanceof Enumeration) {
return PACKAGEABLE_ELEMENT_TYPE.ENUMERATION;
} else if (element instanceof Measure) {
return PACKAGEABLE_ELEMENT_TYPE.MEASURE;
} else if (element instanceof Unit) {
return PACKAGEABLE_ELEMENT_TYPE.UNIT;
} else if (element instanceof Profile) {
return PACKAGEABLE_ELEMENT_TYPE.PROFILE;
} else if (element instanceof ConcreteFunctionDefinition) {
return PACKAGEABLE_ELEMENT_TYPE.FUNCTION;
} else if (element instanceof FlatData) {
return PACKAGEABLE_ELEMENT_TYPE.FLAT_DATA_STORE;
} else if (element instanceof Database) {
return PACKAGEABLE_ELEMENT_TYPE.DATABASE;
} else if (element instanceof Mapping) {
return PACKAGEABLE_ELEMENT_TYPE.MAPPING;
} else if (element instanceof Service) {
return PACKAGEABLE_ELEMENT_TYPE.SERVICE;
} else if (element instanceof PackageableConnection) {
return PACKAGEABLE_ELEMENT_TYPE.CONNECTION;
} else if (element instanceof PackageableRuntime) {
return PACKAGEABLE_ELEMENT_TYPE.RUNTIME;
} else if (element instanceof FileGenerationSpecification) {
return PACKAGEABLE_ELEMENT_TYPE.FILE_GENERATION;
} else if (element instanceof GenerationSpecification) {
return PACKAGEABLE_ELEMENT_TYPE.GENERATION_SPECIFICATION;
} else if (element instanceof SectionIndex) {
return PACKAGEABLE_ELEMENT_TYPE.SECTION_INDEX;
} else if (element instanceof DataElement) {
return PACKAGEABLE_ELEMENT_TYPE.DATA;
}
const extraElementTypeLabelGetters = this.editorStore.pluginManager
.getApplicationPlugins()
.flatMap(
(plugin) =>
(
plugin as DSL_LegendStudioApplicationPlugin_Extension
).getExtraElementTypeGetters?.() ?? [],
);
for (const labelGetter of extraElementTypeLabelGetters) {
const label = labelGetter(element);
if (label) {
return label;
}
}
throw new UnsupportedOperationError(
`Can't get type label for element '${element.path}': no compatible label getter available from plugins`,
);
}
getSetImplementationType(setImplementation: SetImplementation): string {
if (setImplementation instanceof PureInstanceSetImplementation) {
return SET_IMPLEMENTATION_TYPE.PUREINSTANCE;
} else if (setImplementation instanceof OperationSetImplementation) {
return SET_IMPLEMENTATION_TYPE.OPERATION;
} else if (setImplementation instanceof FlatDataInstanceSetImplementation) {
return SET_IMPLEMENTATION_TYPE.FLAT_DATA;
} else if (setImplementation instanceof EmbeddedFlatDataPropertyMapping) {
return SET_IMPLEMENTATION_TYPE.EMBEDDED_FLAT_DATA;
} else if (
setImplementation instanceof RootRelationalInstanceSetImplementation
) {
return SET_IMPLEMENTATION_TYPE.RELATIONAL;
} else if (
setImplementation instanceof EmbeddedRelationalInstanceSetImplementation
) {
return SET_IMPLEMENTATION_TYPE.EMBEDDED_RELATIONAL;
} else if (setImplementation instanceof AggregationAwareSetImplementation) {
return SET_IMPLEMENTATION_TYPE.AGGREGATION_AWARE;
}
const extraSetImplementati