Memahami SQS Visibility Timeout: Mengapa Pesan Diproses Dua Kali?

Kamu baru saja menemukan bahwa order yang sama diproses dua kali di sistem e-commerce — dua email konfirmasi terkirim, stok berkurang dua kali lipat. Setelah investigasi, akar masalahnya bukan di aplikasi, tapi di konfigurasi SQS Visibility Timeout yang terlalu pendek dibanding waktu aktual pemrosesan pesan.

TL;DR: SQS Visibility Timeout

AspekDetail
Apa ituDurasi pesan 'disembunyikan' dari consumer lain setelah di-receive
Default30 detik
Range valid0 detik hingga 12 jam
Penyebab double processingTimeout habis sebelum consumer selesai memproses dan menghapus pesan
Solusi utamaSet timeout lebih besar dari waktu pemrosesan maksimum, atau gunakan ChangeMessageVisibility secara berkala
Risiko timeout terlalu panjangPesan gagal tidak segera tersedia kembali — delay recovery meningkat

Bagaimana SQS Visibility Timeout Bekerja

SQS bukan message broker yang melacak 'siapa sedang memproses apa' secara stateful. Model dasarnya adalah at-least-once delivery — SQS menjamin pesan akan terkirim minimal satu kali, tapi tidak menjamin tepat satu kali. Mekanisme utama yang mencegah double processing adalah visibility timeout, bukan lock eksklusif.

Ketika consumer memanggil ReceiveMessage, SQS mengembalikan pesan tersebut dan langsung memulai countdown visibility timeout. Selama countdown berjalan, pesan tidak akan muncul di response ReceiveMessage consumer lain. Setelah consumer selesai memproses, ia harus memanggil DeleteMessage untuk menghapus pesan secara permanen. Jika DeleteMessage tidak dipanggil sebelum timeout habis, pesan kembali visible dan consumer lain bisa mengambilnya.

Bayangkan visibility timeout seperti 'tanda reserved' di meja restoran. Selama tanda itu terpasang, pelayan lain tidak akan mengarahkan tamu ke meja itu. Tapi kalau tanda diangkat sebelum tamu pergi — karena timer habis — meja itu dianggap kosong lagi.
sequenceDiagram participant CA as Consumer A participant SQS as SQS Queue participant CB as Consumer B CA->>SQS: ReceiveMessage SQS-->>CA: Pesan M1 + ReceiptHandle Note over SQS: Visibility Timeout dimulai Note over SQS: M1 tidak visible untuk consumer lain alt Skenario Sukses CA->>CA: Proses M1 (selesai sebelum timeout) CA->>SQS: DeleteMessage(ReceiptHandle) SQS-->>CA: OK - Pesan terhapus permanen else Skenario Timeout Habis Note over SQS: Timeout habis - M1 kembali visible CB->>SQS: ReceiveMessage SQS-->>CB: Pesan M1 (sama!) Note over CA,CB: Double Processing terjadi else Skenario Perpanjangan CA->>SQS: ChangeMessageVisibility(+60s) SQS-->>CA: Timeout diperpanjang CA->>CA: Lanjutkan pemrosesan CA->>SQS: DeleteMessage(ReceiptHandle) end
  1. Consumer A memanggil ReceiveMessage — SQS mengembalikan pesan M1 dan memulai visibility timeout countdown.
  2. Pesan M1 tidak visible untuk consumer lain selama timeout berlangsung.
  3. Skenario sukses: Consumer A selesai memproses dan memanggil DeleteMessage sebelum timeout habis. Pesan terhapus permanen.
  4. Skenario gagal: Consumer A masih memproses saat timeout habis. Pesan M1 kembali visible — Consumer B mengambilnya dan terjadi double processing.
  5. Skenario perpanjangan: Consumer A memanggil ChangeMessageVisibility untuk memperpanjang timeout sebelum habis.

Mengapa Visibility Timeout Menyebabkan Pesan Diproses Dua Kali

Ini adalah kesalahan konfigurasi yang paling sering terjadi di production: developer mengukur waktu pemrosesan rata-rata di environment development, lalu menetapkan visibility timeout berdasarkan angka itu. Di production, ada kondisi yang tidak muncul di dev — koneksi database lambat, downstream API timeout, GC pause di JVM, atau sekadar volume data yang lebih besar.

Misalnya, visibility timeout di-set 30 detik karena rata-rata pemrosesan 10-15 detik. Tapi saat terjadi spike traffic dan database connection pool penuh, satu request bisa memakan waktu 45 detik. Timeout habis di detik ke-30, pesan kembali visible, consumer lain mengambilnya — dan sekarang dua consumer memproses order yang sama secara bersamaan.

