Entendiendo el Visibility Timeout de SQS: Por Qué Tus Mensajes Se Procesan Dos Veces

Tienes un worker que consume mensajes de SQS, y de repente notas que el mismo mensaje está siendo procesado por dos instancias distintas al mismo tiempo. El primer instinto es buscar un bug en el código, pero la causa real casi siempre está en una configuración que se ignora hasta que el sistema está en producción: el Visibility Timeout de SQS.

TL;DR: Visibility Timeout en SQS

Aspecto Detalle
¿Qué controla? El tiempo que un mensaje permanece invisible para otros consumidores después de ser recibido
Valor por defecto 30 segundos
Rango configurable 0 segundos a 12 horas
Causa principal de doble procesamiento El procesamiento tarda más que el Visibility Timeout configurado
Solución operativa Extender el timeout dinámicamente con ChangeMessageVisibility o ajustar el valor base
Garantía de entrega SQS Standard ofrece entrega 'at-least-once', no 'exactly-once'

Cómo Funciona el Visibility Timeout de SQS

SQS no elimina un mensaje cuando un consumidor lo recibe. En cambio, lo oculta temporalmente del resto de consumidores durante un período definido: el Visibility Timeout. La idea es darle tiempo al consumidor para procesar el mensaje y luego eliminarlo explícitamente con DeleteMessage. Si el consumidor falla o no elimina el mensaje a tiempo, SQS asume que el procesamiento falló y vuelve a hacer el mensaje visible para que otro consumidor lo intente.

Este mecanismo es lo que permite la resiliencia ante fallos. Pero también es exactamente lo que causa el doble procesamiento cuando el tiempo de procesamiento supera el timeout configurado.

sequenceDiagram participant SQS as Cola SQS participant CA as Consumer A participant CB as Consumer B CA->>SQS: ReceiveMessage SQS-->>CA: Mensaje + ReceiptHandle Note over SQS: Mensaje invisible
(Visibility Timeout activo) alt Procesamiento exitoso CA->>CA: Procesa mensaje CA->>SQS: DeleteMessage(ReceiptHandle) SQS-->>CA: Mensaje eliminado else Timeout expira antes de terminar Note over SQS: Timeout expirado
Mensaje visible nuevamente CB->>SQS: ReceiveMessage SQS-->>CB: Mismo mensaje + nuevo ReceiptHandle Note over CA,CB: Doble procesamiento
CA y CB procesan el mismo mensaje end
  1. Consumer A recibe el mensaje: SQS marca el mensaje como invisible durante el período del Visibility Timeout.
  2. Ventana de invisibilidad activa: Ningún otro consumidor puede ver el mensaje mientras el timeout esté vigente.
  3. Escenario exitoso: Consumer A termina el procesamiento y llama a DeleteMessage antes de que expire el timeout. El mensaje desaparece de la cola.
  4. Escenario de doble procesamiento: El timeout expira antes de que Consumer A termine. SQS hace el mensaje visible nuevamente y Consumer B lo recibe, resultando en procesamiento duplicado.

Por Qué el Visibility Timeout de SQS Causa Procesamiento Duplicado

El valor por defecto de 30 segundos funciona bien para tareas rápidas. El problema aparece cuando el procesamiento involucra llamadas a bases de datos lentas, requests HTTP a servicios externos, o transformaciones de datos pesadas. Si cualquiera de esas operaciones tarda más de 30 segundos, el mensaje se vuelve visible antes de que el worker original haya terminado.

Lo que hace esto especialmente difícil de diagnosticar es que el worker original no recibe ninguna notificación de que el timeout expiró. Sigue procesando el mensaje en paralelo con el nuevo consumidor, sin saber que ya no tiene exclusividad sobre ese mensaje.

Piénsalo como un turno en una panadería: te dan un número y te dicen que tienes 30 segundos para hacer tu pedido. Si tardas más, el cajero asume que te fuiste y llama al siguiente número. Tú sigues ahí, pero el cajero ya está atendiendo a otra persona con el mismo pedido.

Diagnóstico: Identificar el Problema en Producción

Antes de cambiar cualquier configuración, confirma que el doble procesamiento viene del Visibility Timeout y no de otra causa. Las métricas de CloudWatch de SQS son el punto de partida más directo.

Paso 1: Revisar la métrica ApproximateNumberOfMessagesNotVisible

Esta métrica indica cuántos mensajes están actualmente en vuelo (recibidos pero no eliminados). Si este número crece sostenidamente, hay mensajes que no se están eliminando a tiempo, lo que eventualmente resulta en que el timeout expira y los mensajes vuelven a la cola.

