Skip to content

WhatsApp: reconnect drain replays all pending deliveries, causing 7-12x message duplication on cron sends #70386

Description

@natekot

Version

v2026.4.14 (regression from v2026.3.13-1)

Summary

Every WhatsApp socket reconnect now re-delivers all pending delivery-queue entries in full, including entries that are currently being delivered for the first time. On installations where the 30-minute inbound-silence watchdog fires regularly, this causes every outbound cron message to be sent 7–12 times.

Steps to reproduce

  1. Configure a cron job with announce or isolated-agent delivery targeting WhatsApp.
  2. Ensure the WhatsApp watchdog fires at its 30-minute timeout during a cron delivery window (i.e., no inbound messages are received to reset the timer).
  3. Observe the cron fires at a scheduled time — the outbound message is sent N+1 times, where N is the number of reconnects that occur while the original delivery is in-flight.

Root cause

In login-BmQuD5Uw.js (v2026.4.14), drainReconnectQueue was replaced by drainPendingDeliveries with this selectEntry:

// v2026.4.14 — NEW (broken)
drainPendingDeliveries({
  drainKey: `whatsapp:${normalizedAccountId}`,
  selectEntry: (entry) => ({
    match: entry.channel === "whatsapp" &&
      normalizeReconnectAccountId(entry.accountId) === normalizedAccountId,
    bypassBackoff: isNoListenerReconnectError(entry.lastError)
  })
})

The old drainReconnectQueue (now a deprecated shim) required entry.lastError to match "No active WhatsApp Web listener" before replaying an entry. A fresh entry with no lastError would not match and was left alone.

The new code matches any pending WhatsApp entry. Combined with isEntryEligibleForRecoveryRetry returning { eligible: true } for fresh entries (retryCount === 0 && lastAttemptAt === undefined), a delivery that was just enqueued milliseconds ago is immediately picked up and re-delivered by the drain — while the original deliverOutboundPayloadsCore call is still running. There is no in-memory claim held by the original delivery path, so claimRecoveryEntry does not protect against this.

Evidence

Railway logs for Apr 19–21 show two distinct burst patterns per cron window for the same destination JID hash:

13:00:20.860  Sending message → sha256:3e25bf2f15db   ← original delivery
13:00:21.267  Sending message → sha256:3e25bf2f15db   ← drain burst (4 sends in ~80ms)
13:00:21.289  Sending message → sha256:3e25bf2f15db
13:00:21.304  Sending message → sha256:3e25bf2f15db
13:00:21.320  Sending message → sha256:3e25bf2f15db
13:00:31.257  Sending message → sha256:3e25bf2f15db   ← second drain (reconnect #2)
13:00:31.272  Sending message → sha256:3e25bf2f15db
13:00:31.287  Sending message → sha256:3e25bf2f15db

Each burst has its own correlationId, confirming they are separate sendMessageWhatsApp invocations, not retries within a single send.

The cron/runs/*.jsonl records show only one delivered: true entry per run — the duplicate sends are invisible to the cron scheduler because the drain's deliver() call enqueues and acks a new entry, leaving no trace in run records.

Proposed fix

Restore the guard that prevents fresh entries from being drain-eligible:

selectEntry: (entry) => ({
  match: entry.channel === "whatsapp" &&
    normalizeReconnectAccountId(entry.accountId) === normalizedAccountId &&
    (entry.retryCount > 0 || entry.lastAttemptAt !== undefined),  // ← don't drain in-flight entries
  bypassBackoff: isNoListenerReconnectError(entry.lastError)
})

Or add a minimum-age guard in isEntryEligibleForRecoveryRetry so entries younger than a configurable threshold (e.g. 10s) are not yet drain-eligible.

Workaround

Pin to v2026.3.13-1.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions