# Reminder jobs idempotent; no duplicate reminders sent after crash recovery

- **Pattern:** `ab-000573` (`booking-notifications-reminders.reminders.reminder-idempotent`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000573
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000573)

## Why it matters

Job queues provide at-least-once delivery guarantees — if a worker crashes after sending the reminder but before acknowledging the job, the queue will re-deliver it on restart. Without an idempotency guard that reads the `reminder_sent` flag before proceeding, crash recovery produces a duplicate send on every failure. CWE-362 (race condition) applies: two workers processing the same job concurrently (a known BullMQ failure mode under high load) can both pass a post-send flag check and send twice. ISO 25010 reliability.fault-tolerance requires that the system produce correct outputs even under failure and recovery. The fix — checking the flag before sending, not after — trades a low-probability missed send for a guaranteed no-duplicate guarantee.

## Severity rationale

Critical because without an idempotency guard, every worker crash during a reminder send produces a guaranteed duplicate notification on queue recovery, which erodes customer trust at scale.

## Remediation

In `src/jobs/reminder-worker.ts`, check `reminder_sent` before sending and set it to `true` before making the carrier call, not after. This is the set-before-send pattern — it accepts a low-probability missed send on crash in exchange for a guaranteed no-duplicate guarantee:

```ts
const worker = new Worker('reminders', async (job) => {
  if (job.name === 'send-reminder') {
    const booking = await db.bookings.findUnique({ where: { id: job.data.bookingId } })
    if (booking.reminder_sent) return // Already sent — skip

    // Set flag BEFORE send — prevents duplicates on crash recovery
    await db.bookings.update({ where: { id: booking.id }, data: { reminder_sent: true } })

    try {
      await sendReminderMessage(booking)
    } catch (error) {
      await db.deliveryLog.create({ data: { booking_id: booking.id, type: 'reminder', error: error.message } })
      throw error // Queue retries; idempotency guard prevents double-send
    }
  }
}, { connection: redis })
```

## Detection

- **ID:** `reminder-idempotent`
- **Severity:** `critical`
- **What to look for:** Find the reminder job handler (the worker that sends individual reminders). Check for: (1) A deduplication guard — does the handler check if `reminder_sent` is already true before proceeding? Quote the check. (2) Timing of the flag set — is `reminder_sent` set to true BEFORE sending (preventing duplicates on crash, but risking missed sends) or AFTER sending (risking duplicates on crash)? Either approach is acceptable if there is a compensating mechanism. (3) Does the job queue itself provide at-least-once or exactly-once semantics? Name the queue library and its delivery guarantee.
- **Pass criteria:** Enumerate all idempotency mechanisms found. ALL of the following: (1) Handler checks `reminder_sent` flag before sending — quote the check code. At least 1 idempotency guard must exist. A handler that sends unconditionally without checking does NOT count as pass. (2) The flag-set timing is intentional and documented OR the flag is set atomically with the send (e.g., database transaction that marks sent + dispatches). (3) After a simulated crash (job fails mid-execution), the system produces at most 1 duplicate send. Report the count of idempotency guards found and state which approach is used even on pass.
- **Fail criteria:** No `reminder_sent` check — handler sends unconditionally. No flag update at all (reminder_sent never set to true). Flag set in a separate, non-atomic step that could be skipped on crash. Handler has no error handling — a crash leaves the reminder in an unknown state.
- **Skip (N/A) when:** Reminders are not implemented (same criteria as reminder-scheduled-job skip).
- **Detail on fail:** Quote the handler function and describe the idempotency gap. Example: `"sendReminder worker at src/jobs/reminder-worker.ts:8 sends the reminder at line 12, then sets reminder_sent=true at line 15. If the process crashes between lines 12 and 15, the reminder will be re-sent on recovery."`.
- **Remediation:** Check the flag before sending and set it atomically:

  ```ts
  // src/jobs/reminder-worker.ts
  const worker = new Worker('reminders', async (job) => {
    if (job.name === 'send-reminder') {
      const booking = await db.bookings.findUnique({ where: { id: job.data.bookingId } })

      // Idempotency guard
      if (booking.reminder_sent) {
        return // Already sent — do not duplicate
      }

      // Set flag BEFORE sending to prevent duplicates on crash
      await db.bookings.update({
        where: { id: booking.id },
        data: { reminder_sent: true }
      })

      try {
        await sendReminderMessage(booking)
      } catch (error) {
        // Log failure but do NOT reset flag — prevents duplicate sends
        await db.deliveryLog.create({
          data: { booking_id: booking.id, type: 'reminder', error: error.message }
        })
        throw error // Queue will retry, but idempotency guard prevents double-send
      }
    }
  }, { connection: redis })
  ```
- **Cross-reference:** Database Audit checks transaction atomicity. Error Handling audit checks crash recovery patterns. Booking Flow & Lifecycle audit checks idempotency of status transitions.

## External references

- cwe CWE-362
- iso-25010 reliability.fault-tolerance

Taxons: error-resilience

HTML version: https://auditbuffet.com/patterns/ab-000573
