|
| 1 | +# ADR-001: Attendee Ticket Email Cache Reliability |
| 2 | + |
| 3 | +**Date:** 2026-03-09 |
| 4 | +**Status:** Accepted |
| 5 | +**Authors:** smarcet |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +Two queued email jobs — `InviteAttendeeTicketEditionMail` and `SummitAttendeeTicketEmail` — interact |
| 10 | +through Laravel's `Cache` facade to coordinate duplicate prevention, cross-class suppression, and |
| 11 | +message passthrough. Both jobs can be dispatched close together when a ticket is assigned or reassigned. |
| 12 | + |
| 13 | +Three issues were identified in the original cache usage: |
| 14 | + |
| 15 | +### 1. Non-Atomic Duplicate Check (Race Condition) |
| 16 | + |
| 17 | +Both `handle()` methods used a `Cache::has()` + `Cache::put()` pattern for self-dedup: |
| 18 | + |
| 19 | +```php |
| 20 | +if (Cache::has($key)) { |
| 21 | + return; // skip |
| 22 | +} |
| 23 | +Cache::put($key, $timestamp, $ttl); |
| 24 | +parent::handle($api); // send email |
| 25 | +``` |
| 26 | + |
| 27 | +This is not atomic. Two queue workers processing the same job could both pass the `has()` check |
| 28 | +before either writes the key, resulting in the same email being sent twice. |
| 29 | + |
| 30 | +### 2. Unbounded Cache Growth (`Cache::forever`) |
| 31 | + |
| 32 | +`SummitAttendeeTicketEmail::handle()` wrote a `_sent` key using `Cache::forever()` to signal to |
| 33 | +`InviteAttendeeTicketEditionMail` that the ticket email was already delivered: |
| 34 | + |
| 35 | +```php |
| 36 | +Cache::forever($summit_attendee_ticket_email_sent_key, $now->getTimestamp()); |
| 37 | +``` |
| 38 | + |
| 39 | +This key was never deleted or expired. Every unique email+ticket pair that triggered a ticket email |
| 40 | +created a permanent cache entry. Over time — across multiple summits and thousands of tickets — these |
| 41 | +entries accumulate with no cleanup mechanism. |
| 42 | + |
| 43 | +The key's purpose is narrow: suppress a late-arriving invitation email for the same ticket. This |
| 44 | +only matters in the minutes after ticket assignment, not permanently. |
| 45 | + |
| 46 | +### 3. Message Passthrough TTL Too Short |
| 47 | + |
| 48 | +`InviteAttendeeTicketEditionMail` caches a user-written invitation message at construction time so |
| 49 | +that `SummitAttendeeTicketEmail` can retrieve it if it runs first (race condition between the two jobs): |
| 50 | + |
| 51 | +```php |
| 52 | +$delay = intval(Config::get("registration.attendee_invitation_email_threshold", 5)); |
| 53 | +Cache::put($key, $message, now()->addMinutes($delay)); |
| 54 | +``` |
| 55 | + |
| 56 | +The TTL was tied to `attendee_invitation_email_threshold` (default 5 minutes). The TTL clock starts |
| 57 | +at construction time, not dispatch time. Under queue pressure — backlog, worker restarts, slow DB |
| 58 | +queries during `SummitAttendeeTicketEmail` construction — the cached message could expire before the |
| 59 | +ticket email job reads it. The message would be silently dropped with no error logged. |
| 60 | + |
| 61 | +## Decision |
| 62 | + |
| 63 | +### Fix 1: Atomic Dedup with `Cache::add()` |
| 64 | + |
| 65 | +Replace `Cache::has()` + `Cache::put()` with `Cache::add()` in both `handle()` methods. |
| 66 | + |
| 67 | +`Cache::add()` atomically sets the key only if it does not already exist, returning `false` if |
| 68 | +another process already claimed it. This eliminates the race window entirely. |
| 69 | + |
| 70 | +```php |
| 71 | +$now = new \DateTime('now', new \DateTimeZone('UTC')); |
| 72 | +if (!Cache::add($key, $now->getTimestamp(), now()->addMinutes($delay))) { |
| 73 | + Log::warning("...already sent..."); |
| 74 | + return; |
| 75 | +} |
| 76 | +parent::handle($api); |
| 77 | +``` |
| 78 | + |
| 79 | +**Tradeoff:** The warning log no longer includes the timestamp of the previous send (since `add()` |
| 80 | +returns a boolean, not the existing value). This was deemed acceptable — the timestamp was rarely |
| 81 | +useful in practice. |
| 82 | + |
| 83 | +### Fix 2: Replace `Cache::forever()` with 1-Hour TTL |
| 84 | + |
| 85 | +```php |
| 86 | +Cache::put($summit_attendee_ticket_email_sent_key, $now->getTimestamp(), now()->addHours(1)); |
| 87 | +``` |
| 88 | + |
| 89 | +One hour is well beyond any realistic queue delay. The self-dedup key (with its threshold-based TTL) |
| 90 | +already prevents rapid-fire duplicates. The `_sent` key is a second layer for late-arriving |
| 91 | +invitation emails; 1 hour covers that case while allowing entries to self-clean. |
| 92 | + |
| 93 | +### Fix 3: Increase Message Passthrough TTL to 1 Hour |
| 94 | + |
| 95 | +```php |
| 96 | +Cache::put($invite_attendee_ticket_edition_mail_message_key, $message, now()->addHours(1)); |
| 97 | +``` |
| 98 | + |
| 99 | +On the happy path, `SummitAttendeeTicketEmail` retrieves and deletes the entry immediately via |
| 100 | +`Cache::forget()`. The 1-hour TTL is a safety net for when the ticket email job is delayed or never |
| 101 | +fires, preventing indefinite cache persistence without risking message loss under queue pressure. |
| 102 | + |
| 103 | +## Affected Files |
| 104 | + |
| 105 | +- `app/Jobs/Emails/Registration/Attendees/InviteAttendeeTicketEditionMail.php` |
| 106 | +- `app/Jobs/Emails/Registration/Attendees/SummitAttendeeTicketEmail.php` |
| 107 | + |
| 108 | +## Consequences |
| 109 | + |
| 110 | +- **Duplicate emails under concurrent workers** are no longer possible (atomic check-then-set). |
| 111 | +- **Cache storage** is bounded — all entries now have finite TTLs and self-clean. |
| 112 | +- **User-written messages** survive queue delays of up to 1 hour instead of 5 minutes. |
| 113 | +- **Log messages** for the dedup warning no longer include the previous send timestamp. |
| 114 | +- **Cross-class suppression** (`_sent` key) expires after 1 hour. If an invitation email is queued |
| 115 | + more than 1 hour after the ticket email was sent, the suppression won't apply. This is acceptable |
| 116 | + because at that point the jobs are no longer racing — something else has gone wrong. |
0 commit comments