sequenceDiagram participant CA as Consumer A participant SQS as SQS Queue participant CB as Consumer B participant DB as Database CA->>SQS: ReceiveMessage (t=0) SQS-->>CA: Pesan M1, timeout=30s CA->>DB: Query (DB lambat karena spike) Note over SQS: t=30: Timeout habis! Note over SQS: M1 kembali visible CB->>SQS: ReceiveMessage (t=31) SQS-->>CB: Pesan M1 (sama!) CB->>DB: Query - mulai proses order DB-->>CA: Response akhirnya datang (t=45) CA->>SQS: DeleteMessage (t=45) CB->>DB: Selesai proses order (t=50) CB->>SQS: DeleteMessage (t=50) Note over CA,CB: Order diproses dua kali!
  1. t=0: Consumer A menerima pesan, visibility timeout 30 detik dimulai.
  2. t=30: Timeout habis. Consumer A masih memproses karena DB lambat. Pesan kembali visible.
  3. t=31: Consumer B mengambil pesan yang sama. Double processing dimulai.
  4. t=45: Consumer A selesai, memanggil DeleteMessage. Tapi Consumer B sudah terlanjur memproses.
  5. t=50: Consumer B juga selesai dan memanggil DeleteMessage. SQS menerima kedua delete — tidak ada error, tapi damage sudah terjadi.

Diagnosis: Verifikasi Konfigurasi Visibility Timeout

Langkah 1: Cek Visibility Timeout Saat Ini di Queue

Sebelum mengubah apapun, pastikan kamu tahu nilai yang sedang aktif. Visibility timeout bisa di-set di level queue (default untuk semua pesan) dan bisa di-override per pesan saat ReceiveMessage dipanggil dengan parameter VisibilityTimeout. Cek keduanya — banyak engineer hanya mengecek queue attribute tapi lupa bahwa kode consumer-nya override nilai tersebut.

# Cek visibility timeout di level queue
aws sqs get-queue-attributes \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-order-queue \
  --attribute-names VisibilityTimeout

Output yang perlu diperhatikan:

{
    "Attributes": {
        "VisibilityTimeout": "30"
    }
}

Lalu bandingkan dengan waktu pemrosesan aktual di CloudWatch atau application log. Jika ada gap, itulah sumber masalahnya.

Langkah 2: Monitor ApproximateNumberOfMessagesNotVisible

Metrik CloudWatch ApproximateNumberOfMessagesNotVisible menunjukkan jumlah pesan yang sedang 'in-flight' — sudah di-receive tapi belum dihapus. Jika angka ini terus naik dan tidak turun, itu sinyal bahwa consumer tidak menyelesaikan pemrosesan dalam visibility timeout window. Ini bukan bukti langsung double processing, tapi indikator kuat bahwa timeout terlalu pendek.

aws cloudwatch get-metric-statistics \
  --namespace AWS/SQS \
  --metric-name ApproximateNumberOfMessagesNotVisible \
  --dimensions Name=QueueName,Value=my-order-queue \
  --start-time 2024-01-15T00:00:00Z \
  --end-time 2024-01-15T01:00:00Z \
  --period 300 \
  --statistics Maximum

Langkah 3: Ukur Waktu Pemrosesan Aktual

Jangan andalkan estimasi. Tambahkan logging di consumer untuk mencatat waktu antara receive dan delete. Kamu butuh nilai P99, bukan rata-rata — karena visibility timeout harus mengakomodasi kasus terburuk, bukan kasus normal.

# Contoh pseudocode di consumer (Python)
import time
import boto3

sqs = boto3.client('sqs', region_name='us-east-1')
queue_url = 'https://sqs.us-east-1.amazonaws.com/123456789012/my-order-queue'

response = sqs.receive_message(
    QueueUrl=queue_url,
    MaxNumberOfMessages=1,
    WaitTimeSeconds=20
)

if 'Messages' in response:
    message = response['Messages'][0]
    receipt_handle = message['ReceiptHandle']
    start_time = time.time()

    try:
        # Proses pesan di sini
        process_order(message['Body'])

        elapsed = time.time() - start_time
        print(f"Processing time: {elapsed:.2f}s")

        sqs.delete_message(
            QueueUrl=queue_url,
            ReceiptHandle=receipt_handle
        )
    except Exception as e:
        elapsed = time.time() - start_time
        print(f"Failed after: {elapsed:.2f}s, error: {e}")
        # Jangan delete — biarkan timeout habis atau gunakan ChangeMessageVisibility

Solusi: Memperbaiki Konfigurasi Visibility Timeout

Solusi 1: Set Visibility Timeout yang Tepat di Level Queue

Aturan praktisnya: set visibility timeout minimal 6 kali waktu pemrosesan P99. Faktor 6x terdengar berlebihan, tapi ini mengakomodasi retry logic, cold start, dan kondisi degraded. Untuk queue dengan pemrosesan yang bisa memakan waktu hingga 2 menit di P99, set timeout ke 12 menit.

