Add configuration to permit backend startup failure

Signed-off-by: Tim Hansen <timbonicush@spotify.com>
This commit is contained in:
Tim Hansen
2025-01-24 13:59:17 -07:00
parent 4de4602e94
commit 5622362b3e
7 changed files with 95 additions and 4 deletions
+17
View File
@@ -0,0 +1,17 @@
---
'@backstage/backend-app-api': patch
---
Added a configuration to permit backend plugin failures on startup:
```yaml
backend:
...
startup:
plugin-x:
optional: true
```
This configuration permits `plugin-x` to fail on startup. Omitting the `startup`
configuration matches the previous behavior, wherein any individual plugin
failure is fatal to backend startup.
+4
View File
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { BackendStartupOptions } from './src';
export interface Config {
backend?: {
/** Used by the feature discovery service */
@@ -23,5 +25,7 @@ export interface Config {
include?: string[];
exclude?: string[];
};
startup?: BackendStartupOptions;
};
}
@@ -560,6 +560,30 @@ describe('BackendInitializer', () => {
);
});
it('should permit startup errors for plugins marked as optional', async () => {
const init = new BackendInitializer([
mockServices.rootLifecycle.factory(),
mockServices.rootLogger.factory(),
mockServices.rootConfig.factory({
data: { backend: { startup: { test: { optional: true } } } },
}),
]);
init.add(
createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE');
},
});
},
}),
);
await init.start();
});
it('should forward errors when multiple plugins fail to start', async () => {
const init = new BackendInitializer([]);
init.add(
@@ -24,7 +24,7 @@ import {
RootLifecycleService,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { ServiceOrExtensionPoint } from './types';
import { BackendStartupOptions, ServiceOrExtensionPoint } from './types';
// Direct internal import to avoid duplication
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import type {
@@ -321,6 +321,21 @@ export class BackendInitializer {
await this.#serviceRegistry.get(coreServices.rootLogger, 'root'),
);
const rootConfig = await this.#serviceRegistry.get(
coreServices.rootConfig,
'root',
);
const startupOptions =
rootConfig
?.getOptionalConfig('backend.startup')
?.get<BackendStartupOptions>() ?? {};
// Gather backend pluginIds marked as optional, since these should not cause a startup failure
const optionalBackends = Object.entries(startupOptions)
.filter(([_, value]) => value?.optional)
.map(([key]) => key);
// All plugins are initialized in parallel
const results = await Promise.allSettled(
allPluginIds.map(async pluginId => {
@@ -392,8 +407,12 @@ export class BackendInitializer {
await lifecycleService.startup();
} catch (error: unknown) {
assertError(error);
initLogger.onPluginFailed(pluginId, error);
throw error;
if (optionalBackends.includes(pluginId)) {
initLogger.onOptionalPluginFailed(pluginId, error);
} else {
initLogger.onPluginFailed(pluginId, error);
throw error;
}
}
}),
);
@@ -28,6 +28,7 @@ export function createInitializationLogger(
): {
onPluginStarted(pluginId: string): void;
onPluginFailed(pluginId: string, error: Error): void;
onOptionalPluginFailed(pluginId: string, error: Error): void;
onAllStarted(): void;
} {
const logger = rootLogger?.child({ type: 'initialization' });
@@ -79,6 +80,13 @@ export function createInitializationLogger(
error,
);
},
onOptionalPluginFailed(pluginId: string, error: Error) {
starting.delete(pluginId);
logger?.error(
`Plugin '${pluginId}' threw an error during startup, but the plugin is marked optional in config so startup will continue.`,
error,
);
},
onAllStarted() {
logger?.info(`Plugin initialization complete${getInitStatus()}`);
+5 -1
View File
@@ -14,5 +14,9 @@
* limitations under the License.
*/
export type { Backend, CreateSpecializedBackendOptions } from './types';
export type {
Backend,
BackendStartupOptions,
CreateSpecializedBackendOptions,
} from './types';
export { createSpecializedBackend } from './createSpecializedBackend';
@@ -43,3 +43,18 @@ export interface CreateSpecializedBackendOptions {
export type ServiceOrExtensionPoint<T = unknown> =
| ExtensionPoint<T>
| ServiceRef<T>;
/**
* @public
*/
export type BackendStartupOptions = {
[pluginId: string]: {
/**
* Used to mark plugins as optional, which allows the backend to start up even in the event
* of a plugin failure. Plugin failures without this configuration are fatal. This can
* enable leaving a crashing plugin installed, but still permit backend startup, which may
* help troubleshoot data-dependent issues.
*/
optional?: boolean;
};
};