aws cloudwatch get-metric-statistics \
  --namespace AWS/SQS \
  --metric-name ApproximateNumberOfMessagesNotVisible \
  --dimensions Name=QueueName,Value=nombre-de-tu-cola \
  --start-time 2024-01-15T00:00:00Z \
  --end-time 2024-01-15T01:00:00Z \
  --period 300 \
  --statistics Average \
  --region us-east-1

Paso 2: Verificar el Visibility Timeout actual de la cola

Antes de asumir que el timeout está mal configurado, confirma el valor real. Es común que el valor haya sido modificado en algún momento sin documentación.

aws sqs get-queue-attributes \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/nombre-de-tu-cola \
  --attribute-names VisibilityTimeout \
  --region us-east-1

Paso 3: Medir el tiempo real de procesamiento

Instrumenta tu worker para registrar el tiempo entre ReceiveMessage y DeleteMessage. Si no tienes esa instrumentación, revisa los logs de CloudWatch Logs o X-Ray si está habilitado. El objetivo es obtener el percentil 95 o 99 del tiempo de procesamiento, no el promedio — los outliers son los que causan el problema.

CLI examples omitted for steps 3 — la medición del tiempo de procesamiento depende del stack de la aplicación y no tiene un comando AWS CLI directo equivalente.

Solución 1: Ajustar el Visibility Timeout Base de la Cola

Si el tiempo de procesamiento es predecible y consistente, la solución más simple es aumentar el Visibility Timeout de la cola al valor que cubra el percentil 99 de tu tiempo de procesamiento, con un margen adicional razonable.

aws sqs set-queue-attributes \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/nombre-de-tu-cola \
  --attributes VisibilityTimeout=300 \
  --region us-east-1

Este comando establece el timeout en 300 segundos (5 minutos). El cambio aplica a todos los mensajes que se reciban a partir de ese momento — los mensajes ya en vuelo mantienen el timeout con el que fueron recibidos.

La desventaja de este enfoque es que si un worker falla genuinamente, el mensaje tardará todo ese tiempo en volver a estar disponible para reintento. Para colas con SLA de recuperación estricto, esto puede ser inaceptable.

Solución 2: Extender el Timeout Dinámicamente con ChangeMessageVisibility

La solución más robusta para procesamiento de duración variable es extender el Visibility Timeout mientras el mensaje está siendo procesado. La API ChangeMessageVisibility permite resetear el contador del timeout para un mensaje específico usando su ReceiptHandle.

El patrón operativo es implementar un 'heartbeat' en el worker: un hilo separado que periódicamente extiende el timeout mientras el procesamiento principal continúa.

aws sqs change-message-visibility \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/nombre-de-tu-cola \
  --receipt-handle AQEBwJnKyrHigUMZj6reyNurHz9Cl3i4BVyVy... \
  --visibility-timeout 60 \
  --region us-east-1

El ReceiptHandle es el identificador único que SQS devuelve cuando recibes un mensaje con ReceiveMessage. Es diferente del MessageId — el ReceiptHandle es específico a esa recepción particular del mensaje y es el que necesitas tanto para ChangeMessageVisibility como para DeleteMessage.

sequenceDiagram participant SQS as Cola SQS participant W as Worker Principal participant HB as Hilo Heartbeat W->>SQS: ReceiveMessage SQS-->>W: Mensaje (ReceiptHandle, timeout=30s) W->>HB: Iniciar heartbeat(ReceiptHandle) loop Cada 20 segundos HB->>SQS: ChangeMessageVisibility(timeout=30s) SQS-->>HB: OK - timeout extendido end W->>W: Procesa mensaje (duración variable) W->>SQS: DeleteMessage(ReceiptHandle) SQS-->>W: Mensaje eliminado W->>HB: Detener heartbeat
  1. Worker recibe el mensaje: Obtiene el ReceiptHandle junto con el cuerpo del mensaje.
  2. Hilo de heartbeat inicia: Un proceso paralelo llama a ChangeMessageVisibility cada N segundos (donde N es menor que el Visibility Timeout base) para extender la ventana de invisibilidad.
  3. Procesamiento principal completa: El worker llama a DeleteMessage con el ReceiptHandle.
  4. Heartbeat se detiene: Una vez eliminado el mensaje, el hilo de heartbeat ya no tiene trabajo que hacer.

El Error de Diagnóstico Más Común: Confundir la Causa

En un sistema de procesamiento de imágenes en producción, los mensajes aparecían duplicados intermitentemente — no siempre, solo bajo carga alta. El primer diagnóstico apuntó a un bug de idempotencia en el código del worker. Se invirtieron horas revisando la lógica de deduplicación.

