refactor(core): Add shutdown method to modules (#17322)

This commit is contained in:
Iván Ovejero
2025-07-16 12:36:11 +02:00
committed by GitHub
parent a417159602
commit 320a810b56
13 changed files with 68 additions and 35 deletions

View File

@@ -113,6 +113,22 @@ export class ModuleRegistry {
} }
} }
async shutdownModule(moduleName: ModuleName) {
const moduleEntry = this.moduleMetadata.get(moduleName);
if (!moduleEntry) {
this.logger.debug('Skipping shutdown for unregistered module', { moduleName });
return;
}
await Container.get(moduleEntry.class).shutdown?.();
const index = this.activeModules.indexOf(moduleName);
if (index > -1) this.activeModules.splice(index, 1);
this.logger.debug(`Shut down module "${moduleName}"`);
}
isActive(moduleName: ModuleName) { isActive(moduleName: ModuleName) {
return this.activeModules.includes(moduleName); return this.activeModules.includes(moduleName);
} }

View File

@@ -15,6 +15,10 @@ export class ModuleMetadata {
this.modules.set(moduleName, moduleEntry); this.modules.set(moduleName, moduleEntry);
} }
get(moduleName: string) {
return this.modules.get(moduleName);
}
getEntries() { getEntries() {
return [...this.modules.entries()]; return [...this.modules.entries()];
} }

View File

@@ -22,6 +22,7 @@ export type ModuleSettings = Record<string, unknown>;
export interface ModuleInterface { export interface ModuleInterface {
init?(): Promise<void>; init?(): Promise<void>;
shutdown?(): Promise<void>;
entities?(): Promise<EntityClass[]>; entities?(): Promise<EntityClass[]>;
settings?(): Promise<ModuleSettings>; settings?(): Promise<ModuleSettings>;
} }

View File

