iachat/app
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
..
actions fix: Disable enqueueing Avatar jobs if the URL is invalid (#12035) 2025-07-24 12:56:39 +04:00
assets feat: Hide installation identifier (#11722) 2025-06-17 15:45:40 -07:00
builders feat: Add standalone outgoing messages count API endpoint (#13419) 2026-02-04 19:36:50 +05:30
channels fix: Move contact events to account stream rather than individual user stream (#11082) 2025-03-13 17:46:48 -07:00
controllers fix: Prevent user enumeration on password reset endpoint (#13528) 2026-02-13 13:45:40 +05:30
dashboards feat: Auto confirm user email when super admin make changes (#12418) 2025-09-23 20:14:02 +05:30
dispatchers feat(ee): Add Captain features (#10665) 2025-01-14 16:15:47 -08:00
drops feat: Add the support for custom attributes in message variables (#8511) 2023-12-08 14:13:35 -08:00
fields chore: improve plan-based feature handling with plan hierarchy (#11335) 2025-04-28 14:13:56 -07:00
finders feat: add SKIP_INCOMING_BCC_PROCESSING as internal config (#12484) 2025-09-22 17:52:56 +05:30
helpers feat: new Captain Editor (#13235) 2026-01-21 13:39:07 +05:30
javascript fix: Persist compose form state on accidental outside click (#13529) 2026-02-17 13:57:44 +05:30
jobs fix: handle 404 errors gracefully in avatar download job (#13491) 2026-02-09 13:27:23 +05:30
listeners fix: prevent deserialization error on deletion (#13264) 2026-01-14 18:00:12 +05:30
mailboxes refactor: strategy pattern for mailbox conversation finding (#12766) 2025-11-10 20:47:18 +05:30
mailers feat: add per-account daily rate limit for outbound emails (#13411) 2026-02-03 02:06:51 +05:30
models fix: Enforce team boundaries to prevent cross-team assignments (#13353) 2026-02-16 14:39:20 +05:30
policies feat: new Captain Editor (#13235) 2026-01-21 13:39:07 +05:30
presenters fix(mailbox): handle malformed sender address headers (#13486) 2026-02-11 11:02:38 -08:00
services fix: duplicate message_created webhooks for WhatsApp messages (#13523) 2026-02-17 14:01:10 +05:30
views feat: Add infinite scroll to contacts search page (#13376) 2026-01-27 18:55:19 -08:00