Merge pull request #26787 from backstage/rugvip/wait

backend-app-api: settle all inits before exiting on error
This commit is contained in:
Patrik Oldsberg
2024-09-20 07:49:37 +02:00
committed by GitHub
4 changed files with 138 additions and 62 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/backend-app-api': patch
---
The backend will no longer exit immediately if any plugin or modules fails to initialize. Instead, the backend will wait for all plugins and modules to either start up successfully or throw, and then shut down the backend if there were any initialization errors.
This fixes an issue where backend initialization errors in adjacent plugins during database schema migration could cause the database migrations to be stuck in a locked state.
@@ -418,6 +418,49 @@ describe('BackendInitializer', () => {
);
});
it('should forward errors when multiple plugins fail to start', async () => {
const init = new BackendInitializer([]);
init.add(
createBackendPlugin({
pluginId: 'test-1',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE A');
},
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'test-2',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE B');
},
});
},
}),
);
const result = init.start();
await expect(result).rejects.toThrow('Backend startup failed');
await expect(result).rejects.toMatchObject({
errors: [
expect.objectContaining({
message: "Plugin 'test-1' startup failed; caused by Error: NOPE A",
}),
expect.objectContaining({
message: "Plugin 'test-2' startup failed; caused by Error: NOPE B",
}),
],
});
});
it('should forward errors when modules fail to start', async () => {
const init = new BackendInitializer([]);
init.add(testPlugin);
@@ -275,76 +275,91 @@ export class BackendInitializer {
);
// All plugins are initialized in parallel
await Promise.all(
const results = await Promise.allSettled(
allPluginIds.map(async pluginId => {
// Initialize all eager services
await this.#serviceRegistry.initializeEagerServicesWithScope(
'plugin',
pluginId,
);
// Modules are initialized before plugins, so that they can provide extension to the plugin
const modules = moduleInits.get(pluginId);
if (modules) {
const tree = DependencyGraph.fromIterable(
Array.from(modules).map(([moduleId, moduleInit]) => ({
value: { moduleId, moduleInit },
// Relationships are reversed at this point since we're only interested in the extension points.
// If a modules provides extension point A we want it to be initialized AFTER all modules
// that depend on extension point A, so that they can provide their extensions.
consumes: Array.from(moduleInit.provides).map(p => p.id),
provides: Array.from(moduleInit.consumes).map(c => c.id),
})),
);
const circular = tree.detectCircularDependency();
if (circular) {
throw new ConflictError(
`Circular dependency detected for modules of plugin '${pluginId}', ${circular
.map(({ moduleId }) => `'${moduleId}'`)
.join(' -> ')}`,
);
}
await tree.parallelTopologicalTraversal(
async ({ moduleId, moduleInit }) => {
const moduleDeps = await this.#getInitDeps(
moduleInit.init.deps,
pluginId,
moduleId,
);
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);
// We allow modules to be installed without the accompanying plugin, so the plugin may not exist
if (pluginInit) {
const pluginDeps = await this.#getInitDeps(
pluginInit.init.deps,
try {
// Initialize all eager services
await this.#serviceRegistry.initializeEagerServicesWithScope(
'plugin',
pluginId,
);
await pluginInit.init.func(pluginDeps).catch(error => {
throw new ForwardedError(
`Plugin '${pluginId}' startup failed`,
error,
// Modules are initialized before plugins, so that they can provide extension to the plugin
const modules = moduleInits.get(pluginId);
if (modules) {
const tree = DependencyGraph.fromIterable(
Array.from(modules).map(([moduleId, moduleInit]) => ({
value: { moduleId, moduleInit },
// Relationships are reversed at this point since we're only interested in the extension points.
// If a modules provides extension point A we want it to be initialized AFTER all modules
// that depend on extension point A, so that they can provide their extensions.
consumes: Array.from(moduleInit.provides).map(p => p.id),
provides: Array.from(moduleInit.consumes).map(c => c.id),
})),
);
});
const circular = tree.detectCircularDependency();
if (circular) {
throw new ConflictError(
`Circular dependency detected for modules of plugin '${pluginId}', ${circular
.map(({ moduleId }) => `'${moduleId}'`)
.join(' -> ')}`,
);
}
await tree.parallelTopologicalTraversal(
async ({ moduleId, moduleInit }) => {
const moduleDeps = await this.#getInitDeps(
moduleInit.init.deps,
pluginId,
moduleId,
);
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);
// We allow modules to be installed without the accompanying plugin, so the plugin may not exist
if (pluginInit) {
const pluginDeps = await this.#getInitDeps(
pluginInit.init.deps,
pluginId,
);
await pluginInit.init.func(pluginDeps).catch(error => {
throw new ForwardedError(
`Plugin '${pluginId}' startup failed`,
error,
);
});
}
initLogger.onPluginStarted(pluginId);
// Once the plugin and all modules have been initialized, we can signal that the plugin has stared up successfully
const lifecycleService = await this.#getPluginLifecycleImpl(pluginId);
await lifecycleService.startup();
} catch (error) {
initLogger.onPluginFailed(pluginId);
throw error;
}
initLogger.onPluginStarted(pluginId);
// Once the plugin and all modules have been initialized, we can signal that the plugin has stared up successfully
const lifecycleService = await this.#getPluginLifecycleImpl(pluginId);
await lifecycleService.startup();
}),
);
const initErrors = results.flatMap(r =>
r.status === 'rejected' ? [r.reason] : [],
);
if (initErrors.length === 1) {
throw initErrors[0];
} else if (initErrors.length > 1) {
// TODO(Rugvip): Seems like there aren't proper types for AggregateError yet
throw new (AggregateError as any)(initErrors, 'Backend startup failed');
}
// Once all plugins and modules have been initialized, we can signal that the backend has started up successfully
const lifecycleService = await this.#getRootLifecycleImpl();
await lifecycleService.startup();
@@ -27,6 +27,7 @@ export function createInitializationLogger(
rootLogger?: RootLoggerService,
): {
onPluginStarted(pluginId: string): void;
onPluginFailed(pluginId: string): void;
onAllStarted(): void;
} {
const logger = rootLogger?.child({ type: 'initialization' });
@@ -67,6 +68,16 @@ export function createInitializationLogger(
starting.delete(pluginId);
started.add(pluginId);
},
onPluginFailed(pluginId: string) {
starting.delete(pluginId);
const status =
starting.size > 0
? `, waiting for ${starting.size} other plugins to finish before shutting down the process`
: '';
logger?.error(
`Plugin '${pluginId}' thew an error during startup${status}`,
);
},
onAllStarted() {
logger?.info(`Plugin initialization complete${getInitStatus()}`);