diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts index b05194f594..555ce63f15 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts @@ -21,7 +21,7 @@ import { createBackendPlugin, createBackendModule, } from '@backstage/backend-plugin-api'; -import { BackendInitializer } from './BackendInitializer'; +import { BackendInitializer, resolveInitChunks } from './BackendInitializer'; import { ServiceRegistry } from './ServiceRegistry'; import { rootLifecycleServiceFactory } from '../services/implementations'; @@ -176,3 +176,49 @@ describe('BackendInitializer', () => { ); }); }); + +describe('resolveInitChunks', () => { + it('should resolve flat chunks', () => { + const resolved = resolveInitChunks( + new Map( + Object.entries({ + a: { consumes: new Set(), provides: new Set(['a']) } as any, + b: { consumes: new Set(), provides: new Set(['b']) } as any, + c: { consumes: new Set(), provides: new Set(['c']) } as any, + d: { consumes: new Set(), provides: new Set(['d']) } as any, + }), + ), + ).map(chunk => Array.from(chunk.keys())); + expect(resolved).toEqual([['a', 'b', 'c', 'd']]); + }); + + it('should resolve nested chunks', () => { + const resolved = resolveInitChunks( + new Map( + Object.entries({ + a: { consumes: new Set(), provides: new Set(['a']) } as any, + b: { consumes: new Set(['a']), provides: new Set(['b', 'c']) } as any, + c: { consumes: new Set(['b']), provides: new Set() } as any, + d: { consumes: new Set(['c']), provides: new Set() } as any, + }), + ), + ).map(chunk => Array.from(chunk.keys())); + expect(resolved).toEqual([['a'], ['b'], ['c', 'd']]); + }); + + it('should detect circular dependencies', () => { + expect(() => + resolveInitChunks( + new Map( + Object.entries({ + a: { consumes: new Set(), provides: new Set(['a']) } as any, + b: { consumes: new Set(['a', 'b']), provides: new Set() } as any, + c: { consumes: new Set(['a', 'c']), provides: new Set() } as any, + }), + ), + ), + ).toThrow( + "Failed to resolve module initialization order, the following modules have circular dependencies, 'b', 'c'", + ); + }); +}); diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 3f1c73928b..19b933d96a 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -38,6 +38,58 @@ export interface BackendRegisterInit { }; } +/** @internal */ +export function resolveInitChunks( + registerInits: Map, +): Map[] { + let registerInitsToOrder = Array.from(registerInits); + const orderedRegisterInits = new Array>(); + + // Keep track of all deps that have been provided so far + const provided = new Set(); + + // Loop until we have ordered all registerInits + while (registerInitsToOrder.length > 0) { + const chunk = new Map(); + + // Find all registerInits that have all their deps provided + for (const [id, registerInit] of registerInitsToOrder) { + if ( + Array.from(registerInit.consumes).every(consumed => + provided.has(consumed), + ) + ) { + chunk.set(id, registerInit); + } + } + + // Add to the result set + orderedRegisterInits.push(chunk); + + // Register all the deps in the current chunk as provided + chunk.forEach(r => r.provides.forEach(p => provided.add(p))); + + // Remove the current chunk from the registerInits to order + const newRegisterInitsToOrder = registerInitsToOrder.filter( + ([id]) => !chunk.has(id), + ); + // Check that we have not hit a circular dependency + if ( + registerInitsToOrder.length !== 0 && + registerInitsToOrder.length === newRegisterInitsToOrder.length + ) { + throw new Error( + `Failed to resolve module initialization order, the following modules have circular dependencies, '${registerInitsToOrder + .map(r => r[0]) + .join("', '")}'`, + ); + } + registerInitsToOrder = newRegisterInitsToOrder; + } + + return orderedRegisterInits; +} + export class BackendInitializer { #startPromise?: Promise; #features = new Array(); @@ -210,21 +262,23 @@ export class BackendInitializer { await Promise.all( allPluginIds.map(async pluginId => { // Modules are initialized before plugins, so that they can provide extension to the plugin - const modules = moduleInits.get(pluginId) ?? []; - await Promise.all( - Array.from(modules).map(async ([moduleId, moduleInit]) => { - const moduleDeps = await this.#getInitDeps( - moduleInit.init.deps, - pluginId, - ); - await moduleInit.init.func(moduleDeps).catch(error => { - throw new ForwardedError( - `Module '${moduleId}' for plugin '${pluginId}' startup failed`, - error, + const modules = moduleInits.get(pluginId) ?? new Map(); + for (const chunk of resolveInitChunks(modules)) { + await Promise.all( + Array.from(chunk).map(async ([moduleId, moduleInit]) => { + const moduleDeps = await this.#getInitDeps( + moduleInit.init.deps, + pluginId, ); - }); - }), - ); + await moduleInit.init.func(moduleDeps).catch(error => { + throw new ForwardedError( + `Module '${moduleId}' for plugin '${pluginId}' startup failed`, + error, + ); + }); + }), + ); + } // Once all modules have been initialized, we can initialize the plugin itself const pluginInit = pluginInits.get(pluginId);