06 sept 2025·7 min de lectura

Tareas cron sin servidor: evitar superposiciones y detectar fallos silenciosos

Haz fiables las tareas cron sin servidor: elige un planificador, bloquea ejecuciones concurrentes con bloqueos y añade un registro de "última ejecución" con alertas.

Tareas cron sin servidor: evitar superposiciones y detectar fallos silenciosos

El problema: superposiciones y fallos silenciosos

La mayoría de las tareas cron sin servidor fallan de dos maneras previsibles: se ejecutan dos veces al mismo tiempo, o dejan de ejecutarse y nadie se da cuenta.

Una superposición ocurre cuando la siguiente ejecución programada empieza antes de que la anterior termine. En sistemas reales eso se traduce en facturas duplicadas, correos repetidos, pagos dobles o importaciones que escriben las mismas filas dos veces. Incluso si tu código es mayormente idempotente, las superposiciones siguen perjudicando: consumen límites de cuota, cobran tarjetas doblemente y mantienen locks más tiempo del esperado.

Los fallos silenciosos son peores porque parecen que no ha pasado nada. Un job puede dejar de ejecutarse porque un deploy eliminó el schedule, un cambio de permisos bloqueó el acceso a la base de datos, un secreto expiró, se alcanzaron cuotas o una actualización de la plataforma desactivó un trigger. Los logs antiguos pueden seguir ahí, así que todo parece estar bien hasta que un cliente reporta datos faltantes.

"Funcionó una vez" no es un plan de fiabilidad. Que un job se ejecutara ayer no prueba que lo hará mañana, especialmente cuando la configuración, los permisos, los secretos y los runtimes cambian sin tocar el código.

Lo que quieres es simple y medible:

  • No ejecuciones concurrentes (una ejecución es la propietaria del trabajo, las demás retroceden)
  • Detección rápida cuando las ejecuciones se detienen (una señal clara de "última ejecución", más una alerta cuando está obsoleta)

Trata el scheduling como infraestructura de producción y dejarás de perseguir bugs raros e intermitentes más tarde.

Elige un planificador que encaje con el job

No todos los planificadores se comportan igual, y eso importa cuando la gente depende de tus jobs.

Planificadores basados en eventos disparan un trigger en (aproximadamente) un momento específico y pasan el trabajo a una función o endpoint. Son simples y baratos, pero la entrega suele ser "mejor esfuerzo" a menos que añadas reintentos, manejo de dead-letter y monitorización.

Planificadores basados en colas encolan un mensaje de "ejecuta este job". Ese salto extra es útil porque las colas suelen darte mejor control sobre reintentos, backpressure y visibilidad. Si tu job es pesado, lento o con picos, una cola facilita ver y recuperarse de fallos.

Opciones comunes incluyen AWS EventBridge Scheduler (o CloudWatch schedules), GCP Cloud Scheduler (a menudo en combinación con Pub/Sub o Cloud Tasks), Azure Functions Timer Trigger (o Logic Apps), y planificadores de CI como GitHub Actions para tareas de mantenimiento ligeras.

Una forma práctica de elegir es responder a unas preguntas:

  • ¿Con qué frecuencia se ejecuta y importa el minuto exacto?
  • ¿Cuánto puede durar una ejecución en pico (segundos vs horas)?
  • ¿Qué debe pasar al fallar: reintentar, alertar o ambos?
  • ¿Qué permisos necesita (base de datos, secretos, APIs de terceros)?
  • ¿Necesitas recuperar ejecuciones perdidas?

Si necesitas garantías fuertes, evita configuraciones de "disparar y olvidar" sin reintentos, sin ruta de dead-letter y sin alerta cuando nada se ejecuta. Así es como los jobs dejan de funcionar silenciosamente durante días.

Define tu modelo de ejecución antes de programar

Muchos problemas de fiabilidad empiezan antes del planificador. Empiezan con una definición difusa de qué significa una "ejecución".