La causa real: bajo carga alta, las instancias EC2 que corrían los workers experimentaban contención de CPU. El tiempo de procesamiento, que normalmente era de 20 segundos, subía a 45 segundos en los picos. El Visibility Timeout estaba en 30 segundos. Solo bajo carga alta, el timeout expiraba antes de que el procesamiento terminara.

La métrica que lo reveló fue ApproximateNumberOfMessagesNotVisible correlacionada con el CPU utilization de las instancias. Cuando el CPU superaba el 80%, los mensajes en vuelo empezaban a reaparecer en la cola.

La solución fue doble: aumentar el Visibility Timeout base a 120 segundos e implementar el heartbeat para mensajes que el worker identificaba como 'pesados' basándose en metadatos del mensaje. El doble procesamiento desapareció.

El Visibility Timeout no es una garantía de exclusividad — es una apuesta sobre cuánto tiempo tardará tu procesamiento. Si pierdes la apuesta, SQS no te avisa.

Consideraciones para SQS FIFO vs. Standard

El Visibility Timeout funciona de la misma manera en colas Standard y FIFO. Sin embargo, hay una diferencia operativa importante: las colas FIFO garantizan entrega 'exactly-once' dentro de un intervalo de deduplicación de 5 minutos usando el MessageDeduplicationId. Esto no elimina el problema del Visibility Timeout — si el timeout expira y el mensaje se reentrega, la deduplicación FIFO no lo bloqueará si el mensaje original ya fue procesado y eliminado.

Para colas Standard, la entrega 'at-least-once' es una garantía de diseño del servicio. Incluso con un Visibility Timeout perfectamente configurado, SQS puede entregar el mismo mensaje más de una vez en circunstancias excepcionales. Diseñar los workers para ser idempotentes no es opcional — es un requisito.

IAM: Permisos Necesarios para Gestionar el Visibility Timeout

Si tu worker necesita llamar a ChangeMessageVisibility, asegúrate de que el rol IAM asociado tenga el permiso correspondiente. El principio de mínimo privilegio aplica: otorga solo las acciones que el worker realmente necesita.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "sqs:ReceiveMessage",
        "sqs:DeleteMessage",
        "sqs:ChangeMessageVisibility",
        "sqs:GetQueueAttributes"
      ],
      "Resource": "arn:aws:sqs:us-east-1:123456789012:nombre-de-tu-cola"
    }
  ]
}

Si el worker también necesita enviar mensajes a una Dead Letter Queue (DLQ) manualmente, agrega sqs:SendMessage con el ARN de la DLQ como recurso adicional. No uses "Resource": "*" a menos que tengas una razón operativa documentada para ello.

Wrap-Up: Visibility Timeout de SQS y Próximos Pasos

El doble procesamiento en SQS casi siempre tiene la misma raíz: el Visibility Timeout expira antes de que el procesamiento termine. La solución correcta depende de la variabilidad de tu tiempo de procesamiento: si es predecible, ajusta el valor base; si varía significativamente, implementa el heartbeat con ChangeMessageVisibility.

Los pasos concretos para resolver el problema:

  1. Mide el percentil 99 de tu tiempo de procesamiento real, no el promedio.
  2. Verifica el Visibility Timeout actual con GetQueueAttributes.
  3. Si el timeout base es insuficiente, auméntalo con SetQueueAttributes.
  4. Para procesamiento de duración variable, implementa el patrón de heartbeat.
  5. Diseña todos los workers para ser idempotentes — SQS Standard no garantiza entrega única.

Recursos oficiales: Documentación de Visibility Timeout en AWS y la Referencia de AWS CLI para SQS.

Glosario de Términos Clave

Término Definición
Visibility Timeout Período durante el cual un mensaje recibido es invisible para otros consumidores de la cola. Configurable entre 0 segundos y 12 horas.
ReceiptHandle Identificador único devuelto por SQS al recibir un mensaje. Requerido para DeleteMessage y ChangeMessageVisibility. Cambia en cada recepción del mismo mensaje.
At-least-once delivery Garantía de entrega de SQS Standard: un mensaje será entregado al menos una vez, pero posiblemente más. No garantiza entrega única.
ChangeMessageVisibility API de SQS que extiende o reduce el Visibility Timeout de un mensaje específico actualmente en vuelo.
Dead Letter Queue (DLQ) Cola de destino para mensajes que no pudieron ser procesados exitosamente después de un número máximo de intentos configurado.

Comentarios

Entradas populares de este blog

EC2 sin acceso a Internet en VPC personalizada: Internet Gateway y Route Table

Actualizar Contenido en CloudFront: Cómo Crear una Invalidación para Limpiar el Caché del Edge

Aumentar el Timeout de Lambda: Configuración, Límites y Diagnóstico en Producción