fix: whatsapp race condition (#185)
* feat: include external created at in whatsapp cloud message * fix: whatsapp providers race condition * refactor: remove redundant comments and streamline message processing lock acquisition * fix: scope message source key by inbox.id to prevent race condition * fix: scope message source key by inbox.id to prevent race condition
This commit is contained in:
parent
43d7642485
commit
f54d113571
@ -197,9 +197,9 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
|
|||||||
Redis::Alfred.get(key)
|
Redis::Alfred.get(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
def cache_message_source_id_in_redis
|
def acquire_message_processing_lock
|
||||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}")
|
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}")
|
||||||
::Redis::Alfred.setex(key, true)
|
Redis::Alfred.set(key, true, nx: true, ex: 1.day)
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_message_source_id_from_redis
|
def clear_message_source_id_from_redis
|
||||||
|
|||||||
@ -24,9 +24,10 @@ module Whatsapp::BaileysHandlers::MessagesUpsert # rubocop:disable Metrics/Modul
|
|||||||
return unless %w[lid user].include?(jid_type)
|
return unless %w[lid user].include?(jid_type)
|
||||||
return unless extract_from_jid(type: 'lid')
|
return unless extract_from_jid(type: 'lid')
|
||||||
return if ignore_message?
|
return if ignore_message?
|
||||||
return if find_message_by_source_id(raw_message_id) || message_under_process?
|
return if find_message_by_source_id(raw_message_id)
|
||||||
|
|
||||||
|
return unless acquire_message_processing_lock
|
||||||
|
|
||||||
cache_message_source_id_in_redis
|
|
||||||
set_contact
|
set_contact
|
||||||
|
|
||||||
unless @contact
|
unless @contact
|
||||||
|
|||||||
@ -26,9 +26,10 @@ class Whatsapp::IncomingMessageBaseService
|
|||||||
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
|
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
|
||||||
# business manager account. While we have not found the core reason yet, the following line ensure that
|
# business manager account. While we have not found the core reason yet, the following line ensure that
|
||||||
# there are no duplicate messages created.
|
# there are no duplicate messages created.
|
||||||
return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process?
|
return if find_message_by_source_id(@processed_params[:messages].first[:id])
|
||||||
|
|
||||||
|
return unless acquire_message_processing_lock
|
||||||
|
|
||||||
cache_message_source_id_in_redis
|
|
||||||
set_contact
|
set_contact
|
||||||
return unless @contact
|
return unless @contact
|
||||||
|
|
||||||
@ -154,7 +155,8 @@ class Whatsapp::IncomingMessageBaseService
|
|||||||
message_type: :incoming,
|
message_type: :incoming,
|
||||||
sender: @contact,
|
sender: @contact,
|
||||||
source_id: message[:id].to_s,
|
source_id: message[:id].to_s,
|
||||||
in_reply_to_external_id: @in_reply_to_external_id
|
in_reply_to_external_id: @in_reply_to_external_id,
|
||||||
|
external_created_at: message[:timestamp].to_i
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -66,23 +66,23 @@ module Whatsapp::IncomingMessageServiceHelpers
|
|||||||
def find_message_by_source_id(source_id)
|
def find_message_by_source_id(source_id)
|
||||||
return unless source_id
|
return unless source_id
|
||||||
|
|
||||||
@message = Message.find_by(source_id: source_id)
|
@message = inbox.messages.find_by(source_id: source_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_under_process?
|
def message_under_process?
|
||||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
|
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{@processed_params[:messages].first[:id]}")
|
||||||
Redis::Alfred.get(key)
|
Redis::Alfred.get(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
def cache_message_source_id_in_redis
|
def acquire_message_processing_lock
|
||||||
return if @processed_params.try(:[], :messages).blank?
|
return false if @processed_params.try(:[], :messages).blank?
|
||||||
|
|
||||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
|
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{@processed_params[:messages].first[:id]}")
|
||||||
::Redis::Alfred.setex(key, true)
|
Redis::Alfred.set(key, true, nx: true, ex: 1.day)
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_message_source_id_from_redis
|
def clear_message_source_id_from_redis
|
||||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
|
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{@processed_params[:messages].first[:id]}")
|
||||||
::Redis::Alfred.delete(key)
|
::Redis::Alfred.delete(key)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -11,9 +11,9 @@ module Whatsapp::ZapiHandlers::Helpers
|
|||||||
!@raw_message[:fromMe]
|
!@raw_message[:fromMe]
|
||||||
end
|
end
|
||||||
|
|
||||||
def cache_message_source_id_in_redis
|
def acquire_message_processing_lock
|
||||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}")
|
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}")
|
||||||
Redis::Alfred.setex(key, true)
|
Redis::Alfred.set(key, true, nx: true, ex: 1.day)
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_message_source_id_from_redis
|
def clear_message_source_id_from_redis
|
||||||
|
|||||||
@ -3,16 +3,19 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def process_received_callback
|
def process_received_callback # rubocop:disable Metrics/MethodLength
|
||||||
@raw_message = processed_params
|
@raw_message = processed_params
|
||||||
@message = nil
|
@message = nil
|
||||||
@contact_inbox = nil
|
@contact_inbox = nil
|
||||||
@contact = nil
|
@contact = nil
|
||||||
|
@lock_acquired = false
|
||||||
|
|
||||||
return unless should_process_message?
|
return unless should_process_message?
|
||||||
return if find_message_by_source_id(raw_message_id) || message_under_process?
|
return if find_message_by_source_id(raw_message_id)
|
||||||
|
|
||||||
cache_message_source_id_in_redis
|
# Atomically acquire lock to prevent race conditions with concurrent webhook deliveries
|
||||||
|
@lock_acquired = acquire_message_processing_lock
|
||||||
|
return unless @lock_acquired
|
||||||
|
|
||||||
return handle_edited_message if @raw_message[:isEdit]
|
return handle_edited_message if @raw_message[:isEdit]
|
||||||
|
|
||||||
@ -28,7 +31,7 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
|
|||||||
handle_create_message
|
handle_create_message
|
||||||
end
|
end
|
||||||
ensure
|
ensure
|
||||||
clear_message_source_id_from_redis
|
clear_message_source_id_from_redis if @lock_acquired
|
||||||
end
|
end
|
||||||
|
|
||||||
def should_process_message?
|
def should_process_message?
|
||||||
|
|||||||
@ -452,20 +452,25 @@ describe Whatsapp::IncomingMessageBaileysService do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'does not create a message if it is already being processed' do
|
it 'does not create a message if it is already being processed' do
|
||||||
allow(Redis::Alfred).to receive(:get).with(format_message_source_key('msg_123')).and_return(true)
|
# Simulate lock already acquired by returning false from SETNX
|
||||||
|
allow(Redis::Alfred).to receive(:set)
|
||||||
|
.with(format_message_source_key('msg_123'), true, nx: true, ex: 1.day)
|
||||||
|
.and_return(false)
|
||||||
|
|
||||||
described_class.new(inbox: inbox, params: params).perform
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
|
||||||
expect(inbox.conversations).to be_empty
|
expect(inbox.conversations).to be_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'caches the message source id in Redis and clears it' do
|
it 'caches and clears message source id in Redis' do
|
||||||
allow(Redis::Alfred).to receive(:setex).with(format_message_source_key('msg_123'), true)
|
allow(Redis::Alfred).to receive(:set)
|
||||||
|
.with(format_message_source_key('msg_123'), true, nx: true, ex: 1.day)
|
||||||
|
.and_return(true)
|
||||||
allow(Redis::Alfred).to receive(:delete).with(format_message_source_key('msg_123'))
|
allow(Redis::Alfred).to receive(:delete).with(format_message_source_key('msg_123'))
|
||||||
|
|
||||||
described_class.new(inbox: inbox, params: params).perform
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
|
||||||
expect(Redis::Alfred).to have_received(:setex)
|
expect(Redis::Alfred).to have_received(:set)
|
||||||
expect(Redis::Alfred).to have_received(:delete)
|
expect(Redis::Alfred).to have_received(:delete)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -24,6 +24,12 @@ describe Whatsapp::IncomingMessageService do
|
|||||||
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
|
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'stores the external_created_at timestamp from the message' do
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
message = whatsapp_channel.inbox.messages.first
|
||||||
|
expect(message.external_created_at).to eq(1_633_034_394)
|
||||||
|
end
|
||||||
|
|
||||||
it 'appends to last conversation when if conversation already exists' do
|
it 'appends to last conversation when if conversation already exists' do
|
||||||
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
|
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
|
||||||
2.times.each { create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) }
|
2.times.each { create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) }
|
||||||
@ -78,6 +84,123 @@ describe Whatsapp::IncomingMessageService do
|
|||||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
expect(whatsapp_channel.inbox.messages.count).to eq(1)
|
expect(whatsapp_channel.inbox.messages.count).to eq(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'will not create duplicate conversations when same message is received for new contact' do
|
||||||
|
# First call creates contact, conversation and message
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||||
|
expect(whatsapp_channel.inbox.messages.count).to eq(1)
|
||||||
|
|
||||||
|
# Second call with same message ID should not create duplicate conversation
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||||
|
expect(whatsapp_channel.inbox.messages.count).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'will not create duplicate conversations when same message is processed concurrently' do
|
||||||
|
# Simulate concurrent processing by having Redis key cleared before second call
|
||||||
|
# but message not yet visible in database due to transaction isolation
|
||||||
|
service1 = described_class.new(inbox: whatsapp_channel.inbox, params: params)
|
||||||
|
service2 = described_class.new(inbox: whatsapp_channel.inbox, params: params)
|
||||||
|
|
||||||
|
# Process first message
|
||||||
|
service1.perform
|
||||||
|
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||||
|
|
||||||
|
# Even if we clear the Redis key manually, should still not create duplicates
|
||||||
|
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: params[:messages].first[:id])
|
||||||
|
Redis::Alfred.delete(key)
|
||||||
|
|
||||||
|
# Second call should check database and find existing message
|
||||||
|
service2.perform
|
||||||
|
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||||
|
expect(whatsapp_channel.inbox.messages.count).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'prevents race condition with atomic lock when two requests arrive simultaneously' do
|
||||||
|
# This test simulates the exact race condition that was causing duplicate conversations
|
||||||
|
# Two concurrent webhook deliveries for the same message should result in only one being processed
|
||||||
|
threads_started = Concurrent::CountDownLatch.new(2)
|
||||||
|
|
||||||
|
thread1 = Thread.new do
|
||||||
|
threads_started.count_down
|
||||||
|
threads_started.wait # Wait for both threads to be ready
|
||||||
|
service = described_class.new(inbox: whatsapp_channel.inbox, params: params)
|
||||||
|
service.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
thread2 = Thread.new do
|
||||||
|
threads_started.count_down
|
||||||
|
threads_started.wait # Wait for both threads to be ready
|
||||||
|
service = described_class.new(inbox: whatsapp_channel.inbox, params: params)
|
||||||
|
service.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
thread1.join
|
||||||
|
thread2.join
|
||||||
|
|
||||||
|
# Only one conversation and one message should exist
|
||||||
|
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||||
|
expect(whatsapp_channel.inbox.messages.count).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'prevents duplicate when both requests pass the message_under_process? check before cache is set' do
|
||||||
|
# This test explicitly simulates the race condition timing:
|
||||||
|
# 1. Request A calls message_under_process? -> returns nil (no lock)
|
||||||
|
# 2. Request B calls message_under_process? -> returns nil (no lock yet!)
|
||||||
|
# 3. Request A calls cache_message_source_id_in_redis
|
||||||
|
# 4. Request B calls cache_message_source_id_in_redis
|
||||||
|
# 5. Both requests pass find_message_by_source_id (message not yet committed)
|
||||||
|
# 6. Both create messages -> DUPLICATE!
|
||||||
|
#
|
||||||
|
# With atomic lock (SETNX), only one can succeed.
|
||||||
|
|
||||||
|
# Key is scoped by inbox.id to prevent cross-inbox lock collisions
|
||||||
|
message_source_key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{whatsapp_channel.inbox.id}_#{params[:messages].first[:id]}")
|
||||||
|
lock_acquired = false
|
||||||
|
|
||||||
|
# Simulate atomic SETNX: only the first call returns true
|
||||||
|
allow(Redis::Alfred).to receive(:set).with(message_source_key, true, nx: true, ex: 1.day) do
|
||||||
|
if lock_acquired
|
||||||
|
false # Second caller fails to acquire lock
|
||||||
|
else
|
||||||
|
lock_acquired = true
|
||||||
|
true # First caller acquires lock
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
allow(Redis::Alfred).to receive(:delete).with(message_source_key)
|
||||||
|
|
||||||
|
# Run two services "simultaneously" (both pass the check before either sets the lock)
|
||||||
|
service1 = described_class.new(inbox: whatsapp_channel.inbox, params: params)
|
||||||
|
service2 = described_class.new(inbox: whatsapp_channel.inbox, params: params)
|
||||||
|
|
||||||
|
# Mock find_message_by_source_id on specific instances (simulating uncommitted transaction)
|
||||||
|
allow(service1).to receive(:find_message_by_source_id).and_return(nil)
|
||||||
|
allow(service2).to receive(:find_message_by_source_id).and_return(nil)
|
||||||
|
|
||||||
|
service1.perform
|
||||||
|
service2.perform
|
||||||
|
|
||||||
|
# With atomic lock, only ONE should create a message
|
||||||
|
expect(whatsapp_channel.inbox.messages.count).to eq(1)
|
||||||
|
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates message in second inbox when same source_id exists in different inbox' do
|
||||||
|
# Create a message with same source_id in a different inbox
|
||||||
|
other_whatsapp_channel = create(:channel_whatsapp, sync_templates: false)
|
||||||
|
other_inbox = other_whatsapp_channel.inbox
|
||||||
|
other_contact = create(:contact, account: other_inbox.account)
|
||||||
|
other_contact_inbox = create(:contact_inbox, inbox: other_inbox, contact: other_contact)
|
||||||
|
other_conversation = create(:conversation, inbox: other_inbox, contact_inbox: other_contact_inbox)
|
||||||
|
create(:message, conversation: other_conversation, inbox: other_inbox, source_id: params[:messages].first[:id])
|
||||||
|
|
||||||
|
# Should still create message in our inbox since source_id check should be inbox-scoped
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||||
|
expect(whatsapp_channel.inbox.messages.count).to eq(1)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when unsupported message types' do
|
context 'when unsupported message types' do
|
||||||
@ -432,7 +555,8 @@ describe Whatsapp::IncomingMessageService do
|
|||||||
] }] }.with_indifferent_access
|
] }] }.with_indifferent_access
|
||||||
|
|
||||||
expect(Message.find_by(source_id: 'wamid.SDFADSf23sfasdafasdfa')).not_to be_present
|
expect(Message.find_by(source_id: 'wamid.SDFADSf23sfasdafasdfa')).not_to be_present
|
||||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: 'wamid.SDFADSf23sfasdafasdfa')
|
# Key is scoped by inbox.id to prevent cross-inbox lock collisions
|
||||||
|
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{whatsapp_channel.inbox.id}_wamid.SDFADSf23sfasdafasdfa")
|
||||||
|
|
||||||
Redis::Alfred.setex(key, true)
|
Redis::Alfred.setex(key, true)
|
||||||
expect(Redis::Alfred.get(key)).to be_truthy
|
expect(Redis::Alfred.get(key)).to be_truthy
|
||||||
|
|||||||
@ -825,9 +825,10 @@ describe Whatsapp::ZapiHandlers::ReceivedCallback do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'does not create message if it is already being processed' do
|
it 'does not create message if it is already being processed' do
|
||||||
allow(Redis::Alfred).to receive(:get)
|
# Simulate lock already acquired by returning false from SETNX
|
||||||
.with(format_message_source_key('duplicate_123'))
|
allow(Redis::Alfred).to receive(:set)
|
||||||
.and_return(true)
|
.with(format_message_source_key('duplicate_123'), true, nx: true, ex: 1.day)
|
||||||
|
.and_return(false)
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
service.perform
|
service.perform
|
||||||
@ -835,16 +836,28 @@ describe Whatsapp::ZapiHandlers::ReceivedCallback do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'caches and clears message source id in Redis' do
|
it 'caches and clears message source id in Redis' do
|
||||||
allow(Redis::Alfred).to receive(:setex)
|
allow(Redis::Alfred).to receive(:set).and_return(true)
|
||||||
allow(Redis::Alfred).to receive(:delete)
|
allow(Redis::Alfred).to receive(:delete)
|
||||||
|
|
||||||
service.perform
|
service.perform
|
||||||
|
|
||||||
expect(Redis::Alfred).to have_received(:setex)
|
expect(Redis::Alfred).to have_received(:set)
|
||||||
.with(format_message_source_key('duplicate_123'), true)
|
.with(format_message_source_key('duplicate_123'), true, nx: true, ex: 1.day)
|
||||||
expect(Redis::Alfred).to have_received(:delete)
|
expect(Redis::Alfred).to have_received(:delete)
|
||||||
.with(format_message_source_key('duplicate_123'))
|
.with(format_message_source_key('duplicate_123'))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'does not clear lock when acquisition fails' do
|
||||||
|
allow(Redis::Alfred).to receive(:set)
|
||||||
|
.with(format_message_source_key('duplicate_123'), true, nx: true, ex: 1.day)
|
||||||
|
.and_return(false)
|
||||||
|
allow(Redis::Alfred).to receive(:delete)
|
||||||
|
|
||||||
|
service.perform
|
||||||
|
|
||||||
|
expect(Redis::Alfred).not_to have_received(:delete)
|
||||||
|
.with(format_message_source_key('duplicate_123'))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when attachment download fails' do
|
context 'when attachment download fails' do
|
||||||
@ -1238,6 +1251,10 @@ describe Whatsapp::ZapiHandlers::ReceivedCallback do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'waits for the lock if it is already acquired' do
|
it 'waits for the lock if it is already acquired' do
|
||||||
|
# Stub the message processing lock to always succeed
|
||||||
|
allow(Redis::Alfred).to receive(:set)
|
||||||
|
.with(format_message_source_key('msg_123'), true, nx: true, ex: 1.day)
|
||||||
|
.and_return(true)
|
||||||
allow(Redis::Alfred).to receive(:set).with('ZAPI::CONTACT_LOCK::5511987654321', 1, nx: true, ex: 5.seconds).and_return(false, true)
|
allow(Redis::Alfred).to receive(:set).with('ZAPI::CONTACT_LOCK::5511987654321', 1, nx: true, ex: 5.seconds).and_return(false, true)
|
||||||
allow(Redis::Alfred).to receive(:delete)
|
allow(Redis::Alfred).to receive(:delete)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user