backend-{plugin,app}-api: introduce startup hooks and parallelize initialization
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': patch
|
||||
---
|
||||
|
||||
Added startup hooks to the lifecycle services.
|
||||
+67
-13
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
+39
-3
@@ -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: () => {},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user