Decide qué cuenta como una ejecución y escríbelo. ¿Es "todo desde la última ejecución", "todos los registros de ayer" o "un id de lote creado a las 02:00"? Esa decisión afecta cómo bloqueas, reintentas y recuperas.

Haz que "ejecutar dos veces" sea seguro cuando puedas

Incluso con buenos locks, asume que una ejecución puede suceder dos veces por reintentos, timeouts o replays manuales. Busca trabajo idempotente: la misma entrada debe llevar al mismo resultado final.

Un patrón simple es almacenar una clave de ejecución (por ejemplo 2026-01-20) y registrar qué elementos se procesaron bajo esa clave. Si la misma clave se ejecuta de nuevo, saltas los ítems completados en vez de repetir efectos secundarios.

Separa el trigger del worker

Trata el trigger del schedule como un iniciador ligero y pon el trabajo real en una función worker. El trigger solo debería calcular la clave de ejecución, intentar reclamar la ejecución y pasar el trabajo.

Esto mantiene la lógica de negocio separada de las guardas de fiabilidad y facilita cambiar de planificador más adelante.

Antes de programar, define los resultados:

  • Éxito: ¿qué datos son definitivamente correctos y dónde están registrados?
  • Falla: ¿qué debe revertirse y qué puede reintentarse?
  • Éxito parcial: ¿qué es seguro conservar y cómo reanudas?
  • Timeout: ¿qué estado puede quedar atrás?

Planifica tu guarda de concurrencia (estrategia de locking)

Si ejecutas tareas cron sin servidor, asume que pasarán dos malos días: un job se alarga y el siguiente schedule se dispara de todas formas, o una función se reintenta tras un timeout y obtienes dos copias. Una guarda de concurrencia es la pequeña pieza que hace esos días aburridos.

Empieza eligiendo dónde vive el lock. Elige algo que tu job pueda leer y escribir rápido, con comportamiento fuerte de "solo uno gana".

Elige tu almacén de locks

Opciones comunes:

  • Una fila única en la base de datos (genial si ya usas Postgres/MySQL y puedes hacer una actualización atómica)
  • Redis (rápido y conveniente para locks cortos, pero asegúrate de que sea altamente disponible)
  • Lease en almacenamiento de objetos (un blob/archivo creado con "if not exists"; simple, pero puede ser más lento)

Luego decide qué representa la clave del lock. Una clave práctica suele incluir el nombre del job y la ventana de tiempo programada, como billing-sync:2026-01-20T02:00Z. Eso evita duplicados para la misma franja sin impedir la ejecución del día siguiente.

Siempre establece un TTL (expiración). El TTL te protege cuando una ejecución crashea a mitad o la plataforma mata el proceso. Ponlo un poco más largo que tu peor tiempo de ejecución, no que tu promedio.

Finalmente, decide qué pasa en conflicto:

  • Saltar (seguro para trabajo idempotente, pero puedes perder una ejecución)
  • Volver a programar (mejor cobertura, pero puede crear picos de tráfico)
  • Fallar en voz alta (mejor para jobs críticos donde perder una ejecución es peor que ruido)

Paso a paso: prevenir ejecuciones concurrentes con un lock

Las superposiciones pasan cuando tu scheduler dispara dos veces o una ejecución dura más de lo esperado. En serverless, la solución más simple es un lock compartido almacenado fuera de la función (una fila en la base de datos, una clave en Redis o un KV gestionado). Una ejecución gana el lock; las demás salen.

1) Usa un flujo de lock claro

Mantén el flujo predecible:

  • Adquirir lock (create atómico o update condicional)
  • Si el lock está tomado, salir rápido
  • Ejecutar el job
  • Liberar el lock, pero solo si aún lo posees

2) Añade un token de propietario y libera siempre

Un token de propietario evita que la Ejecución B libere el lock de la Ejecución A. Libera siempre en un bloque finally para que los errores no dejen un lock permanente.

import crypto from "crypto";

export async function handler() {
  const lockKey = "nightly-report";
  const owner = crypto.randomUUID();
  const ttlSeconds = 15 * 60; // lock safety window

  const acquired = await acquireLock({ lockKey, owner, ttlSeconds });
  if (!acquired) return { status: "skipped", reason: "lock_taken" };

  try {
    await doWork();
    return { status: "ok" };
  } finally {
    await releaseLock({ lockKey, owner }); // only release if owner matches
  }
}

Un buen acquireLock es atómico y establece un expiry (TTL) para que una ejecución muerta no bloquee para siempre.

3) Prueba con superposición forzada

Dispara dos ejecuciones al mismo tiempo (invoca manualmente dos veces o reduce el schedule temporalmente). Una debería ejecutarse; la otra debería registrar "skipped: lock_taken". Si ambas se ejecutan, tu escritura del lock no es verdaderamente atómica o te falta la comprobación del owner.

Paso a paso: añade una comprobación de heartbeat de "última ejecución"

Arregla auth y secretos
Arreglamos la deriva de permisos, autenticación rota y secretos expirados que rompen workers programados.

Un heartbeat es un pequeño registro de "yo me ejecuté" que tu job escribe cada vez que termina (éxito o fallo). Convierte fallos silenciosos en alertas, lo cual importa en serverless donde no hay un proceso siempre activo que note una parada.

1) Elige dónde guardar "última ejecución"

Elige un lugar fácil de escribir, rápido de leer y poco probable que caiga al mismo tiempo que tu scheduler:

  • Una tabla en la base de datos
  • Una entrada en un KV (simple "job_name -> last_run")
  • Un sistema de métricas / gauge en series temporales

2) Registra los campos correctos

No almacenes solo una marca temporal. Guarda suficiente información para depurar sin hurgar en logs primero.

job_name, run_id, started_at, finished_at, status, duration_ms, error_snippet

Una regla práctica: escribe una vez al inicio (status=running) y luego actualiza al final (status=success o failed). Eso también te permite detectar "stuck running".

3) Establece un umbral y reglas de alerta

Ajusta el umbral de "heartbeat faltante" a aproximadamente 2x tu intervalo esperado. Si un job corre cada 15 minutos, alerta si no hay heartbeat exitoso en 30 minutos.

Separa tipos de alerta:

  • Heartbeat faltante: no hay éxito dentro del umbral (probablemente no se está ejecutando)
  • Fallos repetidos: las últimas N ejecuciones fallaron (el job se ejecuta, pero el trabajo rompe)

Ejemplo: una sincronización nocturna de facturación debería ejecutarse a las 02:00 y terminar en 5 minutos. Alerta si no hay éxito a las 02:15. Usa otra alerta si falló tres noches seguidas.

Logs y alertas que sean realmente útiles

Cuando los jobs cron serverless se comportan mal, el primer problema suele no ser el scheduler. Es que nadie puede responder tres preguntas rápido: ¿se inició?, ¿terminó?, ¿por qué se saltó?

Dale a cada ejecución un run id consistente (por ejemplo: timestamp + sufijo aleatorio corto). Regístralo al inicio e inclúyelo en cada línea de log para poder seguir una ejecución de principio a fin.

También registra cuándo una ejecución no ocurre a propósito. Un run saltado no es lo mismo que un fallo, pero sigue siendo una señal importante. Si el job no se ejecutó porque no consiguió el lock, dilo claramente e incluye la clave del lock (y el owner si lo tienes).

Mantén los logs consistentes:

  • Inicio: run id, nombre del job, tiempo del trigger, versión/commit, entradas importantes
  • Skip: run id, nombre del job, razón del skip (conflicto de lock, scheduler deshabilitado, feature flag off)
  • Final: run id, estado (ok/failed), duración, conteos (ítems procesados, errores)
  • Falla: run id, tipo de error, contexto seguro y qué ya se hizo

Las alertas deben vigilar patrones, no solo errores individuales. Un pico en duración puede significar que una API upstream está lenta. Muchos skips pueden indicar que tu lock está atascado. No ejecuciones normalmente apuntan a deriva de permisos del scheduler o a un deploy que eliminó el trigger.

