iachat/spec/services/whatsapp
Shivam Mishra 39243b9e71
fix: duplicate message_created webhooks for WhatsApp messages (#13523)
Some customers using WhatsApp inboxes with account-level webhooks were
reporting receiving duplicate `message_created` webhook deliveries for
every incoming message. Upon inspection, here's what we found

- Both payloads are identical.
- No errors appear in the application logs
- Webhook URL is only configured in one place. 

This meant, the system was sending the webhooks twice. For some context,
there's a know related issue... Meta's WhatsApp Business API can deliver
the same webhook notification multiple times for a single message. The
codebase already acknowledges this — there's a comment in
`IncomingMessageBaseService#process_messages` noting that "multiple
webhook events can be received against the same message due to
misconfigurations in the Meta business manager account." A deduplication
guard exists, but it doesn't actually work under concurrency.

### Rationale

The existing dedup was a three-step sequence: check Redis (`GET`), check
the database, then set a Redis flag (`SETEX`). Two Sidekiq workers
processing duplicate Meta webhooks simultaneously would both complete
the `GET` before either executed the `SETEX`, so both would proceed to
create a message. The `source_id` column has a non-unique index, so the
database wouldn't catch the duplicate either. Each message then
independently fires `after_create_commit`, dispatching two
`message_created` webhook events to the customer.

```
             Worker A                          Worker B
                │                                 │
                ▼                                 ▼
        Redis GET key ──► nil               Redis GET key ──► nil
                │                                 │
                │    ◄── both pass guard ──►      │
                │                                 │
                ▼                                 ▼
        Redis SETEX key                    Redis SETEX key
                │                                 │
                ▼                                 ▼
        BEGIN transaction               BEGIN transaction
        INSERT message                   INSERT message
        DELETE Redis key ◄─┐                      │
        COMMIT             │             DELETE Redis key
                           │             COMMIT
                           │                      │
                           └── key gone before ───┘
                              B's commit lands

                ▼                                 ▼
        after_create_commit              after_create_commit
        dispatch MESSAGE_CREATED         dispatch MESSAGE_CREATED
                │                                 │
                ▼                                 ▼
        WebhookJob ──► n8n               WebhookJob ──► n8n
                    (duplicate!)
```

There was a second, subtler problem visible in the diagram: the Redis
key was cleared *inside* the database transaction, before the
transaction committed. This opened a window where neither the Redis
check nor the database check would see the in-flight message.

The fix collapses the check-and-set into a single `SET NX EX` call,
which is atomic in Redis. The key is no longer eagerly cleared — it
expires naturally after 24 hours. The database lookup
(`find_message_by_source_id`) remains as a fallback for messages that
were created before the lock expired.

```
             Worker A                          Worker B
                │                                 │
                ▼                                 ▼
        Redis SET NX ──► OK              Redis SET NX ──► nil
                │                                 │
                ▼                                 ▼
        proceeds to create              returns early
        message normally                (lock already held)
```

### Implementation Notes

The lock logic is extracted into `Whatsapp::MessageDedupLock`, a small
class that wraps a single `Redis SET NX EX` call. This makes the
concurrency guarantee testable in isolation — the spec uses a
`CyclicBarrier` to race two threads against the same key and asserts
exactly one wins, without needing database writes,
`use_transactional_tests = false`, or monkey-patching.

Because the Redis lock now persists (instead of being cleared
mid-transaction), existing WhatsApp specs needed an `after` hook to
clean up `MESSAGE_SOURCE_KEY::*` keys between examples. Transactional
fixtures only roll back the database, not Redis.
2026-02-17 14:01:10 +05:30
..
providers feat: Add backend changes for whatsapp csat template (#12984) 2025-12-11 16:36:37 +05:30
channel_creation_service_spec.rb fix: Remove the same account validation for whatsapp channels (#12811) 2025-11-06 21:18:52 +05:30
csat_template_service_spec.rb feat: Add backend changes for whatsapp csat template (#12984) 2025-12-11 16:36:37 +05:30
embedded_signup_service_spec.rb feat: Add WhatsApp health monitoring and self-service registration completion (#12556) 2025-10-02 11:25:48 +05:30
facebook_api_client_spec.rb fix: Subscribe app to WABA before overriding webhook callback URL (#13279) 2026-02-02 16:50:35 +05:30
incoming_message_service_spec.rb fix: duplicate message_created webhooks for WhatsApp messages (#13523) 2026-02-17 14:01:10 +05:30
incoming_message_whatsapp_cloud_service_spec.rb fix: duplicate message_created webhooks for WhatsApp messages (#13523) 2026-02-17 14:01:10 +05:30
message_dedup_lock_spec.rb fix: duplicate message_created webhooks for WhatsApp messages (#13523) 2026-02-17 14:01:10 +05:30
oneoff_campaign_service_spec.rb fix: Improve WhatsApp template message error handling (#12168) 2025-08-12 16:31:56 +05:30
phone_info_service_spec.rb feat: Whatsapp embedded signup (#11612) 2025-07-14 21:37:06 -07:00
populate_template_parameters_service_spec.rb fix: Normalize URLs with spaces in WhatsApp template parameters (#12594) 2025-10-08 15:33:06 +05:30
send_on_whatsapp_service_spec.rb fix: Force account_id in the query (#13388) 2026-01-28 14:51:24 -08:00
template_parameter_converter_service_spec.rb fix: Handle nil processed_params for WhatsApp templates without params (#12177) 2025-08-12 22:56:53 +05:30
token_exchange_service_spec.rb feat: Whatsapp embedded signup (#11612) 2025-07-14 21:37:06 -07:00
token_validation_service_spec.rb feat: Whatsapp embedded signup (#11612) 2025-07-14 21:37:06 -07:00
webhook_setup_service_spec.rb fix: Setup webhooks for manual WhatsApp Cloud channel creation (#13278) 2026-01-19 14:12:36 +04:00
webhook_teardown_service_spec.rb feat: add reauth flow for wa embedded signup (#11940) 2025-08-08 01:48:45 +05:30