@@ -1,15 +1,15 @@
import { Container, Service } from '@n8n/di'; import { Container, Service } from '@n8n/di';
import { OnShutdown } from '../on-shutdown'; import { OnShutdown } from '../on-shutdown';
import { ShutdownRegistryMetadata } from '../shutdown-registry-metadata'; import { ShutdownMetadata } from '../shutdown-metadata';
describe('OnShutdown', () => { describe('OnShutdown', () => {
let shutdownRegistryMetadata: ShutdownRegistryMetadata; let shutdownMetadata: ShutdownMetadata;
beforeEach(() => { beforeEach(() => {
shutdownRegistryMetadata = new ShutdownRegistryMetadata(); shutdownMetadata = new ShutdownMetadata();
Container.set(ShutdownRegistryMetadata, shutdownRegistryMetadata); Container.set(ShutdownMetadata, shutdownMetadata);
jest.spyOn(shutdownRegistryMetadata, 'register'); jest.spyOn(shutdownMetadata, 'register');
}); });
it('should register a methods that is decorated with OnShutdown', () => { it('should register a methods that is decorated with OnShutdown', () => {
@@ -19,8 +19,8 @@ describe('OnShutdown', () => {
async onShutdown() {} async onShutdown() {}
} }
expect(shutdownRegistryMetadata.register).toHaveBeenCalledTimes(1); expect(shutdownMetadata.register).toHaveBeenCalledTimes(1);
expect(shutdownRegistryMetadata.register).toHaveBeenCalledWith(100, { expect(shutdownMetadata.register).toHaveBeenCalledWith(100, {
methodName: 'onShutdown', methodName: 'onShutdown',
serviceClass: TestClass, serviceClass: TestClass,
}); });
@@ -36,12 +36,12 @@ describe('OnShutdown', () => {
async two() {} async two() {}
} }
expect(shutdownRegistryMetadata.register).toHaveBeenCalledTimes(2); expect(shutdownMetadata.register).toHaveBeenCalledTimes(2);
expect(shutdownRegistryMetadata.register).toHaveBeenCalledWith(100, { expect(shutdownMetadata.register).toHaveBeenCalledWith(100, {
methodName: 'one', methodName: 'one',
serviceClass: TestClass, serviceClass: TestClass,
}); });
expect(shutdownRegistryMetadata.register).toHaveBeenCalledWith(100, { expect(shutdownMetadata.register).toHaveBeenCalledWith(100, {
methodName: 'two', methodName: 'two',
serviceClass: TestClass, serviceClass: TestClass,
}); });
@@ -56,9 +56,9 @@ describe('OnShutdown', () => {
} }
} }
expect(shutdownRegistryMetadata.register).toHaveBeenCalledTimes(1); expect(shutdownMetadata.register).toHaveBeenCalledTimes(1);
// @ts-expect-error We are checking internal parts of the shutdown service // @ts-expect-error We are checking internal parts of the shutdown service
expect(shutdownRegistryMetadata.handlersByPriority[10].length).toEqual(1); expect(shutdownMetadata.handlersByPriority[10].length).toEqual(1);
}); });
it('should throw an error if the decorated member is not a function', () => { it('should throw an error if the decorated member is not a function', () => {

View File

@@ -3,6 +3,6 @@ export {
DEFAULT_SHUTDOWN_PRIORITY, DEFAULT_SHUTDOWN_PRIORITY,
LOWEST_SHUTDOWN_PRIORITY, LOWEST_SHUTDOWN_PRIORITY,
} from './constants'; } from './constants';
export { ShutdownRegistryMetadata } from './shutdown-registry-metadata'; export { ShutdownMetadata } from './shutdown-metadata';
export { OnShutdown } from './on-shutdown'; export { OnShutdown } from './on-shutdown';
export type { ShutdownHandler, ShutdownServiceClass } from './types'; export type { ShutdownHandler, ShutdownServiceClass } from './types';

View File

@@ -2,7 +2,7 @@ import { Container } from '@n8n/di';
import { UnexpectedError } from 'n8n-workflow'; import { UnexpectedError } from 'n8n-workflow';
import { DEFAULT_SHUTDOWN_PRIORITY } from './constants'; import { DEFAULT_SHUTDOWN_PRIORITY } from './constants';
import { ShutdownRegistryMetadata } from './shutdown-registry-metadata'; import { ShutdownMetadata } from './shutdown-metadata';
import type { ShutdownServiceClass } from './types'; import type { ShutdownServiceClass } from './types';
/** /**
@@ -31,7 +31,7 @@ export const OnShutdown =
const methodName = String(propertyKey); const methodName = String(propertyKey);
// TODO: assert that serviceClass is decorated with @Service // TODO: assert that serviceClass is decorated with @Service
if (typeof descriptor?.value === 'function') { if (typeof descriptor?.value === 'function') {
Container.get(ShutdownRegistryMetadata).register(priority, { serviceClass, methodName }); Container.get(ShutdownMetadata).register(priority, { serviceClass, methodName });
} else { } else {
const name = `${serviceClass.name}.${methodName}()`; const name = `${serviceClass.name}.${methodName}()`;
throw new UnexpectedError( throw new UnexpectedError(

View File

@@ -5,7 +5,7 @@ import { HIGHEST_SHUTDOWN_PRIORITY, LOWEST_SHUTDOWN_PRIORITY } from './constants
import type { ShutdownHandler } from './types'; import type { ShutdownHandler } from './types';
@Service() @Service()
export class ShutdownRegistryMetadata { export class ShutdownMetadata {
private handlersByPriority: ShutdownHandler[][] = []; private handlersByPriority: ShutdownHandler[][] = [];
register(priority: number, handler: ShutdownHandler) { register(priority: number, handler: ShutdownHandler) {

View File

@@ -1,14 +1,10 @@
import { Logger } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common';
import { SettingsRepository } from '@n8n/db'; import { SettingsRepository } from '@n8n/db';
import { OnPubSubEvent, OnShutdown } from '@n8n/decorators'; import { OnPubSubEvent } from '@n8n/decorators';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { Cipher, type IExternalSecretsManager } from 'n8n-core'; import { Cipher, type IExternalSecretsManager } from 'n8n-core';
import { jsonParse, type IDataObject, ensureError, UnexpectedError } from 'n8n-workflow'; import { jsonParse, type IDataObject, ensureError, UnexpectedError } from 'n8n-workflow';
import { EventService } from '@/events/event.service';
import { License } from '@/license';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { import {
EXTERNAL_SECRETS_DB_KEY, EXTERNAL_SECRETS_DB_KEY,
EXTERNAL_SECRETS_INITIAL_BACKOFF, EXTERNAL_SECRETS_INITIAL_BACKOFF,
@@ -18,6 +14,10 @@ import { ExternalSecretsProviders } from './external-secrets-providers.ee';
import { ExternalSecretsConfig } from './external-secrets.config'; import { ExternalSecretsConfig } from './external-secrets.config';
import type { ExternalSecretsSettings, SecretsProvider, SecretsProviderSettings } from './types'; import type { ExternalSecretsSettings, SecretsProvider, SecretsProviderSettings } from './types';
import { EventService } from '@/events/event.service';
import { License } from '@/license';
import { Publisher } from '@/scaling/pubsub/publisher.service';
@Service() @Service()
export class ExternalSecretsManager implements IExternalSecretsManager { export class ExternalSecretsManager implements IExternalSecretsManager {
private providers: Record<string, SecretsProvider> = {}; private providers: Record<string, SecretsProvider> = {};
@@ -65,7 +65,6 @@ export class ExternalSecretsManager implements IExternalSecretsManager {
this.logger.debug('External secrets manager initialized'); this.logger.debug('External secrets manager initialized');
} }
@OnShutdown()
shutdown() { shutdown() {
clearInterval(this.updateInterval); clearInterval(this.updateInterval);
Object.values(this.providers).forEach((p) => { Object.values(this.providers).forEach((p) => {

View File

@@ -1,5 +1,5 @@
import type { ModuleInterface } from '@n8n/decorators'; import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators'; import { BackendModule, OnShutdown } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
@BackendModule({ name: 'external-secrets', licenseFlag: 'feat:externalSecrets' }) @BackendModule({ name: 'external-secrets', licenseFlag: 'feat:externalSecrets' })
@@ -16,4 +16,11 @@ export class ExternalSecretsModule implements ModuleInterface {
await externalSecretsManager.init(); await externalSecretsManager.init();
externalSecretsProxy.setManager(externalSecretsManager); externalSecretsProxy.setManager(externalSecretsManager);
} }
@OnShutdown()
async shutdown() {
const { ExternalSecretsManager } = await import('./external-secrets-manager.ee');
Container.get(ExternalSecretsManager).shutdown();
}
} }

View File

@@ -1,5 +1,5 @@
import type { ModuleInterface } from '@n8n/decorators'; import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators'; import { BackendModule, OnShutdown } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { InstanceSettings } from 'n8n-core'; import { InstanceSettings } from 'n8n-core';
@@ -31,4 +31,11 @@ export class InsightsModule implements ModuleInterface {
return Container.get(InsightsService).settings(); return Container.get(InsightsService).settings();
} }
@OnShutdown()
async shutdown() {
const { InsightsService } = await import('./insights.service');
await Container.get(InsightsService).shutdown();
}
} }

View File

@@ -1,6 +1,6 @@
import { type InsightsSummary, type InsightsDateRange } from '@n8n/api-types'; import { type InsightsSummary, type InsightsDateRange } from '@n8n/api-types';
import { LicenseState, Logger } from '@n8n/backend-common'; import { LicenseState, Logger } from '@n8n/backend-common';
import { OnLeaderStepdown, OnLeaderTakeover, OnShutdown } from '@n8n/decorators'; import { OnLeaderStepdown, OnLeaderTakeover } from '@n8n/decorators';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core'; import { InstanceSettings } from 'n8n-core';
import { UserError } from 'n8n-workflow'; import { UserError } from 'n8n-workflow';
@@ -55,7 +55,6 @@ export class InsightsService {
this.pruningService.stopPruningTimer(); this.pruningService.stopPruningTimer();
} }
@OnShutdown()
async shutdown() { async shutdown() {
await this.collectionService.shutdown(); await this.collectionService.shutdown();
this.stopCompactionAndPruningTimers(); this.stopCompactionAndPruningTimers();

View File

@@ -1,4 +1,4 @@
import { ShutdownRegistryMetadata } from '@n8n/decorators'; import { ShutdownMetadata } from '@n8n/decorators';
import type { ShutdownServiceClass } from '@n8n/decorators'; import type { ShutdownServiceClass } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
@@ -16,11 +16,11 @@ describe('ShutdownService', () => {
let mockComponent: MockComponent; let mockComponent: MockComponent;
let onShutdownSpy: jest.SpyInstance; let onShutdownSpy: jest.SpyInstance;
const errorReporter = mock<ErrorReporter>(); const errorReporter = mock<ErrorReporter>();
const shutdownRegistryMetadata = Container.get(ShutdownRegistryMetadata); const shutdownMetadata = Container.get(ShutdownMetadata);
beforeEach(() => { beforeEach(() => {
shutdownRegistryMetadata.clear(); shutdownMetadata.clear();
shutdownService = new ShutdownService(mock(), errorReporter, shutdownRegistryMetadata); shutdownService = new ShutdownService(mock(), errorReporter, shutdownMetadata);
mockComponent = new MockComponent(); mockComponent = new MockComponent();
Container.set(MockComponent, mockComponent); Container.set(MockComponent, mockComponent);
onShutdownSpy = jest.spyOn(mockComponent, 'onShutdown'); onShutdownSpy = jest.spyOn(mockComponent, 'onShutdown');

View File

@@ -1,6 +1,6 @@
import { Logger } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common';
import type { ShutdownHandler } from '@n8n/decorators'; import type { ShutdownHandler } from '@n8n/decorators';
import { ShutdownRegistryMetadata } from '@n8n/decorators'; import { ShutdownMetadata } from '@n8n/decorators';
import { Container, Service } from '@n8n/di'; import { Container, Service } from '@n8n/di';
import { ErrorReporter } from 'n8n-core'; import { ErrorReporter } from 'n8n-core';
import { assert, UnexpectedError, UserError } from 'n8n-workflow'; import { assert, UnexpectedError, UserError } from 'n8n-workflow';
@@ -23,17 +23,17 @@ export class ShutdownService {
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly errorReporter: ErrorReporter, private readonly errorReporter: ErrorReporter,
private readonly shutdownRegistry: ShutdownRegistryMetadata, private readonly shutdownMetadata: ShutdownMetadata,
) {} ) {}
/** Registers given listener to be notified when the application is shutting down */ /** Registers given listener to be notified when the application is shutting down */
register(priority: number, handler: ShutdownHandler) { register(priority: number, handler: ShutdownHandler) {
this.shutdownRegistry.register(priority, handler); this.shutdownMetadata.register(priority, handler);
} }
/** Validates that all the registered shutdown handlers are properly configured */ /** Validates that all the registered shutdown handlers are properly configured */
validate() { validate() {
const handlers = this.shutdownRegistry.getHandlersByPriority().flat(); const handlers = this.shutdownMetadata.getHandlersByPriority().flat();
for (const { serviceClass, methodName } of handlers) { for (const { serviceClass, methodName } of handlers) {
if (!Container.has(serviceClass)) { if (!Container.has(serviceClass)) {
@@ -74,7 +74,7 @@ export class ShutdownService {
} }
private async startShutdown() { private async startShutdown() {
const handlers = Object.values(this.shutdownRegistry.getHandlersByPriority()).reverse(); const handlers = Object.values(this.shutdownMetadata.getHandlersByPriority()).reverse();
for (const handlerGroup of handlers) { for (const handlerGroup of handlers) {
await Promise.allSettled( await Promise.allSettled(