Haz cada alerta accionable. Incluye la última ejecución exitosa, la próxima ejecución esperada y la primera cosa a comprobar (registro de lock, estado del scheduler, deploys recientes).

Reintentos, timeouts y recuperaciones seguras

Haz que los logs sean realmente útiles
Añadimos run IDs, razones de skip y logs consistentes para que los incidentes sean fáciles de rastrear.

Los reintentos ayudan, pero también crean superposiciones. Muchos planificadores reintentan automáticamente si tu función devuelve un error o expira. Sin un lock distribuido (o si lo liberas demasiado pronto), un reintento puede empezar mientras la ejecución original aún trabaja.

Los timeouts empeoran esto. En serverless, la plataforma puede detener tu código a mitad cuando alcanzas un límite de tiempo. Puede que no tengas oportunidad de limpiar y puede que no sepas qué pasos se completaron. Si el scheduler reintenta, puedes enviar correos dobles, cobrar doble o escribir duplicados.

Un enfoque más seguro es hacer cada ejecución reanudable e idempotente. Piensa en checkpoints, no en una única función "hacerlo todo". Por ejemplo, un job nocturno de facturación puede almacenar un marcador de progreso como "procesado hasta invoice_id 18420" y continuar desde ahí.

Guardarraíles que previenen que reintentos y recuperaciones hagan daño:

  • Mantén el lock durante toda la ejecución. Suéltalo solo cuando realmente hayas terminado.
  • Registra un run_id y marcadores de progreso para que un reintento pueda continuar en vez de reiniciar.
  • Divide el trabajo en pequeños lotes con una comprobación por ítem de "ya procesado".
  • Añade un modo de backfill controlado que procese ventanas perdidas una a una.

Los backfills importan porque los schedules se deslizan. Si la ejecución de ayer falló, la de hoy no debería procesar automáticamente dos días de una vez a menos que tu sistema pueda manejarlo. Una regla simple: "procesa la ventana más antigua faltante primero y luego para".

Errores comunes y soluciones fáciles

La mayoría de fallos en producción vienen de unas pocas decisiones que parecen bien en un prototipo.

  • TTL más corto que el job. Tu promesa de "una sola ejecución" se rompe cuando una ejecución es lenta. Solución: pon TTL al peor caso de ejecución más un margen, y refuérzalo mientras el job esté vivo.
  • TTL demasiado largo. Una ejecución que crashea puede bloquear el schedule durante horas. Solución: mantén el TTL razonable, libera en finally y usa token de owner para que otra instancia no pueda desbloquear por accidente.
  • Locks en memoria. En serverless, cada ejecución puede aterrizar en una instancia diferente, así que flags en memoria no sirven. Solución: usa un almacén compartido (fila DB, Redis o KV gestionado).
  • Asumir "exactamente una vez" sin idempotencia. Los reintentos y la entrega al menos una vez te golpearán. Solución: escribe con claves únicas, upserts o una comprobación de run-id antes de efectos secundarios.
  • Usar logs como heartbeat. Los logs son geniales para depurar, pero molestos para alertar. Solución: escribe un registro de última ejecución en un lugar consultable (DB/KV/métricas).

Una causa fácil de pasar por alto de fallos silenciosos es la deriva de permisos. El scheduler aún dispara, pero el worker ya no puede leer secretos, escribir en almacenamiento o llamar a una API tras un cambio.

Lista rápida antes de desplegar

Antes de confiar tus tareas cron sin servidor en producción, haz una pasada enfocada en fallos aburridos: un scheduler apagado, un lock que expira demasiado pronto o un heartbeat que nadie comprueba.

  • Confirma que el schedule está habilitado en el entorno correcto y que la identidad del runtime puede leer secretos, escribir en tu DB/cola y emitir logs.
  • Escribe tu guarda de concurrencia: formato de clave de lock, dónde se almacena, TTL y qué pasa en conflicto (saltar, reprogramar o fallar).
  • Valida el TTL con tiempos reales. Si el job a veces tarda 12 minutos, un lock de 10 minutos creará superposiciones.
  • Almacena una señal de "última ejecución exitosa" en un lugar que puedas consultar rápido durante un incidente e incluye estado (no solo timestamp).
  • Ejecuta dos pruebas intencionadas: (1) fuerza un fallo para confirmar que las alertas llegan a una persona, y (2) fuerza una superposición para confirmar que la segunda ejecución es bloqueada y queda claramente registrada.

