Skip to main content

Atomic Duplicate Guard

Status: Shipped 2026-05-17 on dev, on master once verified. Replaces the previous 2-min find-then-insert guard which had a TOCTOU race that allowed concurrent dupes through.

TL;DR

  • Every POST /api/messages claims an atomic lock on (workspaceId, recipientPhone, recipientEmail, exact body text) before sending.
  • If a second identical POST arrives within 15 minutes, it gets the original message back with duplicateOnly: true and no new send fires.
  • Works WITHOUT an Idempotency-Key header — automatic, server-side, no caller change required.
  • Idempotency-Key is still preferred when your client controls retries (24-hour window, exact-request match), but this guard exists for callers that don’t set one.

Why this exists

Real-world incident, 2026-05-15 → 2026-05-17 (Frank Sondors’ Mailforge integration → Tuco workspace org_3C059wfibDQCpSfA0rbXVFsoJUk): The fix closes the first two failure modes for every caller without requiring them to send Idempotency-Key.

What gets blocked vs. allowed

Blocked: two POSTs with byte-identical (workspaceId, recipientPhone, recipientEmail, message body) within 15 minutes. Allowed (NOT blocked):

How it works under the hood

  1. POST arrives, passes auth + validation.
  2. Tuco computes hash = sha256(workspaceId + recipientPhone + recipientEmail + body).
  3. Tuco attempts insertOne into message_dedup_locks with _id = hash and expiresAt = now + 15 min.
  4. Mongo’s built-in unique _id index decides the race:
    • Insert succeeds → caller owns the slot. Proceeds with the send. The resulting message ID is attached to the lock so future retries get the right sibling back. Lock is auto-released if the send hard-fails before the message doc lands.
    • Insert fails with E11000 → another request beat us. We look up the sibling message in messages (workspace + recipient + body, last 15 min) and return it with duplicateOnly: true. If the sibling hasn’t been written yet (the original is still in flight), we poll for up to 3 s, then return duplicateOnly: true, message: null rather than risk a duplicate send.
    • Insert fails with any other error → guard logs the error and falls through. The request proceeds without dedup protection. The guard is built to never break the send path.
  5. A TTL index on expiresAt (with expireAfterSeconds: 0) clears the lock 15 minutes after acquisition. Mongo’s TTL monitor sweeps every ~60 s, so worst-case lock lifetime is ~16 min.

Response shapes

Normal send

HTTP 201 (or 200 when an existing lead was reused).

Duplicate caught — sibling found

HTTP 200. No new send fires. message._id is the original sibling — your CRM workflow can branch on duplicateOnly === true and treat it as a no-op success.

Duplicate caught — original still in flight

HTTP 200. The guard saw an active sibling lock but the original’s message doc hadn’t landed within the 3-s poll window. Safer to refuse than risk a duplicate — when the original finishes, a fresh retry from your side will receive the standard “duplicate caught — sibling found” response.

Known sharp edges

  • Failed sends still return the failed sibling on retry within 15 min. If your first send ended in status: "failed" and you immediately retry with the same body, you’ll get the failed sibling back with duplicateOnly: true. To force a fresh send: change the body, wait 15 min for the lock to TTL, or manually delete the lock (db.collection('message_dedup_locks').deleteOne({ _id: '<hash>' })).
  • Polling cost. A duplicate request that arrives before the original’s message doc lands holds the connection for up to 3 s. Real-world: createMessage inserts the message doc within ~50 ms of acquiring the lock, so most polls resolve on the first iteration.
  • Lock leak on process crash. Mitigated by the TTL — locks always expire 15 min after acquisition regardless of process state.

How it interacts with Idempotency-Key

The Idempotency-Key header check at /api/messages runs before this guard. If your client sends an Idempotency-Key, retries of the same request return the original response from idempotency_keys (24-hour window) without ever reaching the dedup guard. Idempotency-Key is preferred when you control the caller. The dedup guard is the safety net for everyone else.

Loki events for ops

What’s NOT changing

  • (workspaceId, contactIdentifiers) unique index on leads. One phone = one lead per workspace.
  • One conversation thread per (workspaceId, recipientPhone).
  • Idempotency-Key semantics on /api/messages (still 24 h, still preferred when callers can set it).
  • HubSpot/GHL workflow plugin behavior — the guard fires identically for every caller because it sits on the shared /api/messages endpoint.

See also