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) {
return this.activeModules.includes(moduleName);
}

View File

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

View File

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

View File

@@ -1,15 +1,15 @@
import { Container, Service } from '@n8n/di';
import { OnShutdown } from '../on-shutdown';
import { ShutdownRegistryMetadata } from '../shutdown-registry-metadata';
import { ShutdownMetadata } from '../shutdown-metadata';
describe('OnShutdown', () => {
let shutdownRegistryMetadata: ShutdownRegistryMetadata;
let shutdownMetadata: ShutdownMetadata;
beforeEach(() => {
shutdownRegistryMetadata = new ShutdownRegistryMetadata();
Container.set(ShutdownRegistryMetadata, shutdownRegistryMetadata);
jest.spyOn(shutdownRegistryMetadata, 'register');
shutdownMetadata = new ShutdownMetadata();
Container.set(ShutdownMetadata, shutdownMetadata);
jest.spyOn(shutdownMetadata, 'register');
});
it('should register a methods that is decorated with OnShutdown', () => {
@@ -19,8 +19,8 @@ describe('OnShutdown', () => {
async onShutdown() {}
}
expect(shutdownRegistryMetadata.register).toHaveBeenCalledTimes(1);
expect(shutdownRegistryMetadata.register).toHaveBeenCalledWith(100, {
expect(shutdownMetadata.register).toHaveBeenCalledTimes(1);
expect(shutdownMetadata.register).toHaveBeenCalledWith(100, {
methodName: 'onShutdown',
serviceClass: TestClass,
});
@@ -36,12 +36,12 @@ describe('OnShutdown', () => {
async two() {}
}
expect(shutdownRegistryMetadata.register).toHaveBeenCalledTimes(2);
expect(shutdownRegistryMetadata.register).toHaveBeenCalledWith(100, {
expect(shutdownMetadata.register).toHaveBeenCalledTimes(2);
expect(shutdownMetadata.register).toHaveBeenCalledWith(100, {
methodName: 'one',
serviceClass: TestClass,
});
expect(shutdownRegistryMetadata.register).toHaveBeenCalledWith(100, {
expect(shutdownMetadata.register).toHaveBeenCalledWith(100, {
methodName: 'two',
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
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', () => {

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,10 @@
import { Logger } from '@n8n/backend-common';
import { SettingsRepository } from '@n8n/db';
import { OnPubSubEvent, OnShutdown } from '@n8n/decorators';
import { OnPubSubEvent } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { Cipher, type IExternalSecretsManager } from 'n8n-core';
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 {
EXTERNAL_SECRETS_DB_KEY,
EXTERNAL_SECRETS_INITIAL_BACKOFF,
@@ -18,6 +14,10 @@ import { ExternalSecretsProviders } from './external-secrets-providers.ee';
import { ExternalSecretsConfig } from './external-secrets.config';
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()
export class ExternalSecretsManager implements IExternalSecretsManager {
private providers: Record<string, SecretsProvider> = {};
@@ -65,7 +65,6 @@ export class ExternalSecretsManager implements IExternalSecretsManager {
this.logger.debug('External secrets manager initialized');
}
@OnShutdown()
shutdown() {
clearInterval(this.updateInterval);
Object.values(this.providers).forEach((p) => {

View File

@@ -1,5 +1,5 @@
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators';
import { BackendModule, OnShutdown } from '@n8n/decorators';
import { Container } from '@n8n/di';
@BackendModule({ name: 'external-secrets', licenseFlag: 'feat:externalSecrets' })
@@ -16,4 +16,11 @@ export class ExternalSecretsModule implements ModuleInterface {
await externalSecretsManager.init();
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 { BackendModule } from '@n8n/decorators';
import { BackendModule, OnShutdown } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
@@ -31,4 +31,11 @@ export class InsightsModule implements ModuleInterface {
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 { 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 { InstanceSettings } from 'n8n-core';
import { UserError } from 'n8n-workflow';
@@ -55,7 +55,6 @@ export class InsightsService {
this.pruningService.stopPruningTimer();
}
@OnShutdown()
async shutdown() {
await this.collectionService.shutdown();
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 { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
@@ -16,11 +16,11 @@ describe('ShutdownService', () => {
let mockComponent: MockComponent;
let onShutdownSpy: jest.SpyInstance;
const errorReporter = mock<ErrorReporter>();
const shutdownRegistryMetadata = Container.get(ShutdownRegistryMetadata);
const shutdownMetadata = Container.get(ShutdownMetadata);
beforeEach(() => {
shutdownRegistryMetadata.clear();
shutdownService = new ShutdownService(mock(), errorReporter, shutdownRegistryMetadata);
shutdownMetadata.clear();
shutdownService = new ShutdownService(mock(), errorReporter, shutdownMetadata);
mockComponent = new MockComponent();
Container.set(MockComponent, mockComponent);
onShutdownSpy = jest.spyOn(mockComponent, 'onShutdown');

View File

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