From 320a810b56cf17edb4d2dc6b2102b5890e03b2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 16 Jul 2025 12:36:11 +0200 Subject: [PATCH] refactor(core): Add `shutdown` method to modules (#17322) --- .../src/modules/module-registry.ts | 16 +++++++++++++ .../decorators/src/module/module-metadata.ts | 4 ++++ packages/@n8n/decorators/src/module/module.ts | 1 + .../shutdown/__tests__/on-shutdown.test.ts | 24 +++++++++---------- .../@n8n/decorators/src/shutdown/index.ts | 2 +- .../decorators/src/shutdown/on-shutdown.ts | 4 ++-- ...istry-metadata.ts => shutdown-metadata.ts} | 2 +- .../external-secrets-manager.ee.ts | 11 ++++----- .../external-secrets.module.ts | 9 ++++++- .../src/modules/insights/insights.module.ts | 9 ++++++- .../src/modules/insights/insights.service.ts | 3 +-- .../__tests__/shutdown.service.test.ts | 8 +++---- packages/cli/src/shutdown/shutdown.service.ts | 10 ++++---- 13 files changed, 68 insertions(+), 35 deletions(-) rename packages/@n8n/decorators/src/shutdown/{shutdown-registry-metadata.ts => shutdown-metadata.ts} (95%) diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index a24fb8f65f..c81bd9b05c 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -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); } diff --git a/packages/@n8n/decorators/src/module/module-metadata.ts b/packages/@n8n/decorators/src/module/module-metadata.ts index e76c16d39f..72b54e1737 100644 --- a/packages/@n8n/decorators/src/module/module-metadata.ts +++ b/packages/@n8n/decorators/src/module/module-metadata.ts @@ -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()]; } diff --git a/packages/@n8n/decorators/src/module/module.ts b/packages/@n8n/decorators/src/module/module.ts index 5e30b1a50b..7ba91dd68b 100644 --- a/packages/@n8n/decorators/src/module/module.ts +++ b/packages/@n8n/decorators/src/module/module.ts @@ -22,6 +22,7 @@ export type ModuleSettings = Record; export interface ModuleInterface { init?(): Promise; + shutdown?(): Promise; entities?(): Promise; settings?(): Promise; } diff --git a/packages/@n8n/decorators/src/shutdown/__tests__/on-shutdown.test.ts b/packages/@n8n/decorators/src/shutdown/__tests__/on-shutdown.test.ts index a9753dcf50..81dc414ac1 100644 --- a/packages/@n8n/decorators/src/shutdown/__tests__/on-shutdown.test.ts +++ b/packages/@n8n/decorators/src/shutdown/__tests__/on-shutdown.test.ts @@ -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', () => { diff --git a/packages/@n8n/decorators/src/shutdown/index.ts b/packages/@n8n/decorators/src/shutdown/index.ts index 224d077bd9..762d1b0368 100644 --- a/packages/@n8n/decorators/src/shutdown/index.ts +++ b/packages/@n8n/decorators/src/shutdown/index.ts @@ -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'; diff --git a/packages/@n8n/decorators/src/shutdown/on-shutdown.ts b/packages/@n8n/decorators/src/shutdown/on-shutdown.ts index 7a9896ba74..ac66b497ac 100644 --- a/packages/@n8n/decorators/src/shutdown/on-shutdown.ts +++ b/packages/@n8n/decorators/src/shutdown/on-shutdown.ts @@ -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( diff --git a/packages/@n8n/decorators/src/shutdown/shutdown-registry-metadata.ts b/packages/@n8n/decorators/src/shutdown/shutdown-metadata.ts similarity index 95% rename from packages/@n8n/decorators/src/shutdown/shutdown-registry-metadata.ts rename to packages/@n8n/decorators/src/shutdown/shutdown-metadata.ts index f72d555e38..d5fd192f78 100644 --- a/packages/@n8n/decorators/src/shutdown/shutdown-registry-metadata.ts +++ b/packages/@n8n/decorators/src/shutdown/shutdown-metadata.ts @@ -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) { diff --git a/packages/cli/src/modules/external-secrets.ee/external-secrets-manager.ee.ts b/packages/cli/src/modules/external-secrets.ee/external-secrets-manager.ee.ts index 8b775ee480..903a55388d 100644 --- a/packages/cli/src/modules/external-secrets.ee/external-secrets-manager.ee.ts +++ b/packages/cli/src/modules/external-secrets.ee/external-secrets-manager.ee.ts @@ -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 = {}; @@ -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) => { diff --git a/packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts b/packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts index 542cbc1ca8..1e12788565 100644 --- a/packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts +++ b/packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts @@ -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(); + } } diff --git a/packages/cli/src/modules/insights/insights.module.ts b/packages/cli/src/modules/insights/insights.module.ts index 3528c39f8f..d580468021 100644 --- a/packages/cli/src/modules/insights/insights.module.ts +++ b/packages/cli/src/modules/insights/insights.module.ts @@ -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(); + } } diff --git a/packages/cli/src/modules/insights/insights.service.ts b/packages/cli/src/modules/insights/insights.service.ts index 0f9a022d4d..512096ed3b 100644 --- a/packages/cli/src/modules/insights/insights.service.ts +++ b/packages/cli/src/modules/insights/insights.service.ts @@ -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(); diff --git a/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts b/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts index f1fe514f0c..ccae169e2e 100644 --- a/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts +++ b/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts @@ -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(); - 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'); diff --git a/packages/cli/src/shutdown/shutdown.service.ts b/packages/cli/src/shutdown/shutdown.service.ts index d819c9e785..b9c801a245 100644 --- a/packages/cli/src/shutdown/shutdown.service.ts +++ b/packages/cli/src/shutdown/shutdown.service.ts @@ -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(