Why NextWhy Next
Volver a la lista
💻 Desarrollo11 min de lectura

Los hooks de cierre se ejecutaban dos veces: cuando SIGTERM tenía dos dueños

En cada despliegue, Sentry recibía un error P2028 de Prisma. Al entrar a corregir el orden de cierre, descubrí que enableShutdownHooks de NestJS y una librería de graceful shutdown capturaban ambas SIGTERM, haciendo que los hooks de cierre se ejecutaran dos veces en cada ocasión.

Lo esencial

Cuando llegaba un SIGTERM durante un despliegue, la base de datos se cerraba antes que un job todavía en curso, y eso provocaba Prisma P2028. La causa era que el cierre de los workers y el cierre de la base de datos estaban en la misma fase del ciclo de vida - se corrigió drenando los workers en la fase más temprana beforeApplicationShutdown. Pero la revisión reveló un problema más de fondo: tanto enableShutdownHooks() como http-graceful-shutdown capturaban SIGTERM, así que los hooks de cierre se ejecutaban dos veces antes de que terminara el drenaje HTTP. Gracias a que los hooks eran idempotent, esto no se convirtió en un incidente, solo dejó un olor en los logs. Se borró una línea (enableShutdownHooks) para dejar un único dueño de la señal, y se verificó con un SIGTERM real.

En esta página

Cada vez que desplegábamos, Sentry recibía el mismo error: Transaction already closed. Es el error que lanza Prisma cuando intenta ejecutar una consulta después de que la transacción ya se cerró, y muere con P2028. Los usuarios no sufrían ningún daño: el reintento de BullMQ hacía que otra tarea completara el job de inmediato, y los registros de finalización seguían sin interrupciones. Aun así, la estructura del problema hacía que reapareciera en silencio cada vez que un despliegue coincidía con tráfico activo. Persiguiendo este error descubrí que los hooks de cierre de nuestro servidor en realidad se estaban ejecutando dos veces en cada ocasión.

Acto 1: la base de datos moría antes que los jobs

Al cruzar la hora del error con los registros de CloudWatch, la línea de tiempo encajaba con exactitud. El despliegue continuo (rolling deploy) envía SIGTERM a la tarea antigua. 6s después, app.close() comienza la limpieza y el motor de Prisma cierra la transacción. Justo después, el updateMany de un job de asset-processing que todavía estaba en curso se ejecuta sobre una transacción ya cerrada y falla con P2028. El usuario estaba subiendo audio de forma continua, así que en el momento del despliegue había un job activo, y la consulta de ese job murió.

La causa era el orden de cierre. Al apagarse, NestJS invoca los hooks del ciclo de vida en un orden determinado. El problema era que cerrar los workers de BullMQ y desconectar Prisma estaban en la misma fase de cierre (onApplicationShutdown). Dentro de la misma fase, el orden de registro de los módulos define el orden de ejecución, y si justo Prisma se desconectaba primero, la consulta del worker que todavía procesaba un job se quedaba sin destino.

La solución era clara: cerrar los workers en una fase anterior. Los hooks de cierre de NestJS tienen un orden, y beforeApplicationShutdown termina antes que el onApplicationShutdown de cualquier módulo. Así que creé un servicio que localiza todos los workers y los drena (espera a que terminen los jobs activos) en esa fase temprana.

@Injectable()
export class WorkerDrainService implements BeforeApplicationShutdown {
  constructor(private readonly discovery: DiscoveryService) {}

  async beforeApplicationShutdown() {
    // Busca todos los WorkerHost registrados y espera a que terminen los jobs activos.
    // Esta fase concluye antes que onApplicationShutdown, donde se desconecta Prisma.
    const workers = this.discovery
      .getProviders()
      .filter((p) => p.instance instanceof WorkerHost);
    await Promise.all(workers.map((w) => w.instance.worker.close()));
  }
}

Worker.close() devuelve la misma promise aunque se llame varias veces, así que no entra en conflicto con el código que ya cerraba los workers, y en entornos sin Redis simplemente no hace nada. Ahora, aunque el despliegue coincida con un job, este termina primero y solo después se cierra la base de datos. Añadí pruebas unitarias y subí el PR. Hasta aquí, era la corrección típica de un bug de orden.

Acto 2: SIGTERM tenía dos dueños

El giro llegó en la revisión. Quien revisaba esta corrección repasó todo el flujo de cierre y lanzó una pregunta: ¿quién está manejando realmente el SIGTERM?

Al comprobarlo, había dos dueños. En main.ts existían estas dos líneas, cada una por su lado.

// ① Le delegamos a Nest mismo la señal de cierre (llamado sin argumentos registra tanto SIGTERM como SIGINT)
app.enableShutdownHooks();

// ② También le delegamos el cierre a la librería http-graceful-shutdown
setupGracefulShutdown(server, {
  onShutdown: async () => {
    await app.close(); // aquí se ejecuta todo el conjunto de hooks del ciclo de vida
  },
});

