¿Por qué los trabajos @Cron de NestJS se ejecutan una vez por réplica y cómo solucionarlo?
Aprenda por qué los horarios @Cron en tiempo de ejecución se multiplican cuando un servicio NestJS se escala horizontalmente, y cómo un disparador externo, escrituras idempotentes y un endpoint protegido lo solucionan.
Una tarea nocturna que debería crear exactamente un registro por entidad comienza a generar duplicados: filas idénticas para la misma entidad y fecha de inicio, escritas con segundos de diferencia, sin que ninguna esté dañada. Nada en el código de la tarea cambió. Lo que sí cambió es que el servicio ahora se ejecuta en varias réplicas, y el programador está dentro de cada una. Este artículo explica por qué el decorador @Cron de NestJS se comporta de esta manera, compara tres formas de solucionarlo y detalla el diseño elegido, incluidos los aspectos de seguridad y zonas horarias asociados.
Cómo ocurren los duplicados
Imaginemos una tarea que traslada cada entidad activa a su siguiente ventana de tiempo definida, creando un nuevo registro por entidad cada día. La implementación típica en NestJS utiliza el decorador @nestjs/schedule:
@Injectable()
export class WindowGenerationService {
@Cron('0 8 * * *') // every day at 08:00
async generateNextWindows() {
const entities = await this.repo.findActiveEndingSoon();
for (const entity of entities) {
await this.repo.createNextWindow(entity);
}
}
}
Esto es exactamente lo que sugiere la documentación, y funciona sin problemas mientras el servicio se ejecuta como una única instancia.
Los problemas comienzan después del escalado horizontal. Con tres instancias, hay tres procesos que inician el módulo cada uno, y @Cron registra su temporizador en cada uno de ellos. Nada los coordina: a las 08:00, los tres se ejecutan simultáneamente. Dado que la tarea no realiza ninguna verificación de idempotencia ni utiliza bloqueos, cada ejecución busca una ventana ya existente, no encuentra ninguna y escribe su propia copia.
Surgen dos costos. El más evidente es la duplicación de datos. El menos obvio es el desperdicio: cada réplica realiza la misma tarea al mismo tiempo, y luego se invierte más esfuerzo en limpiar los resultados. Un programador de tareas en tiempo real en un servicio escalable no solo representa un error de corrección; por diseño, consume recursos informáticos en proporción al número de réplicas. Nada en el código indica esto, por lo que tiende a manifestarse en datos de prueba en lugar de durante las revisiones.
Tres formas de solucionarlo
Opción 1: un bloqueo distribuido
Mantenga @Cron, pero haga que las instancias compitan por un bloqueo, como un bloqueo de advertencia en la base de datos o una clave en la caché, y permita que solo el ganador se ejecute. Esto funciona, pero sustituye un fallo visible por uno invisible. Las filas duplicadas al menos pueden encontrarse y eliminarse. Una ejecución omitida no puede hacerlo: si el mecanismo de bloqueo está fuera de servicio a las 08:00, o una instancia se cae mientras mantiene el bloqueo antes de que expire su TTL, la tarea simplemente no se lleva a cabo, y nadie se da cuenta hasta que una ventana faltante causa problemas días después. También añade una nueva dependencia y un nuevo modo de fallo silencioso para compensar por un temporizador colocado en la capa incorrecta.
Opción 2: idempotencia por sí sola
Haga que la tarea sea segura para ejecutarse más de una vez, de modo que las ejecuciones adicionales no hagan nada. Esto es económico y correcto, pero tres contenedores siguen activándose cada noche para realizar trabajo redundante.
Opción 3: sacar la programación del aplicativo
La aplicación no debe decidir cuándo se ejecuta el trabajo. Deje que un programador externo controle el reloj y envíe una solicitud HTTP a un endpoint normal; la aplicación solo decide qué ocurre cuando recibe esa solicitud. Un disparador genera una ejecución, y agregar réplicas ya no multiplica nada. Los lugares comunes para ese disparador son un Kubernetes CronJob, el servicio de programación de un proveedor en la nube o el horario de una pipeline CI.
El diseño elegido combina la opción 3 con la idempotencia de la opción 2 como medida de seguridad.
El nuevo diseño
Se elimina el decorador @Cron, y la lógica de las tareas se encuentra detrás de un endpoint:
@Post('jobs/run')
async runJob(@Body() body: RunJobDto) {
this.assertValidSecret(body.secret);
return this.jobs.run(body.jobKey);
}
El programador externo llama a esta función una vez en el momento programado. El equilibrador de carga dirige la solicitud a una instancia, que ejecuta el trabajo; las demás réplicas nunca participan. Al pasar un jobKey se permite que un único endpoint envíe varias tareas.
Una mejora práctica: una tarea que dura minutos podría superar el tiempo de espera HTTP del programador. Para tareas largas, considere confirmar rápidamente la solicitud y ejecutar el trabajo en segundo plano, manteniendo al mismo tiempo la prevención de solapamientos.
Mantener la idempotencia como medida de seguridad
La verificación de idempotencia sigue siendo necesaria, ya que la promesa de “ejecutarse exactamente una vez” es algo que la infraestructura generalmente cumple pero que ocasionalmente se incumple: el programador vuelve a intentarlo tras un tiempo de espera, alguien activa la tarea manualmente o una instancia se reinicia a mitad del proceso.
async createNextWindow(entity: Entity) {
const existing = await this.repo.findByEntityIdAndStartDate(
entity.id,
entity.nextStartDate,
);
if (existing) return; // already done, no-op
await this.repo.create(/* ... */);
}
Antes de crear una ventana, el método busca una con el mismo ID de entidad y fecha de inicio, y devuelve un resultado temprano si existe. Tenga en cuenta que el patrón de verificar primero y luego insertar también puede ser problemático si dos ejecuciones coinciden exactamente. La solución fiable es establecer una restricción única en la base de datos sobre el ID de entidad y la fecha de inicio, de modo que los duplicados concurrentes fallen al intentar insertarse en lugar de ser guardados. Para conocer más sobre el diseño de escrituras seguras contra intentos repetidos, consulte nuestra guía sobre claves de idempotencia en los endpoints POST de Node.js.
Costos del cambio
Un endpoint de tarea es un endpoint público
Al convertir una tarea nocturna privada en una ruta HTTP, se crea un botón que cualquiera que la encuentre puede presionar repetidamente. Por lo tanto, dicha ruta requiere un secreto compartido, y la forma en que se compara ese secreto es importante:
private assertValidSecret(provided: string) {
const expected = this.config.cronSecret;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new UnauthorizedException();
}
}
Comparar con provided === expected puede filtrar información a través del tiempo de ejecución: la comparación de cadenas puede detenerse en el primer carácter que no coincide, por lo que el tiempo necesario para fallar indica hasta qué punto la suposición era correcta, lo que permite a un atacante recuperar la información secreta poco a poco. timingSafeEqual de Node, proveniente del módulo crypto, realiza la comparación en tiempo constante. Requiere buffers de longitud igual, por eso primero se verifica la longitud; hashear ambos valores antes de compararlos evita revelar incluso esa longitud. Los revisores rara vez señalan este problema, y confiar en que el endpoint permanezca sin ser descubierto no constituye una estrategia viable.
Hay dos pasos adicionales de reforzamiento que vale la pena considerar. Enviar el secreto en un encabezado de solicitud en lugar del cuerpo evita que quede incluido en los registros del middleware. Además, validar que secret sea una cadena no vacía en RunJobDto impide que Buffer.from genere un error por falta de entrada.
Elegir la hora es una decisión del producto
El segundo costo es fácil de subestimar: elegir el momento adecuado. La tarea debe ejecutarse después del inicio del día para cada usuario, y estos se encuentran en varias zonas horarias de EE. UU.; por lo tanto, una hora que sea mediodía en la costa este todavía es antes del amanecer en la costa oeste. Por eso, el horario se fija en una hora UTC específica, seleccionada según la zona horaria más occidental en la que opera la empresa, ya que ese es el momento seguro en todas partes. El crontab utiliza UTC mientras que el requisito indica la hora local, y es en la traducción entre ambos donde se toma la decisión real. Anote ese razonamiento junto al horario, ya que no resulta obvio solo con la expresión cron.
Puntos clave
@Cronse ejecuta en cada proceso que carga el módulo, por lo que sus ejecuciones se multiplican según la cantidad de réplicas sin ninguna advertencia en el código.
timingSafeEqual, validación de entradas y registro adecuado.Lecturas relacionadas
- Dentro de los interceptores de NestJS: Arreglando una regresión de latencia del 96% a escala — Aprenda cómo el pipeline de ejecución AOP de NestJS y los problemas al desmontar RxJS causaron un aumento en la latencia P99, y cómo crear un interceptor de auditoría sin asignación de memoria para solucionarlo.
- De la subida a la URL: Almacenamiento y servicio seguro de archivos de usuarios en Express — Aprenda dónde deben almacenarse los archivos subidos en las aplicaciones Express, cómo express.static mapea una carpeta a URLs, y qué medidas de seguridad evitan que las subidas de usuarios se conviertan en vulnerabilidades.