status service: get rid of calculateDepthRecursive (#179160)

## Summary

We were spending a lot of time in `calculateDepthRecursive`

<img width="1367" alt="Screenshot 2024-03-21 at 13 46 00"
src="11047c7b-9373-4976-ba40-8e6ad8d15373">

And it could fully be avoided given the plugin service already sorts the
plugins.

This PR changes the way the plugin system exposes its
`PluginDependencies` so that the keys are topologically ordered (same
order we use to setup / start the plugins) and then use that ordering
directly from the status service.
This commit is contained in:
Pierre Gayvallet 2024-03-21 18:31:58 +01:00 committed by GitHub
parent 7ca14caca7
commit 386c29094d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 115 additions and 129 deletions

View file

@ -128,7 +128,7 @@ test('getPlugins returns the list of plugins', () => {
expect(pluginsSystem.getPlugins()).toEqual([pluginA, pluginB]);
});
test('getPluginDependencies returns dependency tree of symbols', () => {
test('getPluginDependencies returns dependency tree with keys topologically sorted', () => {
pluginsSystem.addPlugin(createPlugin('plugin-a', { required: ['no-dep'] }));
pluginsSystem.addPlugin(
createPlugin('plugin-b', { required: ['plugin-a'], optional: ['no-dep', 'other'] })
@ -138,6 +138,7 @@ test('getPluginDependencies returns dependency tree of symbols', () => {
expect(pluginsSystem.getPluginDependencies()).toMatchInlineSnapshot(`
Object {
"asNames": Map {
"no-dep" => Array [],
"plugin-a" => Array [
"no-dep",
],
@ -145,9 +146,9 @@ test('getPluginDependencies returns dependency tree of symbols', () => {
"plugin-a",
"no-dep",
],
"no-dep" => Array [],
},
"asOpaqueIds": Map {
Symbol(no-dep) => Array [],
Symbol(plugin-a) => Array [
Symbol(no-dep),
],
@ -155,7 +156,6 @@ test('getPluginDependencies returns dependency tree of symbols', () => {
Symbol(plugin-a),
Symbol(no-dep),
],
Symbol(no-dep) => Array [],
},
}
`);

View file

@ -34,6 +34,7 @@ export class PluginsSystem<T extends PluginType> {
private readonly log: Logger;
// `satup`, the past-tense version of the noun `setup`.
private readonly satupPlugins: PluginName[] = [];
private sortedPluginNames?: Set<string>;
constructor(private readonly coreContext: CoreContext, public readonly type: T) {
this.log = coreContext.logger.get('plugins-system', this.type);
@ -47,6 +48,9 @@ export class PluginsSystem<T extends PluginType> {
}
this.plugins.set(plugin.name, plugin);
// clear sorted plugin name cache on addition
this.sortedPluginNames = undefined;
}
public getPlugins() {
@ -54,32 +58,31 @@ export class PluginsSystem<T extends PluginType> {
}
/**
* @returns a ReadonlyMap of each plugin and an Array of its available dependencies
* @returns a Map of each plugin and an Array of its available dependencies
* @internal
*/
public getPluginDependencies(): PluginDependencies {
const asNames = new Map(
[...this.plugins].map(([name, plugin]) => [
const asNames = new Map<string, string[]>();
const asOpaqueIds = new Map<symbol, symbol[]>();
for (const pluginName of this.getTopologicallySortedPluginNames()) {
const plugin = this.plugins.get(pluginName)!;
const dependencies = [
...new Set([
...plugin.requiredPlugins,
...plugin.optionalPlugins.filter((optPlugin) => this.plugins.has(optPlugin)),
]),
];
asNames.set(
plugin.name,
[
...new Set([
...plugin.requiredPlugins,
...plugin.optionalPlugins.filter((optPlugin) => this.plugins.has(optPlugin)),
]),
].map((depId) => this.plugins.get(depId)!.name),
])
);
const asOpaqueIds = new Map(
[...this.plugins].map(([name, plugin]) => [
dependencies.map((depId) => this.plugins.get(depId)!.name)
);
asOpaqueIds.set(
plugin.opaqueId,
[
...new Set([
...plugin.requiredPlugins,
...plugin.optionalPlugins.filter((optPlugin) => this.plugins.has(optPlugin)),
]),
].map((depId) => this.plugins.get(depId)!.opaqueId),
])
);
dependencies.map((depId) => this.plugins.get(depId)!.opaqueId)
);
}
return { asNames, asOpaqueIds };
}
@ -298,68 +301,75 @@ export class PluginsSystem<T extends PluginType> {
return publicPlugins;
}
/**
* Gets topologically sorted plugin names that are registered with the plugin system.
* Ordering is possible if and only if the plugins graph has no directed cycles,
* that is, if it is a directed acyclic graph (DAG). If plugins cannot be ordered
* an error is thrown.
*
* Uses Kahn's Algorithm to sort the graph.
*/
private getTopologicallySortedPluginNames() {
// We clone plugins so we can remove handled nodes while we perform the
// topological ordering. If the cloned graph is _not_ empty at the end, we
// know we were not able to topologically order the graph. We exclude optional
// dependencies that are not present in the plugins graph.
const pluginsDependenciesGraph = new Map(
[...this.plugins.entries()].map(([pluginName, plugin]) => {
return [
pluginName,
new Set([
...plugin.requiredPlugins,
...plugin.optionalPlugins.filter((dependency) => this.plugins.has(dependency)),
]),
] as [PluginName, Set<PluginName>];
})
);
// First, find a list of "start nodes" which have no outgoing edges. At least
// one such node must exist in a non-empty acyclic graph.
const pluginsWithAllDependenciesSorted = [...pluginsDependenciesGraph.keys()].filter(
(pluginName) => pluginsDependenciesGraph.get(pluginName)!.size === 0
);
const sortedPluginNames = new Set<PluginName>();
while (pluginsWithAllDependenciesSorted.length > 0) {
const sortedPluginName = pluginsWithAllDependenciesSorted.pop()!;
// We know this plugin has all its dependencies sorted, so we can remove it
// and include into the final result.
pluginsDependenciesGraph.delete(sortedPluginName);
sortedPluginNames.add(sortedPluginName);
// Go through the rest of the plugins and remove `sortedPluginName` from their
// unsorted dependencies.
for (const [pluginName, dependencies] of pluginsDependenciesGraph) {
// If we managed delete `sortedPluginName` from dependencies let's check
// whether it was the last one and we can mark plugin as sorted.
if (dependencies.delete(sortedPluginName) && dependencies.size === 0) {
pluginsWithAllDependenciesSorted.push(pluginName);
}
}
if (!this.sortedPluginNames) {
this.sortedPluginNames = getTopologicallySortedPluginNames(this.plugins);
}
if (pluginsDependenciesGraph.size > 0) {
const edgesLeft = JSON.stringify([...pluginsDependenciesGraph.keys()]);
throw new Error(
`Topological ordering of plugins did not complete, these plugins have cyclic or missing dependencies: ${edgesLeft}`
);
}
return sortedPluginNames;
return this.sortedPluginNames;
}
}
/**
* Gets topologically sorted plugin names that are registered with the plugin system.
* Ordering is possible if and only if the plugins graph has no directed cycles,
* that is, if it is a directed acyclic graph (DAG). If plugins cannot be ordered
* an error is thrown.
*
* Uses Kahn's Algorithm to sort the graph.
*/
const getTopologicallySortedPluginNames = (plugins: Map<PluginName, PluginWrapper>) => {
// We clone plugins so we can remove handled nodes while we perform the
// topological ordering. If the cloned graph is _not_ empty at the end, we
// know we were not able to topologically order the graph. We exclude optional
// dependencies that are not present in the plugins graph.
const pluginsDependenciesGraph = new Map(
[...plugins.entries()].map(([pluginName, plugin]) => {
return [
pluginName,
new Set([
...plugin.requiredPlugins,
...plugin.optionalPlugins.filter((dependency) => plugins.has(dependency)),
]),
] as [PluginName, Set<PluginName>];
})
);
// First, find a list of "start nodes" which have no outgoing edges. At least
// one such node must exist in a non-empty acyclic graph.
const pluginsWithAllDependenciesSorted = [...pluginsDependenciesGraph.keys()].filter(
(pluginName) => pluginsDependenciesGraph.get(pluginName)!.size === 0
);
const sortedPluginNames = new Set<PluginName>();
while (pluginsWithAllDependenciesSorted.length > 0) {
const sortedPluginName = pluginsWithAllDependenciesSorted.pop()!;
// We know this plugin has all its dependencies sorted, so we can remove it
// and include into the final result.
pluginsDependenciesGraph.delete(sortedPluginName);
sortedPluginNames.add(sortedPluginName);
// Go through the rest of the plugins and remove `sortedPluginName` from their
// unsorted dependencies.
for (const [pluginName, dependencies] of pluginsDependenciesGraph) {
// If we managed delete `sortedPluginName` from dependencies let's check
// whether it was the last one and we can mark plugin as sorted.
if (dependencies.delete(sortedPluginName) && dependencies.size === 0) {
pluginsWithAllDependenciesSorted.push(pluginName);
}
}
}
if (pluginsDependenciesGraph.size > 0) {
const edgesLeft = JSON.stringify([...pluginsDependenciesGraph.keys()]);
throw new Error(
`Topological ordering of plugins did not complete, these plugins have cyclic or missing dependencies: ${edgesLeft}`
);
}
return sortedPluginNames;
};
const buildReverseDependencyMap = (
pluginMap: Map<PluginName, PluginWrapper>
): Map<PluginName, PluginName[]> => {

View file

@ -10,6 +10,16 @@ import type { PluginName, PluginOpaqueId } from '@kbn/core-base-common';
/** @internal */
export interface PluginDependencies {
/**
* Plugin to dependencies map with plugin names as key/values.
*
* Keys sorted by plugin topological order (root plugins first, leaf plugins last).
*/
asNames: ReadonlyMap<PluginName, PluginName[]>;
/**
* Plugin to dependencies map with plugin opaque ids as key/values.
*
* Keys sorted by plugin topological order (root plugins first, leaf plugins last).
*/
asOpaqueIds: ReadonlyMap<PluginOpaqueId, PluginOpaqueId[]>;
}

View file

@ -23,7 +23,6 @@ import {
takeUntil,
delay,
} from 'rxjs/operators';
import { sortBy } from 'lodash';
import { isDeepStrictEqual } from 'util';
import type { PluginName } from '@kbn/core-base-common';
import { ServiceStatusLevels, type CoreStatus, type ServiceStatus } from '@kbn/core-status-common';
@ -45,7 +44,6 @@ export interface Deps {
interface PluginData {
[name: PluginName]: {
name: PluginName;
depth: number; // depth of this plugin in the dependency tree (root plugins will have depth = 1)
dependencies: PluginName[];
reverseDependencies: PluginName[];
reportedStatus?: PluginStatus;
@ -81,7 +79,8 @@ export class PluginsStatusService {
constructor(deps: Deps, private readonly statusTimeoutMs: number = STATUS_TIMEOUT_MS) {
this.pluginData = this.initPluginData(deps.pluginDependencies);
this.rootPlugins = this.getRootPlugins();
this.orderedPluginNames = this.getOrderedPluginNames();
// plugin dependencies keys are already sorted
this.orderedPluginNames = [...deps.pluginDependencies.keys()];
this.coreSubscription = deps.core$
.pipe(
@ -216,23 +215,20 @@ export class PluginsStatusService {
private initPluginData(pluginDependencies: ReadonlyMap<PluginName, PluginName[]>): PluginData {
const pluginData: PluginData = {};
if (pluginDependencies) {
pluginDependencies.forEach((dependencies, name) => {
pluginData[name] = {
name,
depth: 0,
dependencies,
reverseDependencies: [],
derivedStatus: defaultStatus,
};
});
pluginDependencies.forEach((dependencies, name) => {
pluginData[name] = {
name,
dependencies,
reverseDependencies: [],
derivedStatus: defaultStatus,
};
});
pluginDependencies.forEach((dependencies, name) => {
dependencies.forEach((dependency) => {
pluginData[dependency].reverseDependencies.push(name);
});
pluginDependencies.forEach((dependencies, name) => {
dependencies.forEach((dependency) => {
pluginData[dependency].reverseDependencies.push(name);
});
}
});
return pluginData;
}
@ -248,36 +244,6 @@ export class PluginsStatusService {
);
}
/**
* Obtain a list of plugins names, ordered by depth.
* @see {calculateDepthRecursive}
* @returns {PluginName[]} a list of plugins, ordered by depth + name
*/
private getOrderedPluginNames(): PluginName[] {
this.rootPlugins.forEach((plugin) => {
this.calculateDepthRecursive(plugin, 1);
});
return sortBy(Object.values(this.pluginData), ['depth', 'name']).map(({ name }) => name);
}
/**
* Calculate the depth of the given plugin, knowing that it's has at least the specified depth
* The depth of a plugin is determined by how many levels of dependencies the plugin has above it.
* We define root plugins as depth = 1, plugins that only depend on root plugins will have depth = 2
* and so on so forth
* @param {PluginName} plugin the name of the plugin whose depth must be calculated
* @param {number} depth the minimum depth that we know for sure this plugin has
*/
private calculateDepthRecursive(plugin: PluginName, depth: number): void {
const pluginData = this.pluginData[plugin];
pluginData.depth = Math.max(pluginData.depth, depth);
const newDepth = depth + 1;
pluginData.reverseDependencies.forEach((revDep) =>
this.calculateDepthRecursive(revDep, newDepth)
);
}
/**
* Updates the root plugins statuses according to the current core services status
*/