From d69df5a70edd58c5e960365ea8b2e89091d401a0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 18 Nov 2025 19:17:28 +0100 Subject: [PATCH] backend-app-api: refactor startup tracking and report results Signed-off-by: Patrik Oldsberg --- .../src/wiring/BackendInitializer.test.ts | 634 ++++++++++++++++-- .../src/wiring/BackendInitializer.ts | 138 ++-- .../src/wiring/BackendStartupError.ts | 71 ++ .../src/wiring/BackstageBackend.ts | 6 +- .../src/wiring/createInitializationLogger.ts | 125 ---- .../createInitializationResultCollector.ts | 178 +++++ packages/backend-app-api/src/wiring/index.ts | 8 +- packages/backend-app-api/src/wiring/types.ts | 87 ++- .../permissionsRegistryServiceFactory.test.ts | 14 +- 9 files changed, 985 insertions(+), 276 deletions(-) create mode 100644 packages/backend-app-api/src/wiring/BackendStartupError.ts delete mode 100644 packages/backend-app-api/src/wiring/createInitializationLogger.ts create mode 100644 packages/backend-app-api/src/wiring/createInitializationResultCollector.ts diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts index 965e467501..f5e4b80417 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts @@ -26,6 +26,7 @@ import { } from '@backstage/backend-plugin-api'; import { BackendInitializer } from './BackendInitializer'; import { mockServices } from '@backstage/backend-test-utils'; +import { BackendStartupError } from './BackendStartupError'; const baseFactories = [ mockServices.rootLifecycle.factory(), @@ -806,49 +807,6 @@ 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); @@ -1289,4 +1247,594 @@ describe('BackendInitializer', () => { await backend.add(slowConsumerModule); await backend.start(); }); + + describe('startup results', () => { + it('should return successful startup result when all plugins and modules start successfully', async () => { + const init = new BackendInitializer(baseFactories); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + init.add( + createBackendPlugin({ + pluginId: 'plugin2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin2', + moduleId: 'module2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + + const result = await init.start(); + + expect(result.plugins).toHaveLength(2); + expect(result.plugins[0]).toMatchObject({ + pluginId: 'plugin1', + modules: [ + { + moduleId: 'module1', + }, + ], + }); + expect(result.plugins[0].failure).toBeUndefined(); + expect(result.plugins[0].modules[0].failure).toBeUndefined(); + expect(result.plugins[1]).toMatchObject({ + pluginId: 'plugin2', + modules: [ + { + moduleId: 'module2', + }, + ], + }); + expect(result.plugins[1].failure).toBeUndefined(); + expect(result.plugins[1].modules[0].failure).toBeUndefined(); + }); + + it('should throw BackendStartupError with results when plugin fails to start', async () => { + const init = new BackendInitializer(baseFactories); + const error = new Error('Plugin failed'); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error; + }, + }); + }, + }), + ); + init.add( + createBackendPlugin({ + pluginId: 'plugin2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + + const err = await init.start().then( + () => { + throw new Error('Expected BackendStartupError to be thrown'); + }, + (e: BackendStartupError) => e, + ); + + expect(err).toBeInstanceOf(BackendStartupError); + expect(err?.result.plugins).toHaveLength(2); + expect(err?.result.plugins[0]).toMatchObject({ + pluginId: 'plugin1', + failure: { error, allowed: false }, + }); + expect(err?.result.plugins[1]).toMatchObject({ + pluginId: 'plugin2', + }); + expect(err?.result.plugins[1].failure).toBeUndefined(); + }); + + it('should throw BackendStartupError with results when module fails to start', async () => { + const init = new BackendInitializer(baseFactories); + const error = new Error('Module failed'); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error; + }, + }); + }, + }), + ); + + const err = await init.start().then( + () => { + throw new Error('Expected BackendStartupError to be thrown'); + }, + (e: BackendStartupError) => e, + ); + + expect(err).toBeInstanceOf(BackendStartupError); + expect(err?.result.plugins).toHaveLength(1); + expect(err?.result.plugins[0]).toMatchObject({ + pluginId: 'plugin1', + modules: [ + { + moduleId: 'module1', + failure: { error, allowed: false }, + }, + ], + }); + expect(err?.result.plugins[0].failure).toBeUndefined(); + }); + + it('should throw BackendStartupError with results when multiple plugins fail', async () => { + const init = new BackendInitializer(baseFactories); + const error1 = new Error('Plugin1 failed'); + const error2 = new Error('Plugin2 failed'); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error1; + }, + }); + }, + }), + ); + init.add( + createBackendPlugin({ + pluginId: 'plugin2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error2; + }, + }); + }, + }), + ); + + const err = await init.start().then( + () => { + throw new Error('Expected BackendStartupError to be thrown'); + }, + (e: BackendStartupError) => e, + ); + + expect(err).toBeInstanceOf(BackendStartupError); + expect(err?.result.plugins).toHaveLength(2); + expect(err?.result.plugins[0].failure?.error).toBe(error1); + expect(err?.result.plugins[1].failure?.error).toBe(error2); + }); + + it('should return results with failure status when plugin boot failure is permitted', async () => { + const error = new Error('Plugin failed'); + const init = new BackendInitializer([ + ...baseFactories, + mockServices.rootConfig.factory({ + data: { + backend: { + startup: { + plugins: { plugin1: { onPluginBootFailure: 'continue' } }, + }, + }, + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error; + }, + }); + }, + }), + ); + init.add( + createBackendPlugin({ + pluginId: 'plugin2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + + const result = await init.start(); + + expect(result.plugins).toHaveLength(2); + expect(result.plugins[0]).toMatchObject({ + pluginId: 'plugin1', + failure: { error, allowed: true }, + }); + expect(result.plugins[1]).toMatchObject({ + pluginId: 'plugin2', + }); + expect(result.plugins[1].failure).toBeUndefined(); + }); + + it('should return results with failure status when module boot failure is permitted', async () => { + const error = new Error('Module failed'); + const init = new BackendInitializer([ + ...baseFactories, + mockServices.rootConfig.factory({ + data: { + backend: { + startup: { + plugins: { + plugin1: { + modules: { + module1: { onPluginModuleBootFailure: 'continue' }, + }, + }, + }, + }, + }, + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error; + }, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + + const result = await init.start(); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0]).toMatchObject({ + pluginId: 'plugin1', + modules: [ + { + moduleId: 'module1', + failure: { error, allowed: true }, + }, + { + moduleId: 'module2', + }, + ], + }); + expect(result.plugins[0].failure).toBeUndefined(); + expect(result.plugins[0].modules[1].failure).toBeUndefined(); + }); + + it('should include all module results even when some modules fail', async () => { + const error1 = new Error('Module1 failed'); + const error2 = new Error('Module2 failed'); + const init = new BackendInitializer([ + ...baseFactories, + mockServices.rootConfig.factory({ + data: { + backend: { + startup: { + plugins: { + plugin1: { + modules: { + module1: { onPluginModuleBootFailure: 'continue' }, + module2: { onPluginModuleBootFailure: 'continue' }, + }, + }, + }, + }, + }, + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error1; + }, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error2; + }, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module3', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + + const result = await init.start(); + + expect(result.plugins[0].modules).toHaveLength(3); + expect(result.plugins[0].modules[0]).toMatchObject({ + moduleId: 'module1', + failure: { error: error1, allowed: true }, + }); + expect(result.plugins[0].modules[1]).toMatchObject({ + moduleId: 'module2', + failure: { error: error2, allowed: true }, + }); + expect(result.plugins[0].modules[2]).toMatchObject({ + moduleId: 'module3', + }); + expect(result.plugins[0].modules[2].failure).toBeUndefined(); + }); + + it('should return results for plugins without modules', async () => { + const init = new BackendInitializer(baseFactories); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + + const result = await init.start(); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0]).toMatchObject({ + pluginId: 'plugin1', + modules: [], + }); + expect(result.plugins[0].failure).toBeUndefined(); + }); + + it('should include error information in BackendStartupError', async () => { + const error = new Error('Test error'); + const init = new BackendInitializer(baseFactories); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error; + }, + }); + }, + }), + ); + + const err = await init.start().then( + () => { + throw new Error('Expected BackendStartupError to be thrown'); + }, + (e: BackendStartupError) => e, + ); + + expect(err).toBeInstanceOf(BackendStartupError); + expect(err?.message).toBe( + "Backend startup failed due to the following errors:\n Plugin 'plugin1' startup failed; caused by Error: Test error", + ); + expect(err?.result).toBeDefined(); + expect(err?.result.plugins[0].failure?.error).toBe(error); + }); + + it('should handle plugin scoped service factory failures', async () => { + const serviceFactoryError = new Error('Service factory failed'); + const ref = createServiceRef<{ value: string }>({ + id: 'failing-service', + }); + const init = new BackendInitializer([ + ...baseFactories, + createServiceFactory({ + service: ref, + deps: {}, + factory: () => { + throw serviceFactoryError; + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: { service: ref }, + async init() {}, + }); + }, + }), + ); + + const err = await init.start().then( + () => { + throw new Error('Expected BackendStartupError to be thrown'); + }, + (e: BackendStartupError) => e, + ); + + expect(err).toBeInstanceOf(BackendStartupError); + expect(err?.result.plugins).toHaveLength(1); + expect(err?.result.plugins[0].pluginId).toBe('plugin1'); + expect(err?.result.plugins[0].failure).toBeDefined(); + expect(err?.result.plugins[0].failure?.allowed).toBe(false); + expect(err?.result.plugins[0].failure?.error.message).toContain( + "Failed to instantiate service 'failing-service'", + ); + expect(err?.result.plugins[0].failure?.error.message).toContain( + 'Service factory failed', + ); + }); + + it('should handle plugin scoped service factory failures with allowed boot failure', async () => { + const serviceFactoryError = new Error('Service factory failed'); + const ref = createServiceRef<{ value: string }>({ + id: 'failing-service', + }); + const init = new BackendInitializer([ + ...baseFactories, + mockServices.rootConfig.factory({ + data: { + backend: { + startup: { + plugins: { plugin1: { onPluginBootFailure: 'continue' } }, + }, + }, + }, + }), + createServiceFactory({ + service: ref, + deps: {}, + factory: () => { + throw serviceFactoryError; + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: { service: ref }, + async init() {}, + }); + }, + }), + ); + + const result = await init.start(); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0].pluginId).toBe('plugin1'); + expect(result.plugins[0].failure).toBeDefined(); + expect(result.plugins[0].failure?.allowed).toBe(true); + expect(result.plugins[0].failure?.error.message).toContain( + "Failed to instantiate service 'failing-service'", + ); + expect(result.plugins[0].failure?.error.message).toContain( + 'Service factory failed', + ); + }); + }); }); diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index e7480d3beb..83d5da72cd 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -24,7 +24,6 @@ import { RootLifecycleService, createServiceFactory, } from '@backstage/backend-plugin-api'; -import { Config } from '@backstage/config'; import { ServiceOrExtensionPoint } from './types'; // Direct internal import to avoid duplication // eslint-disable-next-line @backstage/no-relative-monorepo-imports @@ -38,9 +37,12 @@ import type { InternalServiceFactory } from '../../../backend-plugin-api/src/ser import { ForwardedError, ConflictError, assertError } from '@backstage/errors'; import { DependencyGraph } from '../lib/DependencyGraph'; import { ServiceRegistry } from './ServiceRegistry'; -import { createInitializationLogger } from './createInitializationLogger'; +import { createInitializationResultCollector } from './createInitializationResultCollector'; import { deepFreeze, unwrapFeature } from './helpers'; import type { RootInstanceMetadataServicePluginInfo } from '@backstage/backend-plugin-api'; +import { BackendStartupResult } from './types'; +import { BackendStartupError } from './BackendStartupError'; +import { createAllowBootFailurePredicate } from './createAllowBootFailurePredicate'; export interface BackendRegisterInit { consumes: Set; @@ -148,7 +150,7 @@ function createRootInstanceMetadataServiceFactory( } export class BackendInitializer { - #startPromise?: Promise; + #startPromise?: Promise; #stopPromise?: Promise; #registrations = new Array(); #extensionPoints = new Map(); @@ -224,7 +226,7 @@ export class BackendInitializer { } } - async start(): Promise { + async start(): Promise { if (this.#startPromise) { throw new Error('Backend has already started'); } @@ -235,10 +237,10 @@ export class BackendInitializer { instanceRegistry.register(this); this.#startPromise = this.#doStart(); - await this.#startPromise; + return await this.#startPromise; } - async #doStart(): Promise { + async #doStart(): Promise { this.#serviceRegistry.checkForCircularDeps(); for (const feature of this.#registeredFeatures) { @@ -332,26 +334,26 @@ export class BackendInitializer { } } - const allPluginIds = [...pluginInits.keys()]; - - const initLogger = createInitializationLogger( - allPluginIds, - await this.#serviceRegistry.get(coreServices.rootLogger, 'root'), - ); + const pluginIds = [...pluginInits.keys()]; const rootConfig = await this.#serviceRegistry.get( coreServices.rootConfig, 'root', ); + const rootLogger = await this.#serviceRegistry.get( + coreServices.rootLogger, + 'root', + ); + + const resultCollector = createInitializationResultCollector({ + pluginIds, + logger: rootLogger, + allowBootFailurePredicate: createAllowBootFailurePredicate(rootConfig), + }); // All plugins are initialized in parallel - const results = await Promise.allSettled( - allPluginIds.map(async pluginId => { - const isBootFailurePermitted = this.#getPluginBootFailurePredicate( - pluginId, - rootConfig, - ); - + await Promise.all( + pluginIds.map(async pluginId => { try { // Initialize all eager services await this.#serviceRegistry.initializeEagerServicesWithScope( @@ -382,37 +384,21 @@ export class BackendInitializer { } await tree.parallelTopologicalTraversal( async ({ moduleId, moduleInit }) => { - const isModuleBootFailurePermitted = - this.#getPluginModuleBootFailurePredicate( - pluginId, - moduleId, - rootConfig, - ); - try { 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, - ); - }); + await moduleInit.init.func(moduleDeps); + resultCollector.onPluginModuleResult(pluginId, moduleId); } catch (error: unknown) { assertError(error); - if (isModuleBootFailurePermitted) { - initLogger.onPermittedPluginModuleFailure( - pluginId, - moduleId, - error, - ); - } else { - initLogger.onPluginModuleFailed(pluginId, moduleId, error); - throw error; - } + resultCollector.onPluginModuleResult( + pluginId, + moduleId, + error, + ); } }, ); @@ -426,46 +412,36 @@ export class BackendInitializer { pluginInit.init.deps, pluginId, ); - await pluginInit.init.func(pluginDeps).catch(error => { - throw new ForwardedError( - `Plugin '${pluginId}' startup failed`, - error, - ); - }); + await pluginInit.init.func(pluginDeps); } - initLogger.onPluginStarted(pluginId); + resultCollector.onPluginResult(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: unknown) { assertError(error); - if (isBootFailurePermitted) { - initLogger.onPermittedPluginFailure(pluginId, error); - } else { - initLogger.onPluginFailed(pluginId, error); - throw error; - } + resultCollector.onPluginResult(pluginId, error); } }), - ); + ).catch(error => { + throw new ForwardedError( + 'Unexpected uncaught backend startup error', + error, + ); + }); - 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'); + const startupResult = resultCollector.finalize(); + if (startupResult.result === 'failure') { + throw new BackendStartupError(startupResult); } // 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(); - initLogger.onAllStarted(); + return startupResult; } // It's fine to call .stop() multiple times, which for example can happen with manual stop + process exit @@ -654,38 +630,6 @@ export class BackendInitializer { } } } - - #getPluginBootFailurePredicate(pluginId: string, config?: Config): boolean { - const defaultStartupBootFailureValue = - config?.getOptionalString( - 'backend.startup.default.onPluginBootFailure', - ) ?? 'abort'; - - const pluginStartupBootFailureValue = - config?.getOptionalString( - `backend.startup.plugins.${pluginId}.onPluginBootFailure`, - ) ?? defaultStartupBootFailureValue; - - return pluginStartupBootFailureValue === 'continue'; - } - - #getPluginModuleBootFailurePredicate( - pluginId: string, - moduleId: string, - config?: Config, - ): boolean { - const defaultStartupBootFailureValue = - config?.getOptionalString( - 'backend.startup.default.onPluginModuleBootFailure', - ) ?? 'abort'; - - const pluginModuleStartupBootFailureValue = - config?.getOptionalString( - `backend.startup.plugins.${pluginId}.modules.${moduleId}.onPluginModuleBootFailure`, - ) ?? defaultStartupBootFailureValue; - - return pluginModuleStartupBootFailureValue === 'continue'; - } } function toInternalBackendFeature( diff --git a/packages/backend-app-api/src/wiring/BackendStartupError.ts b/packages/backend-app-api/src/wiring/BackendStartupError.ts new file mode 100644 index 0000000000..7876b2c236 --- /dev/null +++ b/packages/backend-app-api/src/wiring/BackendStartupError.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { CustomErrorBase } from '@backstage/errors'; +import { BackendStartupResult } from './types'; + +function formatMessage(startupResult: BackendStartupResult): string { + const parts: string[] = [ + 'Backend startup failed due to the following errors:', + ]; + const failures: string[] = []; + + for (const plugin of startupResult.plugins) { + if (plugin.failure && !plugin.failure.allowed) { + failures.push( + ` Plugin '${plugin.pluginId}' startup failed; caused by ${plugin.failure.error}`, + ); + } + + for (const mod of plugin.modules) { + if (mod.failure && !mod.failure.allowed) { + failures.push( + ` Module '${mod.moduleId}' for plugin '${plugin.pluginId}' startup failed; caused by ${mod.failure.error}`, + ); + } + } + } + + if (failures.length > 0) { + parts.push(...failures); + } + + return parts.join('\n'); +} + +/** + * Error thrown when backend startup fails. + * Includes detailed startup results for all plugins and modules. + * + * @public + */ +export class BackendStartupError extends CustomErrorBase { + name = 'BackendStartupError' as const; + + /** + * The startup results for all plugins and modules. + */ + readonly #startupResult: BackendStartupResult; + + constructor(startupResult: BackendStartupResult) { + super(formatMessage(startupResult)); + this.#startupResult = startupResult; + } + + get result(): BackendStartupResult { + return this.#startupResult; + } +} diff --git a/packages/backend-app-api/src/wiring/BackstageBackend.ts b/packages/backend-app-api/src/wiring/BackstageBackend.ts index 49a8bbbbfb..a101a03375 100644 --- a/packages/backend-app-api/src/wiring/BackstageBackend.ts +++ b/packages/backend-app-api/src/wiring/BackstageBackend.ts @@ -17,7 +17,7 @@ import { BackendFeature, ServiceFactory } from '@backstage/backend-plugin-api'; import { BackendInitializer } from './BackendInitializer'; import { unwrapFeature } from './helpers'; -import { Backend } from './types'; +import { Backend, BackendStartupResult } from './types'; export class BackstageBackend implements Backend { #initializer: BackendInitializer; @@ -34,8 +34,8 @@ export class BackstageBackend implements Backend { } } - async start(): Promise { - await this.#initializer.start(); + async start(): Promise { + return await this.#initializer.start(); } async stop(): Promise { diff --git a/packages/backend-app-api/src/wiring/createInitializationLogger.ts b/packages/backend-app-api/src/wiring/createInitializationLogger.ts deleted file mode 100644 index 600826a435..0000000000 --- a/packages/backend-app-api/src/wiring/createInitializationLogger.ts +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * 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 { RootLoggerService } from '@backstage/backend-plugin-api'; - -const LOGGER_INTERVAL_MAX = 60_000; - -function joinIds(ids: Iterable): string { - return [...ids].map(id => `'${id}'`).join(', '); -} - -export function createInitializationLogger( - pluginIds: string[], - rootLogger?: RootLoggerService, -): { - onPluginStarted(pluginId: string): void; - onPluginFailed(pluginId: string, error: Error): void; - onPermittedPluginFailure(pluginId: string, error: Error): void; - onPluginModuleFailed(pluginId: string, moduleId: string, error: Error): void; - onPermittedPluginModuleFailure( - pluginId: string, - moduleId: string, - error: Error, - ): void; - onAllStarted(): void; -} { - const logger = rootLogger?.child({ type: 'initialization' }); - const starting = new Set(pluginIds); - const started = new Set(); - - logger?.info(`Plugin initialization started: ${joinIds(pluginIds)}`); - - const getInitStatus = () => { - let status = ''; - if (started.size > 0) { - status = `, newly initialized: ${joinIds(started)}`; - started.clear(); - } - if (starting.size > 0) { - status += `, still initializing: ${joinIds(starting)}`; - } - return status; - }; - - // Periodically log the initialization status with a fibonacci backoff - let interval = 1000; - let prevInterval = 0; - let timeout: NodeJS.Timeout | undefined; - const onTimeout = () => { - logger?.info(`Plugin initialization in progress${getInitStatus()}`); - - const nextInterval = Math.min(interval + prevInterval, LOGGER_INTERVAL_MAX); - prevInterval = interval; - interval = nextInterval; - - timeout = setTimeout(onTimeout, nextInterval); - }; - timeout = setTimeout(onTimeout, interval); - - return { - onPluginStarted(pluginId: string) { - starting.delete(pluginId); - started.add(pluginId); - }, - onPluginFailed(pluginId: string, error: Error) { - 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}' threw an error during startup${status}.`, - error, - ); - }, - onPermittedPluginFailure(pluginId: string, error: Error) { - starting.delete(pluginId); - logger?.error( - `Plugin '${pluginId}' threw an error during startup, but boot failure is permitted for this plugin so startup will continue.`, - error, - ); - }, - onPluginModuleFailed(pluginId: string, moduleId: string, error: Error) { - const status = - starting.size > 0 - ? `, waiting for ${starting.size} other plugins to finish before shutting down the process` - : ''; - logger?.error( - `Module ${moduleId} in Plugin '${pluginId}' threw an error during startup${status}.`, - error, - ); - }, - onPermittedPluginModuleFailure( - pluginId: string, - moduleId: string, - error: Error, - ) { - logger?.error( - `Module ${moduleId} in Plugin '${pluginId}' threw an error during startup, but boot failure is permitted for this plugin module so startup will continue.`, - error, - ); - }, - onAllStarted() { - logger?.info(`Plugin initialization complete${getInitStatus()}`); - - if (timeout) { - clearTimeout(timeout); - timeout = undefined; - } - }, - }; -} diff --git a/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts b/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts new file mode 100644 index 0000000000..4dca92ef58 --- /dev/null +++ b/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts @@ -0,0 +1,178 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { RootLoggerService } from '@backstage/backend-plugin-api'; +import { AllowBootFailurePredicate } from './createAllowBootFailurePredicate'; +import { + BackendStartupResult, + PluginStartupResult, + ModuleStartupResult, +} from './types'; + +const LOGGER_INTERVAL_MAX = 60_000; + +function joinIds(ids: Iterable): string { + return [...ids].map(id => `'${id}'`).join(', '); +} + +export function createInitializationResultCollector(options: { + pluginIds: string[]; + logger?: RootLoggerService; + allowBootFailurePredicate: AllowBootFailurePredicate; +}): { + onPluginResult(pluginId: string, error?: Error): void; + onPluginModuleResult(pluginId: string, moduleId: string, error?: Error): void; + finalize(): BackendStartupResult; +} { + const logger = options.logger?.child({ type: 'initialization' }); + const beginAt = new Date(); + const starting = new Set(options.pluginIds); + const started = new Set(); + + let hasDisallowedFailures = false; + + const pluginResults: PluginStartupResult[] = []; + const moduleResultsByPlugin: Map = new Map( + Array.from(starting).map(pluginId => [pluginId, []]), + ); + + logger?.info(`Plugin initialization started: ${joinIds(starting)}`); + + const getInitStatus = () => { + let status = ''; + if (started.size > 0) { + status = `, newly initialized: ${joinIds(started)}`; + started.clear(); + } + if (starting.size > 0) { + status += `, still initializing: ${joinIds(starting)}`; + } + return status; + }; + + // Periodically log the initialization status with a fibonacci backoff + let interval = 1000; + let prevInterval = 0; + let timeout: NodeJS.Timeout | undefined; + const onTimeout = () => { + logger?.info(`Plugin initialization in progress${getInitStatus()}`); + + const nextInterval = Math.min(interval + prevInterval, LOGGER_INTERVAL_MAX); + prevInterval = interval; + interval = nextInterval; + + timeout = setTimeout(onTimeout, nextInterval); + }; + timeout = setTimeout(onTimeout, interval); + + return { + onPluginResult(pluginId: string, error?: Error) { + starting.delete(pluginId); + started.add(pluginId); + + const modules = moduleResultsByPlugin.get(pluginId); + if (!modules) { + throw new Error( + `Failed to push plugin result for nonexistent plugin '${pluginId}'`, + ); + } + + if (!error) { + pluginResults.push({ + pluginId, + resultAt: new Date(), + modules, + }); + } else { + const allowed = options.allowBootFailurePredicate(pluginId); + pluginResults.push({ + pluginId, + resultAt: new Date(), + modules, + failure: { + error, + allowed, + }, + }); + if (allowed) { + logger?.error( + `Plugin '${pluginId}' threw an error during startup, but boot failure is permitted for this plugin so startup will continue.`, + error, + ); + } else { + hasDisallowedFailures = true; + const status = + starting.size > 0 + ? `, waiting for ${starting.size} other plugins to finish before shutting down the process` + : ''; + logger?.error( + `Plugin '${pluginId}' threw an error during startup${status}.`, + error, + ); + } + } + }, + onPluginModuleResult(pluginId: string, moduleId: string, error?: Error) { + const moduleResults = moduleResultsByPlugin.get(pluginId); + if (!moduleResults) { + throw new Error( + `Failed to push module result for nonexistent plugin '${pluginId}'`, + ); + } + + if (!error) { + moduleResults.push({ moduleId, resultAt: new Date() }); + } else { + const allowed = options.allowBootFailurePredicate(pluginId, moduleId); + moduleResults.push({ + moduleId, + resultAt: new Date(), + failure: { error, allowed }, + }); + if (allowed) { + logger?.error( + `Module ${moduleId} in Plugin '${pluginId}' threw an error during startup, but boot failure is permitted for this plugin module so startup will continue.`, + error, + ); + } else { + hasDisallowedFailures = true; + const status = + starting.size > 0 + ? `, waiting for ${starting.size} other plugins to finish before shutting down the process` + : ''; + logger?.error( + `Module ${moduleId} in Plugin '${pluginId}' threw an error during startup${status}.`, + error, + ); + } + } + }, + finalize() { + logger?.info(`Plugin initialization complete${getInitStatus()}`); + + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + return { + beginAt, + resultAt: new Date(), + result: hasDisallowedFailures ? 'failure' : 'success', + plugins: pluginResults, + }; + }, + }; +} diff --git a/packages/backend-app-api/src/wiring/index.ts b/packages/backend-app-api/src/wiring/index.ts index 00e4a9d4b4..fa021a61a6 100644 --- a/packages/backend-app-api/src/wiring/index.ts +++ b/packages/backend-app-api/src/wiring/index.ts @@ -14,5 +14,11 @@ * limitations under the License. */ -export type { Backend, CreateSpecializedBackendOptions } from './types'; +export type { + Backend, + CreateSpecializedBackendOptions, + BackendStartupResult, + PluginStartupResult, + ModuleStartupResult, +} from './types'; export { createSpecializedBackend } from './createSpecializedBackend'; diff --git a/packages/backend-app-api/src/wiring/types.ts b/packages/backend-app-api/src/wiring/types.ts index bd761ff3c8..fcb092a8c9 100644 --- a/packages/backend-app-api/src/wiring/types.ts +++ b/packages/backend-app-api/src/wiring/types.ts @@ -26,7 +26,7 @@ import { */ export interface Backend { add(feature: BackendFeature | Promise<{ default: BackendFeature }>): void; - start(): Promise; + start(): Promise; stop(): Promise; } @@ -43,3 +43,88 @@ export interface CreateSpecializedBackendOptions { export type ServiceOrExtensionPoint = | ExtensionPoint | ServiceRef; + +/** + * Result of a module startup attempt. + * @public + */ +export interface ModuleStartupResult { + /** + * The time the module startup was completed. + */ + resultAt: Date; + + /** + * The ID of the module. + */ + moduleId: string; + + /** + * If the startup failed, this contains information about the failure. + */ + failure?: { + /** + * The error that occurred during startup, if any. + */ + error: Error; + /** + * Whether the failure was allowed. + */ + allowed: boolean; + }; +} + +/** + * Result of a plugin startup attempt. + * @public + */ +export interface PluginStartupResult { + /** + * The time the plugin startup was completed. + */ + resultAt: Date; + /** + * If the startup failed, this contains information about the failure. + */ + failure?: { + /** + * The error that occurred during startup, if any. + */ + error: Error; + /** + * Whether the failure was allowed. + */ + allowed: boolean; + }; + /** + * The ID of the plugin. + */ + pluginId: string; + /** + * Results for all modules belonging to this plugin. + */ + modules: ModuleStartupResult[]; +} + +/** + * Result of a backend startup attempt. + * @public + */ +export interface BackendStartupResult { + /** + * The time the backend startup started. + */ + beginAt: Date; + /** + * The time the backend startup was completed. + */ + resultAt: Date; + /** + * Results for all plugins that were attempted to start. + */ + plugins: PluginStartupResult[]; + /** + * The result of the backend startup. + */ + result: 'success' | 'failure'; +} diff --git a/packages/backend-defaults/src/entrypoints/permissionsRegistry/permissionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/permissionsRegistry/permissionsRegistryServiceFactory.test.ts index 578c76378a..083207d6aa 100644 --- a/packages/backend-defaults/src/entrypoints/permissionsRegistry/permissionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/permissionsRegistry/permissionsRegistryServiceFactory.test.ts @@ -49,9 +49,10 @@ describe('permissionsRegistryServiceFactory', () => { }), ], }), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Plugin 'test' startup failed; caused by Error: Resource type 'some-resource' belongs to plugin 'other', but was used with plugin 'test'"`, - ); + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Backend startup failed due to the following errors: + Plugin 'test' startup failed; caused by Error: Resource type 'some-resource' belongs to plugin 'other', but was used with plugin 'test'" + `); await expect( startTestBackend({ @@ -75,8 +76,9 @@ describe('permissionsRegistryServiceFactory', () => { }), ], }), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Plugin 'test' startup failed; caused by Error: Resource type 'some-resource' belongs to plugin 'other', but was used with plugin 'test'"`, - ); + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Backend startup failed due to the following errors: + Plugin 'test' startup failed; caused by Error: Resource type 'some-resource' belongs to plugin 'other', but was used with plugin 'test'" + `); }); });