aws sqs set-queue-attributes \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-order-queue \
  --attributes VisibilityTimeout=720

Perubahan ini berlaku untuk semua pesan yang di-receive setelah perubahan diterapkan. Pesan yang sudah in-flight menggunakan visibility timeout yang berlaku saat mereka di-receive.

Solusi 2: Perpanjang Visibility Timeout Secara Dinamis dengan ChangeMessageVisibility

Untuk pemrosesan dengan durasi yang tidak bisa diprediksi — misalnya memanggil external API dengan SLA yang bervariasi — pendekatan yang lebih robust adalah memperpanjang visibility timeout secara berkala selama pemrosesan berlangsung. Consumer memanggil ChangeMessageVisibility sebelum timeout habis untuk 'mereset' countdown.

🔽 Klik untuk melihat contoh implementasi heartbeat dengan ChangeMessageVisibility
import time
import threading
import boto3

sqs = boto3.client('sqs', region_name='us-east-1')
queue_url = 'https://sqs.us-east-1.amazonaws.com/123456789012/my-order-queue'

def extend_visibility(receipt_handle, stop_event, extension_seconds=60, interval=45):
    """Perpanjang visibility timeout setiap 'interval' detik."""
    while not stop_event.is_set():
        time.sleep(interval)
        if not stop_event.is_set():
            try:
                sqs.change_message_visibility(
                    QueueUrl=queue_url,
                    ReceiptHandle=receipt_handle,
                    VisibilityTimeout=extension_seconds
                )
                print(f"Visibility extended by {extension_seconds}s")
            except sqs.exceptions.MessageNotInflight:
                # Pesan sudah dihapus atau timeout sudah habis
                break

response = sqs.receive_message(
    QueueUrl=queue_url,
    MaxNumberOfMessages=1,
    WaitTimeSeconds=20,
    VisibilityTimeout=60  # Initial timeout 60 detik
)

if 'Messages' in response:
    message = response['Messages'][0]
    receipt_handle = message['ReceiptHandle']

    stop_event = threading.Event()
    heartbeat = threading.Thread(
        target=extend_visibility,
        args=(receipt_handle, stop_event)
    )
    heartbeat.daemon = True
    heartbeat.start()

    try:
        process_order(message['Body'])
        sqs.delete_message(
            QueueUrl=queue_url,
            ReceiptHandle=receipt_handle
        )
    finally:
        stop_event.set()
        heartbeat.join()

Satu hal yang sering terlewat: ChangeMessageVisibility tidak bisa dipanggil setelah visibility timeout habis. Jika timeout sudah habis dan pesan sudah kembali visible (atau sudah di-receive consumer lain), panggilan ini akan mengembalikan error MessageNotInflight. Artinya heartbeat interval harus lebih pendek dari timeout yang sedang berjalan — bukan sama atau lebih panjang.

Solusi 3: Desain Consumer yang Idempoten

Visibility timeout adalah mekanisme pencegahan, bukan jaminan. SQS secara eksplisit mendokumentasikan at-least-once delivery — dalam kondisi tertentu (misalnya hardware failure di sisi SQS), pesan bisa terkirim lebih dari sekali meskipun visibility timeout dikonfigurasi dengan benar. Satu-satunya cara untuk benar-benar aman dari double processing adalah membuat consumer idempoten.

Implementasi paling praktis: simpan MessageId SQS di database dengan unique constraint sebelum memproses. Jika insert gagal karena duplicate, skip pemrosesan. Ini membutuhkan satu round-trip database tambahan per pesan, tapi mengeliminasi risiko side effect ganda.

# Contoh idempotency check menggunakan DynamoDB
import boto3
from botocore.exceptions import ClientError

dynamodb = boto3.client('dynamodb', region_name='us-east-1')

def is_already_processed(message_id):
    """Return True jika pesan sudah pernah diproses."""
    try:
        dynamodb.put_item(
            TableName='processed-messages',
            Item={
                'MessageId': {'S': message_id},
                'ProcessedAt': {'S': str(time.time())}
            },
            ConditionExpression='attribute_not_exists(MessageId)'
        )
        return False  # Insert berhasil, pesan belum pernah diproses
    except ClientError as e:
        if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
            return True  # Pesan sudah pernah diproses
        raise

Kasus Nyata: Misdiagnosis yang Sering Terjadi

Tim menerima laporan bahwa beberapa transaksi diproses dua kali, tapi hanya saat traffic tinggi. Investigasi awal mengarah ke bug di application code — mungkin ada loop yang tidak sengaja memanggil fungsi pemrosesan dua kali. Semua unit test hijau, tidak ada loop yang mencurigakan.

