From cc3d402294f7da1cdc91d4f475c1280c17855cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= <32224751+Lp-Francois@users.noreply.github.com> Date: Sun, 26 Nov 2023 19:23:47 +0100 Subject: [PATCH] feat: graceful shutdown timeout (#2422) Adds a new configuration option for the `TerminsModule.forRoot()` function that allows specifying a sleep timeout before gracefully shutting down. ```typescript Terminus.forRoot({ gracefulShutdownTimeoutMs: 1000 }) ``` --- README.md | 4 ++ .../graceful-shutdown-timeout.service.spec.ts | 47 +++++++++++++++++++ .../graceful-shutdown-timeout.service.ts | 42 +++++++++++++++++ lib/terminus-options.interface.ts | 20 ++++++++ lib/terminus.module.ts | 11 ++++- lib/utils/index.ts | 1 + lib/utils/sleep.ts | 2 + 7 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 lib/graceful-shutdown-timeout/graceful-shutdown-timeout.service.spec.ts create mode 100644 lib/graceful-shutdown-timeout/graceful-shutdown-timeout.service.ts create mode 100644 lib/utils/sleep.ts diff --git a/README.md b/README.md index e22763ad6..c928267e6 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,10 @@ If everything is set up correctly, you can access the healthcheck on `http://loc For more information, [see docs](https://docs.nestjs.com/recipes/terminus). You can find more samples in the [samples/](https://github.com/nestjs/terminus/tree/master/sample) folder of this repository. +### Graceful shutdown timeout + +If your application requires postponing its shutdown process, this can be done by configuring the `gracefulShutdownTimeoutMs` in the `TerminusModule options`. This setting can prove particularly beneficial when working with an orchestrator such as Kubernetes. By setting a delay slightly longer than the readiness check interval, you can achieve zero downtime when shutting down containers. + ## Contribute In order to get started, first read through our [Contributing guidelines](https://github.com/nestjs/terminus/blob/master/CONTRIBUTING.md). diff --git a/lib/graceful-shutdown-timeout/graceful-shutdown-timeout.service.spec.ts b/lib/graceful-shutdown-timeout/graceful-shutdown-timeout.service.spec.ts new file mode 100644 index 000000000..f8767c19c --- /dev/null +++ b/lib/graceful-shutdown-timeout/graceful-shutdown-timeout.service.spec.ts @@ -0,0 +1,47 @@ +import { Test } from '@nestjs/testing'; +import { LoggerService } from '@nestjs/common'; +import { + GracefulShutdownService, + TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT, +} from './graceful-shutdown-timeout.service'; +import { TERMINUS_LOGGER } from '../health-check/logger/logger.provider'; +import { sleep } from '../utils'; + +jest.mock('../utils', () => ({ + sleep: jest.fn(), +})); + +const loggerMock: Partial = { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +}; + +describe('GracefulShutdownService', () => { + let service: GracefulShutdownService; + let logger: LoggerService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + GracefulShutdownService, + { provide: TERMINUS_LOGGER, useValue: loggerMock }, + { provide: TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT, useValue: 1000 }, + ], + }).compile(); + + logger = module.get(TERMINUS_LOGGER); + service = module.get(GracefulShutdownService); + }); + + it('should not trigger sleep if signal is not SIGTERM', async () => { + await service.beforeApplicationShutdown('SIGINT'); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('should trigger sleep if signal is SIGTERM', async () => { + await service.beforeApplicationShutdown('SIGTERM'); + expect(sleep).toHaveBeenCalledWith(1000); + }); +}); diff --git a/lib/graceful-shutdown-timeout/graceful-shutdown-timeout.service.ts b/lib/graceful-shutdown-timeout/graceful-shutdown-timeout.service.ts new file mode 100644 index 000000000..3d84f3686 --- /dev/null +++ b/lib/graceful-shutdown-timeout/graceful-shutdown-timeout.service.ts @@ -0,0 +1,42 @@ +import { + type BeforeApplicationShutdown, + ConsoleLogger, + Inject, + Injectable, + LoggerService, +} from '@nestjs/common'; +import { TERMINUS_LOGGER } from '../health-check/logger/logger.provider'; +import { sleep } from '../utils'; + +export const TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT = + 'TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT'; + +/** + * Handles Graceful shutdown timeout useful to await + * for some time before the application shuts down. + */ +@Injectable() +export class GracefulShutdownService implements BeforeApplicationShutdown { + constructor( + @Inject(TERMINUS_LOGGER) + private readonly logger: LoggerService, + @Inject(TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT) + private readonly gracefulShutdownTimeoutMs: number, + ) { + if (this.logger instanceof ConsoleLogger) { + this.logger.setContext(GracefulShutdownService.name); + } + } + + async beforeApplicationShutdown(signal: string) { + this.logger.log(`Received termination signal ${signal}`); + + if (signal === 'SIGTERM') { + this.logger.log( + `Awaiting ${this.gracefulShutdownTimeoutMs}ms before shutdown`, + ); + await sleep(this.gracefulShutdownTimeoutMs); + this.logger.log(`Timeout reached, shutting down now`); + } + } +} diff --git a/lib/terminus-options.interface.ts b/lib/terminus-options.interface.ts index d789910f5..06339328e 100644 --- a/lib/terminus-options.interface.ts +++ b/lib/terminus-options.interface.ts @@ -2,7 +2,27 @@ import { type LoggerService, type Type } from '@nestjs/common'; export type ErrorLogStyle = 'pretty' | 'json'; +/** + * The Terminus module options + * + * errorLogStyle: The style of the error logger. Either 'pretty' or 'json'. Default to 'json'. + * logger: The logger to use. Either default logger or your own. + * gracefulShutdownTimeoutMs: The timeout to wait in ms before the application shuts down. Default to 0ms. + * @publicApi + */ export interface TerminusModuleOptions { + /** + * The style of the error logger + * @default 'json' + */ errorLogStyle?: ErrorLogStyle; + /** + * The logger to use. Either default logger or your own. + */ logger?: Type | boolean; + /** + * The timeout to wait in ms before the application shuts down + * @default 0 + */ + gracefulShutdownTimeoutMs?: number; } diff --git a/lib/terminus.module.ts b/lib/terminus.module.ts index 00ca536fb..591942be2 100644 --- a/lib/terminus.module.ts +++ b/lib/terminus.module.ts @@ -1,4 +1,5 @@ import { type DynamicModule, Module } from '@nestjs/common'; +import { TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT } from './graceful-shutdown-timeout/graceful-shutdown-timeout.service'; import { HealthCheckService } from './health-check'; import { getErrorLoggerProvider } from './health-check/error-logger/error-logger.provider'; import { ERROR_LOGGERS } from './health-check/error-logger/error-loggers.provider'; @@ -30,7 +31,11 @@ const exports_ = [HealthCheckService, ...HEALTH_INDICATORS]; }) export class TerminusModule { static forRoot(options: TerminusModuleOptions = {}): DynamicModule { - const { errorLogStyle = 'json', logger = true } = options; + const { + errorLogStyle = 'json', + logger = true, + gracefulShutdownTimeoutMs = 0, + } = options; return { module: TerminusModule, @@ -38,6 +43,10 @@ export class TerminusModule { ...providers, getErrorLoggerProvider(errorLogStyle), getLoggerProvider(logger), + { + provide: TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT, + useValue: gracefulShutdownTimeoutMs, + }, ], exports: exports_, }; diff --git a/lib/utils/index.ts b/lib/utils/index.ts index f0e81d5a3..f8e51a72c 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -2,3 +2,4 @@ export * from './promise-timeout'; export * from './checkPackage.util'; export * from './types'; export * from './is-error'; +export * from './sleep'; diff --git a/lib/utils/sleep.ts b/lib/utils/sleep.ts new file mode 100644 index 000000000..a8c24f36c --- /dev/null +++ b/lib/utils/sleep.ts @@ -0,0 +1,2 @@ +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms));