diff --git a/.changeset/ninety-turkeys-knock.md b/.changeset/ninety-turkeys-knock.md new file mode 100644 index 0000000000..7f26f0eeda --- /dev/null +++ b/.changeset/ninety-turkeys-knock.md @@ -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. diff --git a/.changeset/olive-carpets-explode.md b/.changeset/olive-carpets-explode.md new file mode 100644 index 0000000000..6741355d58 --- /dev/null +++ b/.changeset/olive-carpets-explode.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Added startup hooks to the lifecycle services. diff --git a/packages/backend-app-api/src/services/implementations/lifecycle/lifecycleServiceFactory.ts b/packages/backend-app-api/src/services/implementations/lifecycle/lifecycleServiceFactory.ts index 79658c0d42..c0cfcf7d8c 100644 --- a/packages/backend-app-api/src/services/implementations/lifecycle/lifecycleServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/lifecycle/lifecycleServiceFactory.ts @@ -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 { + 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, + ); }, }); diff --git a/packages/backend-app-api/src/services/implementations/rootLifecycle/rootLifecycleServiceFactory.ts b/packages/backend-app-api/src/services/implementations/rootLifecycle/rootLifecycleServiceFactory.ts index f379c2ec52..045c5e8267 100644 --- a/packages/backend-app-api/src/services/implementations/rootLifecycle/rootLifecycleServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/rootLifecycle/rootLifecycleServiceFactory.ts @@ -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 { + 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 { - 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( diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index b2c62c2122..a6d0e74866 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -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; #features = new Array(); - #registerInits = new Array(); + #pluginInits = new Array(); + #moduleInits = new Map>(); #extensionPoints = new Map, 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>(); @@ -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) { - let registerInitsToOrder = registerInits.slice(); - const orderedRegisterInits = new Array(); - - // TODO: Validate duplicates - - while (registerInitsToOrder.length > 0) { - const toRemove = new Set(); - - 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 { 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 { 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 { + const lifecycleService = await this.#serviceHolder.get( + coreServices.lifecycle, + pluginId, + ); + if (lifecycleService instanceof BackendPluginLifecycleImpl) { + return lifecycleService; + } + throw new Error('Unexpected plugin lifecycle service implementation'); } } diff --git a/packages/backend-app-api/src/wiring/createSpecializedBackend.test.ts b/packages/backend-app-api/src/wiring/createSpecializedBackend.test.ts index ad50455e5f..dc9a0cc085 100644 --- a/packages/backend-app-api/src/wiring/createSpecializedBackend.test.ts +++ b/packages/backend-app-api/src/wiring/createSpecializedBackend.test.ts @@ -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: () => {}, + }), }), ], }), diff --git a/packages/backend-defaults/src/CreateBackend.test.ts b/packages/backend-defaults/src/CreateBackend.test.ts index 8d56cd0986..946de90b2b 100644 --- a/packages/backend-defaults/src/CreateBackend.test.ts +++ b/packages/backend-defaults/src/CreateBackend.test.ts @@ -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: () => {}, + }), }), ], }), diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 7dc6ae97b3..b3cbef993c 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -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; + +// @public (undocumented) +export interface LifecycleServiceStartupOptions { + logger?: LoggerService; +} + // @public export interface LoggerService { // (undocumented) diff --git a/packages/backend-plugin-api/src/services/definitions/LifecycleService.ts b/packages/backend-plugin-api/src/services/definitions/LifecycleService.ts index 89351194e6..e0641e0457 100644 --- a/packages/backend-plugin-api/src/services/definitions/LifecycleService.ts +++ b/packages/backend-plugin-api/src/services/definitions/LifecycleService.ts @@ -16,6 +16,21 @@ import { LoggerService } from './LoggerService'; +/** + * @public + */ +export type LifecycleServiceStartupHook = () => void | Promise; + +/** + * @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. */ diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 0122bc0be8..ab0f0b84b1 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -26,6 +26,8 @@ export type { DiscoveryService } from './DiscoveryService'; export type { HttpRouterService } from './HttpRouterService'; export type { LifecycleService, + LifecycleServiceStartupHook, + LifecycleServiceStartupOptions, LifecycleServiceShutdownHook, LifecycleServiceShutdownOptions, } from './LifecycleService';