feat(backend-app-api): add config to permit module failures on startup to not effect the plugin from starting up

Signed-off-by: Avery, Mark <mark@webark.cc>
This commit is contained in:
Avery, Mark
2025-03-22 13:57:29 -07:00
parent 36ded672c8
commit 729a7d6e76
5 changed files with 259 additions and 17 deletions
+35
View File
@@ -0,0 +1,35 @@
---
'@backstage/backend-app-api': patch
---
Added a configuration to permit backend plugin module failures on startup:
```yaml
backend:
...
startup:
plugins:
plugin-x:
modules:
module-y:
onPluginModuleBootFailure: continue
```
This configuration permits `plugin-x` with `module-y` to fail on startup. Omitting the
`onPluginModuleBootFailure` configuration matches the previous behavior, wherein any
individual plugin module failure is forwarded to the plugin and aborts backend startup.
The default can also be changed, so that continuing on failure is the default
unless otherwise specified:
```yaml
backend:
startup:
default:
onPluginModuleBootFailure: continue
plugins:
catalog:
modules:
github:
onPluginModuleBootFailure: abort
```
+21
View File
@@ -34,6 +34,14 @@ export interface Config {
* `onPluginBootFailure: abort` to be required.
*/
onPluginBootFailure?: 'continue' | 'abort';
/**
* The default value for `onPluginModuleBootFailure` if not specified for a particular plugin module.
* This defaults to 'abort', which means `onPluginModuleBootFailure: continue` must be specified
* for backend startup to continue on plugin module boot failure. This can also be set to
* 'continue', which flips the logic for individual plugin modules so that they must be set to
* `onPluginModuleBootFailure: abort` to be required.
*/
onPluginModuleBootFailure?: 'continue' | 'abort';
};
plugins?: {
[pluginId: string]: {
@@ -46,6 +54,19 @@ export interface Config {
* setting).
*/
onPluginBootFailure?: 'continue' | 'abort';
modules?: {
[moduleId: string]: {
/**
* Used to control backend startup behavior when this plugin module fails to boot up. Setting
* this to `continue` allows the backend to continue starting up, even if this plugin
* module fails. This can enable leaving a crashing plugin installed, but still permit backend
* startup, which may help troubleshoot data-dependent issues. Plugin module failures for plugin modules
* set to `abort` are fatal (this is the default unless overridden by the `default`
* setting).
*/
onPluginModuleBootFailure?: 'continue' | 'abort';
};
};
};
};
};
@@ -541,7 +541,7 @@ describe('BackendInitializer', () => {
});
it('should forward errors when plugins fail to start', async () => {
const init = new BackendInitializer([]);
const init = new BackendInitializer(baseFactories);
init.add(
createBackendPlugin({
pluginId: 'test',
@@ -562,8 +562,7 @@ describe('BackendInitializer', () => {
it('should permit startup errors for plugins with onPluginBootFailure: continue', async () => {
const init = new BackendInitializer([
mockServices.rootLifecycle.factory(),
mockServices.rootLogger.factory(),
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
@@ -590,8 +589,7 @@ describe('BackendInitializer', () => {
it('should permit startup errors if the default onPluginBootFailure is continue', async () => {
const init = new BackendInitializer([
mockServices.rootLifecycle.factory(),
mockServices.rootLogger.factory(),
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
@@ -618,8 +616,7 @@ describe('BackendInitializer', () => {
it('should forward errors for plugins explicitly marked to abort when the default is continue', async () => {
const init = new BackendInitializer([
mockServices.rootLifecycle.factory(),
mockServices.rootLogger.factory(),
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
@@ -649,6 +646,130 @@ describe('BackendInitializer', () => {
);
});
it('should forward errors when plugin modules fail to start', async () => {
const init = new BackendInitializer(baseFactories);
init.add(testPlugin);
init.add(
createBackendModule({
pluginId: 'test',
moduleId: 'mod',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE');
},
});
},
}),
);
await expect(init.start()).rejects.toThrow(
"Module 'mod' for plugin 'test' startup failed; caused by Error: NOPE",
);
});
it('should permit startup errors for plugin modules with onPluginModuleBootFailure: continue', async () => {
const init = new BackendInitializer([
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
startup: {
plugins: {
test: {
modules: { mod: { onPluginModuleBootFailure: 'continue' } },
},
},
},
},
},
}),
]);
init.add(testPlugin);
init.add(
createBackendModule({
pluginId: 'test',
moduleId: 'mod',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE');
},
});
},
}),
);
await expect(init.start()).resolves.not.toThrow();
});
it('should permit startup errors if the default onPluginModuleBootFailure is continue', async () => {
const init = new BackendInitializer([
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
startup: { default: { onPluginModuleBootFailure: 'continue' } },
},
},
}),
]);
init.add(testPlugin);
init.add(
createBackendModule({
pluginId: 'test',
moduleId: 'mod',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE');
},
});
},
}),
);
await expect(init.start()).resolves.not.toThrow();
});
it('should forward errors for plugin modules explicitly marked to abort when the default is continue', async () => {
const init = new BackendInitializer([
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
startup: {
default: { onPluginModuleBootFailure: 'continue' },
plugins: {
test: {
modules: { mod: { onPluginModuleBootFailure: 'abort' } },
},
},
},
},
},
}),
]);
init.add(testPlugin);
init.add(
createBackendModule({
pluginId: 'test',
moduleId: 'mod',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE');
},
});
},
}),
);
await expect(init.start()).rejects.toThrow(
"Module 'mod' for plugin 'test' startup failed; caused by Error: NOPE",
);
});
it('should forward errors when multiple plugins fail to start', async () => {
const init = new BackendInitializer([]);
init.add(
@@ -365,17 +365,38 @@ export class BackendInitializer {
}
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,
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,
);
});
} catch (error: unknown) {
assertError(error);
if (isModuleBootFailurePermitted) {
initLogger.onPermittedPluginModuleFailure(
pluginId,
moduleId,
error,
);
} else {
initLogger.onPluginModuleFailed(pluginId, moduleId, error);
throw error;
}
}
},
);
}
@@ -649,6 +670,24 @@ export class BackendInitializer {
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(
@@ -29,6 +29,12 @@ export function createInitializationLogger(
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' });
@@ -87,6 +93,26 @@ export function createInitializationLogger(
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()}`);