Ambas capturan SIGTERM. Cuando llega una señal, Node invoca todos los listeners registrados en orden, sin esperar a que ninguno termine. Así que en el momento en que llega un SIGTERM real ocurre esto: el listener registrado por Nest ejecuta una vez la misma secuencia de cierre que app.close(), de inmediato y sin esperar el drenaje de HTTP. Por separado, http-graceful-shutdown vacía las conexiones HTTP a su manera y luego, en onShutdown, llama a app.close() una segunda vez. Los hooks de cierre terminan ejecutándose dos veces.

Como solo razonarlo mentalmente no bastaba para tener certeza, volví a leer los logs de cierre de producción. Ahí estaba la evidencia. 20ms después de la marca de tiempo del SIGTERM, y antes de que terminara el drenaje HTTP, ya arrancaba onModuleDestroy, y los logs de los hooks de cierre de SpeechService y QueueMonitorService aparecían duplicados, dos veces cada uno. La única razón por la que esto no derivó en un incidente es que los hooks de cada servicio estaban escritos para ser seguros aunque se llamaran dos veces (idempotent). Gracias a ese diseño defensivo, la "doble ejecución" quedó como un olor en los logs y no como una falla, y ese olor fue justamente lo que permitió atrapar el bug.

Se borra una línea

La corrección consistió en borrar una sola línea.

// main.ts
- app.enableShutdownHooks();

Al eliminar enableShutdownHooks(), el único dueño de SIGTERM queda http-graceful-shutdown. No se pierde nada: el app.close() que llama su onShutdown de todos modos ejecuta el conjunto completo de hooks del ciclo de vida. Lo único que cambia es que los hooks corren una vez, no dos, y además después de que termina el drenaje de HTTP.

Cuanto más pequeña es la corrección, más necesaria es la verificación real. Envié un SIGTERM real al servidor ya compilado para observar la secuencia con mis propios ojos.

Received SIGTERM → health check 503 → HTTP connections closed
→ shutdown hooks run only once, after HTTP close
→ Drained 56/56 BullMQ workers in 211ms → shutdown completed → clean exit

En producción, lo que antes eran dos ejecuciones de los hooks antes del drenaje HTTP ahora es una sola, después del drenaje. Como la verificación se hizo ya con la corrección del drenaje de workers del Acto 1 integrada, de paso quedó comprobado el comportamiento conjunto de ambas correcciones funcionando juntas.

También dejé anotada una trampa. En NODE_ENV=development, http-graceful-shutdown opera en modo de cierre inmediato y ni siquiera pasa por la ruta graceful. Por eso esta verificación no se reproduce en modo desarrollo. Solo pude medirlo de verdad tras poner NODE_ENV=staging con secretos ficticios, para forzar el mismo camino que en producción. Al verificar una ruta de cierre, lo primero es confirmar que el entorno de prueba realmente pasa por esa ruta.

Lo que aprendimos del código de cierre

La lección de esta historia va más allá del escenario estrecho de la secuencia de cierre.

Cada señal debe tener un único dueño. Si ya integraste una librería de graceful shutdown, no dejes encendido al mismo tiempo el registro automático de hooks de cierre que ofrece el framework. En el momento en que ambos capturan el mismo SIGTERM, la secuencia de cierre se ejecuta dos veces en silencio. Al incorporar una herramienta nueva, lo primero es ver de qué señal o evento se vuelve dueña, y si ya existía código capturando lo mismo.

El cierre también tiene un orden. El código que limpia algo debe correr después de que termine todo lo que depende de ello. La base de datos debe cerrarse después de que terminen los jobs que la usan, y para eso hay que drenar esos jobs en una fase anterior. Que el framework divida los hooks de cierre en fases no es un adorno: existe precisamente para expresar este orden.

El código escrito a la defensiva compra tiempo. Si los hooks de cierre no hubieran estado hechos para ser seguros aunque se llamaran dos veces, este bug se habría manifestado primero como un incidente de datos, no como un olor en los logs. Gracias a que los hooks estaban escritos de forma idempotent y resistieron la doble ejecución, pudimos descubrirlo en los logs en vez de en un incidente. Si hubiéramos dado por sentado que un hook se ejecuta una sola vez, la historia habría sido mucho peor.

Preguntas frecuentes

Cuando cierro mi app de NestJS, los hooks del ciclo de vida (como onModuleDestroy) se ejecutan dos veces. ¿Por qué pasa esto?

Es muy probable que haya dos dueños manejando SIGTERM. app.enableShutdownHooks() hace que Nest registre directamente sus propios listeners de señales de cierre, y si además usas una librería de graceful shutdown (por ejemplo, http-graceful-shutdown), esta también captura SIGTERM y llama a app.close() en su onShutdown. Como Node invoca a todos los listeners registrados para una misma señal, la secuencia de cierre termina ejecutándose dos veces. Basta con dejar que solo uno de los dos sea el dueño de la señal.

Si uso una librería de graceful shutdown, ¿no debería llamar a enableShutdownHooks()?

Si la librería llama a app.close() dentro de su callback onShutdown, entonces enableShutdownHooks() es redundante, porque app.close() ya ejecuta todo el conjunto de hooks del ciclo de vida. Una señal debe tener un solo dueño. Si dejas que la librería sea la dueña de la señal y quitas enableShutdownHooks(), los hooks se ejecutan una sola vez, y además después de que termina el drenaje de HTTP.

Artículos relacionados