diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb index bf8fc5425..b0bede8b0 100644 --- a/app/jobs/webhooks/whatsapp_events_job.rb +++ b/app/jobs/webhooks/whatsapp_events_job.rb @@ -1,5 +1,9 @@ -class Webhooks::WhatsappEventsJob < ApplicationJob +class Webhooks::WhatsappEventsJob < MutexApplicationJob queue_as :low + # Retry budget (19 × 2s = 38s) must exceed the 30s lock TTL set in `perform`, otherwise + # a webhook that arrives just after the lock is acquired can exhaust retries before the + # holder finishes and silently drop its message. + retry_on LockAcquisitionError, wait: 2.seconds, attempts: 20 def perform(params = {}) channel = find_channel_from_whatsapp_business_payload(params) @@ -9,6 +13,20 @@ class Webhooks::WhatsappEventsJob < ApplicationJob return end + sender_id = contact_sender_id(params) + return process_events(channel, params) if sender_id.blank? + + # Album uploads arrive as separate concurrent webhooks. Serialize per (inbox, contact) + # so the first webhook creates the conversation and the rest append to it. + # 30s TTL covers the attachment download + transaction — the default 1s expires + # mid-processing and lets a concurrent webhook re-acquire before the first commit. + key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: sender_id) + with_lock(key, 30.seconds) do + process_events(channel, params) + end + end + + def process_events(channel, params) if message_echo_event?(params) handle_message_echo(channel, params) else @@ -69,6 +87,16 @@ class Webhooks::WhatsappEventsJob < ApplicationJob private + # Echo payloads reverse the fields — `from` is the business number and `to` is the contact. + # Returns nil for status-only webhooks so they bypass the lock. + def contact_sender_id(params) + value = params.dig(:entry, 0, :changes, 0, :value) || params + message = (value[:messages] || value[:message_echoes])&.first + return if message.blank? + + message[:to] || message[:from] + end + def channel_is_inactive?(channel) return true if channel.blank? return true if channel.reauthorization_required? diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb index 893fb50ef..15ff553cc 100644 --- a/lib/redis/redis_keys.rb +++ b/lib/redis/redis_keys.rb @@ -43,6 +43,7 @@ module Redis::RedisKeys TIKTOK_REFRESH_TOKEN_MUTEX = 'TIKTOK_REFRESH_TOKEN_LOCK::%s'.freeze SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%s::%s'.freeze EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%s'.freeze + WHATSAPP_MESSAGE_MUTEX = 'WHATSAPP_MESSAGE_CREATE_LOCK::%s::%s'.freeze CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%s'.freeze CAPTAIN_DOCUMENT_SYNC_MUTEX = 'CAPTAIN_DOCUMENT_SYNC_LOCK::%s'.freeze