Setelah menambahkan logging timestamp yang lebih detail, ditemukan bahwa dua pemrosesan terjadi dari instance berbeda, bukan dari instance yang sama. Log menunjukkan:

[instance-A] 14:32:01 - Received message msg-abc123, starting processing
[instance-B] 14:32:31 - Received message msg-abc123, starting processing
[instance-A] 14:32:45 - Completed processing msg-abc123, deleting
[instance-B] 14:32:58 - Completed processing msg-abc123, deleting

Instance A menerima pesan pukul 14:32:01. Visibility timeout 30 detik habis pukul 14:32:31 — tepat saat traffic spike membuat pemrosesan lebih lambat dari biasanya. Instance B mengambil pesan yang sama 1 detik setelah timeout habis.

Asumsi awalnya salah: masalah bukan di kode, tapi di gap antara visibility timeout (30 detik) dan waktu pemrosesan aktual saat load tinggi (44 detik). Solusinya: naikkan visibility timeout ke 5 menit dan tambahkan idempotency check sebagai safety net.

IAM: Permission yang Dibutuhkan Consumer

Consumer SQS membutuhkan permission spesifik untuk operasi yang dibahas di atas. Berikut policy dengan least privilege untuk skenario ini:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SQSConsumerPermissions",
      "Effect": "Allow",
      "Action": [
        "sqs:ReceiveMessage",
        "sqs:DeleteMessage",
        "sqs:ChangeMessageVisibility",
        "sqs:GetQueueAttributes"
      ],
      "Resource": "arn:aws:sqs:us-east-1:123456789012:my-order-queue"
    }
  ]
}

Jangan tambahkan sqs:SendMessage ke consumer role kecuali memang dibutuhkan. Pisahkan producer role dan consumer role.

Memahami SQS Visibility Timeout: Dead Letter Queue sebagai Safety Net

Visibility timeout yang terlalu panjang punya trade-off: jika consumer crash setelah menerima pesan, pesan tidak akan tersedia kembali sampai timeout habis. Untuk pemrosesan yang memakan waktu lama, ini bisa berarti delay recovery yang signifikan.

Kombinasikan visibility timeout yang tepat dengan Dead Letter Queue (DLQ). Set maxReceiveCount di redrive policy — jika pesan di-receive lebih dari N kali tanpa berhasil dihapus, SQS otomatis memindahkannya ke DLQ. Ini memisahkan 'pesan yang butuh waktu lama' dari 'pesan yang memang bermasalah'.

# Set redrive policy untuk memindahkan pesan ke DLQ setelah 3 kali receive
aws sqs set-queue-attributes \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-order-queue \
  --attributes '{
    "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:123456789012:my-order-dlq\",\"maxReceiveCount\":\"3\"}",
    "VisibilityTimeout": "720"
  }'

Wrap-Up dan Langkah Selanjutnya

Double processing di SQS hampir selalu berakar dari satu dari dua hal: visibility timeout lebih pendek dari waktu pemrosesan aktual, atau consumer tidak mengimplementasikan idempotency. Keduanya perlu ditangani — visibility timeout yang tepat mengurangi frekuensi double delivery, idempotency mengeliminasi konsekuensinya.

Langkah konkret yang bisa dilakukan sekarang:

  1. Ukur P99 processing time dari log production, bukan dari estimasi.
  2. Set visibility timeout ke nilai yang lebih besar dari P99, dengan buffer yang cukup.
  3. Implementasikan ChangeMessageVisibility heartbeat untuk pemrosesan dengan durasi tidak pasti.
  4. Tambahkan idempotency check di consumer sebagai safety net.
  5. Konfigurasi DLQ dengan maxReceiveCount yang sesuai.

Referensi resmi: AWS SQS Visibility Timeout Documentation.

Glossary

IstilahDefinisi
Visibility TimeoutDurasi pesan tidak terlihat oleh consumer lain setelah di-receive. Default 30 detik, maksimum 12 jam.
At-Least-Once DeliveryJaminan SQS bahwa pesan akan terkirim minimal satu kali, tapi tidak menjamin tepat satu kali.
ReceiptHandleToken unik yang diterima consumer saat mengambil pesan. Digunakan untuk DeleteMessage dan ChangeMessageVisibility. Berbeda dari MessageId.
Dead Letter Queue (DLQ)Queue terpisah tempat SQS memindahkan pesan yang gagal diproses setelah mencapai batas maxReceiveCount.
IdempotencyProperti operasi yang menghasilkan hasil sama meskipun dieksekusi lebih dari satu kali. Kunci untuk menangani at-least-once delivery dengan aman.

Komentar

Postingan populer dari blog ini

EC2 Tidak Bisa Akses Internet di Custom VPC: Cara Pasang Internet Gateway dan Update Route Table