backend-{plugin,app}-api: introduce startup hooks and parallelize initialization

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-05-24 15:55:26 +02:00
parent c4e8fefd9f
commit 3bb4158a8a
10 changed files with 256 additions and 76 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Switched startup strategy to initialize all plugins in parallel, as well as hook into the new startup lifecycle hooks.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
Added startup hooks to the lifecycle services.
@@ -14,12 +14,72 @@
* limitations under the License.
*/
import {
createServiceFactory,
coreServices,
LifecycleService,
LifecycleServiceShutdownHook,
LifecycleServiceShutdownOptions,
LifecycleServiceStartupHook,
LifecycleServiceStartupOptions,
LoggerService,
PluginMetadataService,
RootLifecycleService,
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
/** @internal */
export class BackendPluginLifecycleImpl implements LifecycleService {
constructor(
private readonly logger: LoggerService,
private readonly rootLifecycle: RootLifecycleService,
private readonly pluginMetadata: PluginMetadataService,
) {}
#hasStarted = false;
#startupTasks: Array<{
hook: LifecycleServiceStartupHook;
options?: LifecycleServiceStartupOptions;
}> = [];
addStartupHook(
hook: LifecycleServiceStartupHook,
options?: LifecycleServiceStartupOptions,
): void {
this.#startupTasks.push({ hook, options });
}
async startup(): Promise<void> {
if (this.#hasStarted) {
return;
}
this.#hasStarted = true;
this.logger.debug(
`Running ${this.#startupTasks.length} plugin startup tasks...`,
);
await Promise.all(
this.#startupTasks.map(async ({ hook, options }) => {
const logger = options?.logger ?? this.logger;
try {
await hook();
logger.debug(`Plugin startup hook succeeded`);
} catch (error) {
logger.error(`Plugin startup hook failed, ${error}`);
}
}),
);
}
addShutdownHook(
hook: LifecycleServiceShutdownHook,
options?: LifecycleServiceShutdownOptions,
): void {
const plugin = this.pluginMetadata.getId();
this.rootLifecycle.addShutdownHook(hook, {
logger: options?.logger?.child({ plugin }) ?? this.logger,
});
}
}
/**
* Allows plugins to register shutdown hooks that are run when the process is about to exit.
* @public
@@ -32,16 +92,10 @@ export const lifecycleServiceFactory = createServiceFactory({
pluginMetadata: coreServices.pluginMetadata,
},
async factory({ rootLifecycle, logger, pluginMetadata }) {
const plugin = pluginMetadata.getId();
return {
addShutdownHook(
hook: LifecycleServiceShutdownHook,
options?: LifecycleServiceShutdownOptions,
): void {
rootLifecycle.addShutdownHook(hook, {
logger: options?.logger?.child({ plugin }) ?? logger,
});
},
};
return new BackendPluginLifecycleImpl(
logger,
rootLifecycle,
pluginMetadata,
);
},
});
@@ -17,16 +17,52 @@
import {
createServiceFactory,
coreServices,
LifecycleServiceStartupHook,
LifecycleServiceStartupOptions,
LifecycleServiceShutdownHook,
LifecycleServiceShutdownOptions,
RootLifecycleService,
LoggerService,
} from '@backstage/backend-plugin-api';
/** @internal */
export class BackendLifecycleImpl implements RootLifecycleService {
constructor(private readonly logger: LoggerService) {}
#isCalled = false;
#hasStarted = false;
#startupTasks: Array<{
hook: LifecycleServiceStartupHook;
options?: LifecycleServiceStartupOptions;
}> = [];
addStartupHook(
hook: LifecycleServiceStartupHook,
options?: LifecycleServiceStartupOptions,
): void {
this.#startupTasks.push({ hook, options });
}
async startup(): Promise<void> {
if (this.#hasStarted) {
return;
}
this.#hasStarted = true;
this.logger.info(`Running ${this.#startupTasks.length} startup tasks...`);
await Promise.all(
this.#startupTasks.map(async ({ hook, options }) => {
const logger = options?.logger ?? this.logger;
try {
await hook();
logger.info(`Startup hook succeeded`);
} catch (error) {
logger.error(`Startup hook failed, ${error}`);
}
}),
);
}
#hasShutdown = false;
#shutdownTasks: Array<{
hook: LifecycleServiceShutdownHook;
options?: LifecycleServiceShutdownOptions;
@@ -40,10 +76,10 @@ export class BackendLifecycleImpl implements RootLifecycleService {
}
async shutdown(): Promise<void> {
if (this.#isCalled) {
if (this.#hasShutdown) {
return;
}
this.#isCalled = true;
this.#hasShutdown = true;
this.logger.info(`Running ${this.#shutdownTasks.length} shutdown tasks...`);
await Promise.all(
@@ -21,6 +21,7 @@ import {
ServiceRef,
} from '@backstage/backend-plugin-api';
import { BackendLifecycleImpl } from '../services/implementations/rootLifecycle/rootLifecycleServiceFactory';
import { BackendPluginLifecycleImpl } from '../services/implementations/lifecycle/lifecycleServiceFactory';
import {
BackendRegisterInit,
EnumerableServiceHolder,
@@ -33,7 +34,8 @@ import { InternalBackendFeature } from '@backstage/backend-plugin-api/src/wiring
export class BackendInitializer {
#startPromise?: Promise<void>;
#features = new Array<InternalBackendFeature>();
#registerInits = new Array<BackendRegisterInit>();
#pluginInits = new Array<BackendRegisterInit>();
#moduleInits = new Map<string, Array<BackendRegisterInit>>();
#extensionPoints = new Map<ExtensionPoint<unknown>, unknown>();
#serviceHolder: EnumerableServiceHolder;
@@ -130,7 +132,7 @@ export class BackendInitializer {
}
}
// Initialize all features
// Enumerate all features
for (const feature of this.#features) {
for (const r of feature.getRegistrations()) {
const provides = new Set<ExtensionPoint<unknown>>();
@@ -147,24 +149,62 @@ export class BackendInitializer {
}
}
this.#registerInits.push({
id: r.type === 'plugin' ? r.pluginId : `${r.pluginId}.${r.moduleId}`,
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
if (r.type === 'plugin') {
this.#pluginInits.push({
id: r.pluginId,
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else {
let modules = this.#moduleInits.get(r.pluginId);
if (!modules) {
modules = [];
this.#moduleInits.set(r.pluginId, modules);
}
modules.push({
id: `${r.pluginId}.${r.moduleId}`,
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
}
}
}
const orderedRegisterResults = this.#resolveInitOrder(this.#registerInits);
// All plugins are initialized in parallel
await Promise.all(
this.#pluginInits.map(async pluginInit => {
// Modules are initialized before plugins, so that they can provide extension to the plugin
const modules = this.#moduleInits.get(pluginInit.id) ?? [];
await Promise.all(
modules.map(async moduleInit => {
const moduleDeps = await this.#getInitDeps(
moduleInit.init.deps,
moduleInit.id,
);
await moduleInit.init.func(moduleDeps);
}),
);
for (const registerInit of orderedRegisterResults) {
const deps = await this.#getInitDeps(
registerInit.init.deps,
registerInit.id,
);
await registerInit.init.func(deps);
}
// Once all modules have been initialized, we can initialize the plugin itself
const pluginDeps = await this.#getInitDeps(
pluginInit.init.deps,
pluginInit.id,
);
await pluginInit.init.func(pluginDeps);
// Once the plugin and all modules have been initialized, we can signal that the plugin has stared up successfully
const lifecycleService = await this.#getPluginLifecycleImpl(
pluginInit.id,
);
await lifecycleService.startup();
}),
);
// 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();
// Once the backend is started, any uncaught errors or unhandled rejections are caught
// and logged, in order to avoid crashing the entire backend on local failures.
@@ -186,56 +226,38 @@ export class BackendInitializer {
}
}
#resolveInitOrder(registerInits: Array<BackendRegisterInit>) {
let registerInitsToOrder = registerInits.slice();
const orderedRegisterInits = new Array<BackendRegisterInit>();
// TODO: Validate duplicates
while (registerInitsToOrder.length > 0) {
const toRemove = new Set<unknown>();
for (const registerInit of registerInitsToOrder) {
const unInitializedDependents = [];
for (const provided of registerInit.provides) {
if (
registerInitsToOrder.some(
init => init !== registerInit && init.consumes.has(provided),
)
) {
unInitializedDependents.push(provided);
}
}
if (unInitializedDependents.length === 0) {
orderedRegisterInits.push(registerInit);
toRemove.add(registerInit);
}
}
registerInitsToOrder = registerInitsToOrder.filter(r => !toRemove.has(r));
}
return orderedRegisterInits;
}
async stop(): Promise<void> {
if (!this.#startPromise) {
return;
}
await this.#startPromise;
const lifecycleService = await this.#getRootLifecycleImpl();
await lifecycleService.shutdown();
}
// Bit of a hacky way to grab the lifecycle services, potentially find a nicer way to do this
async #getRootLifecycleImpl(): Promise<BackendLifecycleImpl> {
const lifecycleService = await this.#serviceHolder.get(
coreServices.rootLifecycle,
'root',
);
// TODO(Rugvip): Find a better way to do this
if (lifecycleService instanceof BackendLifecycleImpl) {
await lifecycleService.shutdown();
} else {
throw new Error('Unexpected lifecycle service implementation');
return lifecycleService;
}
throw new Error('Unexpected root lifecycle service implementation');
}
async #getPluginLifecycleImpl(
pluginId: string,
): Promise<BackendPluginLifecycleImpl> {
const lifecycleService = await this.#serviceHolder.get(
coreServices.lifecycle,
pluginId,
);
if (lifecycleService instanceof BackendPluginLifecycleImpl) {
return lifecycleService;
}
throw new Error('Unexpected plugin lifecycle service implementation');
}
}
@@ -32,12 +32,18 @@ describe('createSpecializedBackend', () => {
createServiceFactory({
service: coreServices.rootLifecycle,
deps: {},
factory: async () => ({ addShutdownHook: () => {} }),
factory: async () => ({
addStartupHook: () => {},
addShutdownHook: () => {},
}),
}),
createServiceFactory({
service: coreServices.rootLifecycle,
deps: {},
factory: async () => ({ addShutdownHook: () => {} }),
factory: async () => ({
addStartupHook: () => {},
addShutdownHook: () => {},
}),
}),
],
}),
@@ -35,7 +35,10 @@ describe('createBackend', () => {
createServiceFactory({
service: coreServices.rootLifecycle,
deps: {},
factory: async () => ({ addShutdownHook: () => {} }),
factory: async () => ({
addStartupHook: () => {},
addShutdownHook: () => {},
}),
}),
],
}),
@@ -49,12 +52,18 @@ describe('createBackend', () => {
createServiceFactory({
service: coreServices.rootLifecycle,
deps: {},
factory: async () => ({ addShutdownHook: () => {} }),
factory: async () => ({
addStartupHook: () => {},
addShutdownHook: () => {},
}),
}),
createServiceFactory({
service: coreServices.rootLifecycle,
deps: {},
factory: async () => ({ addShutdownHook: () => {} }),
factory: async () => ({
addStartupHook: () => {},
addShutdownHook: () => {},
}),
}),
],
}),
+12
View File
@@ -273,6 +273,10 @@ export interface LifecycleService {
hook: LifecycleServiceShutdownHook,
options?: LifecycleServiceShutdownOptions,
): void;
addStartupHook(
hook: LifecycleServiceStartupHook,
options?: LifecycleServiceStartupOptions,
): void;
}
// @public (undocumented)
@@ -283,6 +287,14 @@ export interface LifecycleServiceShutdownOptions {
logger?: LoggerService;
}
// @public (undocumented)
export type LifecycleServiceStartupHook = () => void | Promise<void>;
// @public (undocumented)
export interface LifecycleServiceStartupOptions {
logger?: LoggerService;
}
// @public
export interface LoggerService {
// (undocumented)
@@ -16,6 +16,21 @@
import { LoggerService } from './LoggerService';
/**
* @public
*/
export type LifecycleServiceStartupHook = () => void | Promise<void>;
/**
* @public
*/
export interface LifecycleServiceStartupOptions {
/**
* Optional {@link LoggerService} that will be used for logging instead of the default logger.
*/
logger?: LoggerService;
}
/**
* @public
*/
@@ -35,6 +50,20 @@ export interface LifecycleServiceShutdownOptions {
* @public
*/
export interface LifecycleService {
/**
* Register a function to be called when the backend has been initialized.
*
* @remarks
*
* When used with plugin scope it will wait for the plugin itself to have been initialized.
*
* When used with root scope it will wait for all plugins to have been initialized.
*/
addStartupHook(
hook: LifecycleServiceStartupHook,
options?: LifecycleServiceStartupOptions,
): void;
/**
* Register a function to be called when the backend is shutting down.
*/
@@ -26,6 +26,8 @@ export type { DiscoveryService } from './DiscoveryService';
export type { HttpRouterService } from './HttpRouterService';
export type {
LifecycleService,
LifecycleServiceStartupHook,
LifecycleServiceStartupOptions,
LifecycleServiceShutdownHook,
LifecycleServiceShutdownOptions,
} from './LifecycleService';