backend-app-api: refactor startup tracking and report results

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-11-18 19:17:28 +01:00
parent 595f99a1ea
commit d69df5a70e
9 changed files with 985 additions and 276 deletions
@@ -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',
);
});
});
});
@@ -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<ServiceOrExtensionPoint>;
@@ -148,7 +150,7 @@ function createRootInstanceMetadataServiceFactory(
}
export class BackendInitializer {
#startPromise?: Promise<void>;
#startPromise?: Promise<BackendStartupResult>;
#stopPromise?: Promise<void>;
#registrations = new Array<InternalBackendRegistrations>();
#extensionPoints = new Map<string, { impl: unknown; pluginId: string }>();
@@ -224,7 +226,7 @@ export class BackendInitializer {
}
}
async start(): Promise<void> {
async start(): Promise<BackendStartupResult> {
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<void> {
async #doStart(): Promise<BackendStartupResult> {
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(
@@ -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;
}
}
@@ -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<void> {
await this.#initializer.start();
async start(): Promise<BackendStartupResult> {
return await this.#initializer.start();
}
async stop(): Promise<void> {
@@ -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>): 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<string>();
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;
}
},
};
}
@@ -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>): 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<string>();
let hasDisallowedFailures = false;
const pluginResults: PluginStartupResult[] = [];
const moduleResultsByPlugin: Map<string, ModuleStartupResult[]> = 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,
};
},
};
}
+7 -1
View File
@@ -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';
+86 -1
View File
@@ -26,7 +26,7 @@ import {
*/
export interface Backend {
add(feature: BackendFeature | Promise<{ default: BackendFeature }>): void;
start(): Promise<void>;
start(): Promise<BackendStartupResult>;
stop(): Promise<void>;
}
@@ -43,3 +43,88 @@ export interface CreateSpecializedBackendOptions {
export type ServiceOrExtensionPoint<T = unknown> =
| ExtensionPoint<T>
| ServiceRef<T>;
/**
* 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';
}