Опирается на правила: R-SHUT-DB-1R-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(), интервалы — через await in-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-хуки с гарантированным порядком: beforeApplicationShutdownonApplicationShutdown. Правило одно: пул закрывается в 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() в beforeApplicationShutdownR-SHUT-DB-X1onApplicationShutdown — после дренажа HTTP и задач
dataSource.destroy() без проверки isInitializedR-SHUT-DB-1if (dataSource.isInitialized) await dataSource.destroy()
pool.end() в кастомном process.on('SIGTERM', ...) до app.close()R-SHUT-DB-X1app.enableShutdownHooks(), хуки Nest
Отсутствие await inflightPromise в beforeApplicationShutdownR-SHUT-DB-2хранить in-flight Promise и await его
worker.close(true) (force) без ожидания джобаR-SHUT-SCHED-X1worker.close() без force — BullMQ ждёт активные джобы
logger.error при нормальном pool.end()R-SHUT-OBS-X1logger.log / logger.debug — это ожидаемое INFO-событие
SQL-скрипт «очистки» в shutdownR-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, await in-flight, BullMQ worker.close().
  • Kafka shutdown — kafkajs consumer.disconnect(), producer.disconnect(), commit-семантика.
  • Идемпотентность in-flight — Idempotency-Key, outbox-дедупликация.
  • Бюджеты и observability — app_shutdown_duration_seconds prom-client Gauge, структурный лог.
  • Kubernetes — terminationGracePeriodSeconds, preStop, probes на /health/{live,ready}.