Una prueba simple de superposición: inicia una ejecución con un sleep intencional en medio, luego dispara una segunda ejecución. Si no ves un mensaje claro "lock held, exiting", tu guarda aún no es fiable.

Ejemplo: un job nocturno que nunca debe ejecutarse dos veces

De prototipo a producción
Convierte una función cron frágil en un trigger + worker claro y fiable.

Un caso doloroso (y común): un "export de reportes" nocturno que corre a las 02:00, genera PDFs y los envía por correo a clientes. Tras un deploy, el scheduler dispara dos veces (o un reintento entra) y algunos clientes reciben correos duplicados. Nada está "caído", pero la confianza cae rápido.

La solución son dos piezas pequeñas: un lock para evitar superposiciones y un heartbeat para detectar paradas silenciosas.

Primero, el job toma un lock distribuido (por ejemplo, una fila en DB o una clave en un store gestionado) con un TTL más largo que el tiempo esperado de ejecución. Si el lock ya está tomado, la segunda invocación sale antes de enviar nada.

Un flujo práctico:

  • Intentar adquirir lock "nightly-export" con TTL 45 minutos
  • Si existe el lock, loggear "skipped: already running" y parar
  • Si se adquiere el lock, generar el export y enviar los correos
  • Liberar el lock (el TTL es una red de seguridad, no el plan)

Segundo, escribe un heartbeat como last_success_at después de enviar los correos. Luego ejecuta una comprobación separada cada 15 minutos que alerte si now - last_success_at es mayor a 24 horas + un intervalo. Eso detecta rápidamente el problema de "el job se detuvo tras un deploy".

Para un responsable no técnico, los mejores logs y alertas son en lenguaje claro:

02:00:01 lock_acquired job=nightly-export run_id=abc123
02:07:44 completed job=nightly-export emails_sent=418 last_success_at=2026-01-20T02:07:44Z
02:00:02 skipped job=nightly-export reason=lock_held current_owner=abc123
ALERT: Nightly export has not succeeded in 25h. Last success: 2026-01-19 02:06 UTC. Check scheduler + secrets.

Siguientes pasos si tus jobs programados siguen siendo poco fiables

Si tus jobs siguen superponiéndose o "simplemente se paran" después de añadir un lock y un heartbeat, el scheduler puede estar bien pero la lógica del job es frágil.

Una señal común es cuando la lógica cron vino de un prototipo generado por IA. Suele verse un lock que no es realmente compartido, secretos filtrándose en logs y un comportamiento de reintentos que parece útil pero causa efectos secundarios duplicados.

Señales de que estás en la fase de "dejar de parchear":

  • Las correcciones funcionan hasta el próximo deploy y entonces las fallas cambian de forma
  • Nadie puede explicar exactamente cuándo se considera que una ejecución está "terminada"
  • Los reintentos crean correos duplicados, cargos o escrituras
  • La autenticación falla de forma impredecible (tokens expirados, refresh faltante, roles incorrectos)
  • Los logs no permiten reconstruir una ejecución de principio a fin

En ese punto, una corta intervención de remediación suele valer más que más ajustes. El objetivo no es reescribir todo. Es hacer el job predecible: un punto de entrada claro, una estrategia de lock, un conjunto de timeouts y un único lugar donde se registre el éxito.

Si estás lidiando con una base de código generada por IA que está rota (especialmente de herramientas como Lovable, Bolt, v0, Cursor o Replit), FixMyMess en fixmymess.ai se centra en diagnosticar y reparar problemas como ejecuciones superpuestas, secretos expuestos y lógica de reintentos frágil para que el job se comporte de forma fiable en producción.