Опирается на правила:
R-SHUT-DB-1…R-SHUT-DB-3иR-SHUT-DB-X1из Graceful Shutdown Style Guide → раздел 4. БД и persistence.
Важно знать
pool.end()/dataSource.destroy()вызывается вonApplicationShutdown— Nest исполняет его послеbeforeApplicationShutdownи закрытия HTTP-сервера: порядок правильный по конструкции.- Не закрывать пул в
beforeApplicationShutdown— в этой фазе ещё живут in-flight HTTP-запросы и работающие интервалы/джобы.- Активные транзакции дожимаются через свой канал: HTTP-handler — через
app.close()+server.close(), интервалы — черезawaitin-flight Promise, BullMQ-джобы — черезawait worker.close().- TypeORM
dataSource.destroy()— точный аналогpool.end()для драйвера pg: закрывает пул и сбрасывает состояние DataSource; вызывать один раз, не повторно.- Typeorm/pg не знают порядка сами по себе —
onApplicationShutdownзадаёт его через Nest DI.- Миграции (TypeORM
runMigrations,node-postgres-migrate) — только на startup; нет «очистки при выходе».- Логировать нормальное закрытие пула на
INFO, неERROR— иначе alert-канал шумит на каждом деплое (R-SHUT-OBS-X1).pool.end()до завершения фоновых задач — interation BullMQ/@nestjs/scheduleнарвётся на закрытый пул посреди транзакции → inconsistent state.
Пул соединений БД — последний ресурс в очереди на закрытие. Nest предоставляет lifecycle-хуки с гарантированным порядком: beforeApplicationShutdown → onApplicationShutdown. Правило одно: пул закрывается в onApplicationShutdown, всё остальное — в beforeApplicationShutdown.
pool.end() — в onApplicationShutdown
R-SHUT-DB-1: Nest исполняет onApplicationShutdown после того, как HTTP-сервер закрыт и все beforeApplicationShutdown завершились. К этому моменту активные транзакции уже дошли до commit/rollback в своих каналах.
// src/infrastructure/database/database.module.ts
import { Module, OnApplicationShutdown, Injectable } from '@nestjs/common';
import { Pool } from 'pg';
@Injectable()
export class DatabaseService implements OnApplicationShutdown {
readonly pool: Pool;
constructor() {
this.pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
}
async onApplicationShutdown(signal?: string): Promise<void> {
this.pool.on('error', () => {});
await this.pool.end();
}
}
Для TypeORM:
// src/infrastructure/database/typeorm-shutdown.service.ts
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@Injectable()
export class TypeOrmShutdownService implements OnApplicationShutdown {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async onApplicationShutdown(): Promise<void> {
if (this.dataSource.isInitialized) {
await this.dataSource.destroy();
}
}
}
Не закрывай пул в beforeApplicationShutdown — в этой фазе Nest ещё не дождался ни HTTP drain, ни фоновых задач.
Активные транзакции дожимаются
R-SHUT-DB-2: механизм дожатия зависит от того, кто держит транзакцию.
HTTP-handler с транзакцией
// src/order/create-order.handler.ts
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../infrastructure/database/database.service';
@Injectable()
export class CreateOrderHandler {
constructor(private readonly db: DatabaseService) {}
async execute(customerId: string, amount: number): Promise<{ id: string }> {
const client = await this.db.pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query<{ id: string }>(
`INSERT INTO orders (customer_id, amount, status)
VALUES ($1, $2, 'pending')
RETURNING id`,
[customerId, amount],
);
await client.query('COMMIT');
return { id: rows[0].id };
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
}
При SIGTERM — app.close() вызывает server.close() + closeIdleConnections(). In-flight запрос дожимается до COMMIT / ROLLBACK. Только потом Nest переходит к onApplicationShutdown и закрывает пул.
Интервал (@nestjs/schedule) с транзакцией
// src/order/outbox-relay.service.ts
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { DatabaseService } from '../infrastructure/database/database.service';
import { ShutdownStateService } from '../health/shutdown-state.service';
@Injectable()
export class OutboxRelayService implements OnApplicationShutdown {
private inflightPromise: Promise<void> | null = null;
constructor(
private readonly db: DatabaseService,
private readonly shutdownState: ShutdownStateService,
) {}
@Interval(5_000)
async processOutboxBatch(): Promise<void> {
if (this.shutdownState.isDraining()) return;
const work = this._doProcessBatch();
this.inflightPromise = work;
await work;
this.inflightPromise = null;
}
private async _doProcessBatch(): Promise<void> {
const client = await this.db.pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query<{ id: string; payload: string }>(
`SELECT id, payload FROM outbox
WHERE dispatched_at IS NULL
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 20`,
);
for (const event of rows) {
await client.query(
`UPDATE outbox SET dispatched_at = now() WHERE id = $1`,
[event.id],
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async beforeApplicationShutdown(): Promise<void> {
if (this.inflightPromise) {
await this.inflightPromise;
}
}
}
Relay проверяет isDraining() перед каждой итерацией, не внутри неё. В beforeApplicationShutdown ждёт текущую итерацию через inflightPromise. Пул закроется только после — в onApplicationShutdown у DatabaseService.
BullMQ-джоб с транзакцией
// src/product/product-index.worker.ts
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { Worker } from 'bullmq';
import { DatabaseService } from '../infrastructure/database/database.service';
@Processor('product-index')
@Injectable()
export class ProductIndexWorker extends WorkerHost implements OnApplicationShutdown {
constructor(private readonly db: DatabaseService) {
super();
}
async process(job: { data: { productId: string } }): Promise<void> {
const client = await this.db.pool.connect();
try {
await client.query('BEGIN');
await client.query(
`UPDATE products SET indexed_at = now() WHERE id = $1`,
[job.data.productId],
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async onApplicationShutdown(): Promise<void> {
await (this.worker as Worker).close();
}
}
worker.close() без force: true — BullMQ ждёт активные джобы до завершения. Pool закрывается только после этого — потому что DatabaseService.onApplicationShutdown вызывается Nest позже (порядок определяется порядком регистрации в DI).
Если нужна явная гарантия порядка — ModuleRef или APP_SHUTDOWN_HOOKS с приоритетом через Injectable({ providedIn: 'root' }) с низким ordering.
Async-CASCADE с долгой транзакцией
Долгий cascade (зачисление баланса Customer, несколько записей) — в beforeApplicationShutdown должен быть await in-flight Promise:
// src/customer/balance-settler.service.ts
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { DatabaseService } from '../infrastructure/database/database.service';
import { ShutdownStateService } from '../health/shutdown-state.service';
@Injectable()
export class BalanceSettlerService implements OnApplicationShutdown {
private activeSettlement: Promise<void> | null = null;
constructor(
private readonly db: DatabaseService,
private readonly shutdownState: ShutdownStateService,
) {}
async settleCustomerBalance(customerId: string, amount: number): Promise<void> {
const work = this._doSettle(customerId, amount);
this.activeSettlement = work;
try {
await work;
} finally {
this.activeSettlement = null;
}
}
private async _doSettle(customerId: string, amount: number): Promise<void> {
const client = await this.db.pool.connect();
try {
await client.query('BEGIN');
await client.query(
`UPDATE customer_balances
SET balance = balance + $2, updated_at = now()
WHERE customer_id = $1`,
[customerId, amount],
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async beforeApplicationShutdown(): Promise<void> {
if (this.activeSettlement) {
await Promise.race([
this.activeSettlement,
new Promise<void>((resolve) => setTimeout(resolve, 20_000).unref()),
]);
}
}
}
Promise.race с таймером — защита от зависшей транзакции. После 20 секунд beforeApplicationShutdown завершается, Nest переходит к следующей фазе; незавершённая транзакция получит ROLLBACK от pg при разрыве соединения.
TypeORM миграции — только старт
R-SHUT-DB-3: runMigrations запускается в onModuleInit или в main.ts до app.listen(). На shutdown — ничего.
// src/main.ts
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
const dataSource = app.get(DataSource);
await dataSource.runMigrations();
await app.listen(3000);
}
bootstrap();
Нет «cleanup-миграций на выходе» — схема БД не откатывается при остановке сервиса. dataSource.destroy() в onApplicationShutdown закрывает соединения, не схему.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
pool.end() в beforeApplicationShutdown | R-SHUT-DB-X1 | onApplicationShutdown — после дренажа HTTP и задач |
dataSource.destroy() без проверки isInitialized | R-SHUT-DB-1 | if (dataSource.isInitialized) await dataSource.destroy() |
pool.end() в кастомном process.on('SIGTERM', ...) до app.close() | R-SHUT-DB-X1 | app.enableShutdownHooks(), хуки Nest |
Отсутствие await inflightPromise в beforeApplicationShutdown | R-SHUT-DB-2 | хранить in-flight Promise и await его |
worker.close(true) (force) без ожидания джоба | R-SHUT-SCHED-X1 | worker.close() без force — BullMQ ждёт активные джобы |
logger.error при нормальном pool.end() | R-SHUT-OBS-X1 | logger.log / logger.debug — это ожидаемое INFO-событие |
| SQL-скрипт «очистки» в shutdown | R-SHUT-DB-3 | миграции только на startup, runMigrations() до app.listen() |
pool.query(...) после pool.end() | R-SHUT-DB-1 | проверять isDraining() перед запросом в фоновых задачах |
Куда дальше
- Рантайм/конфигурация —
app.enableShutdownHooks(),ShutdownStateService, force-deadline поверхserver.close(). - HTTP drain —
server.close(),closeIdleConnections(), долгие эндпоинты → 202. - Фоновые задачи и outbox —
SchedulerRegistry,awaitin-flight, BullMQworker.close(). - Kafka shutdown — kafkajs
consumer.disconnect(),producer.disconnect(), commit-семантика. - Идемпотентность in-flight —
Idempotency-Key, outbox-дедупликация. - Бюджеты и observability —
app_shutdown_duration_secondsprom-client Gauge, структурный лог. - Kubernetes —
terminationGracePeriodSeconds, preStop, probes на/health/{live,ready}.