Five identical schedules, thirteen SMS in ten minutes
09:33 to 09:43 on a Friday morning: thirteen SMS messages, several push notifications, all from my own safety app, all to me. The trigger was five identical "Check-in" schedules firing at 09:00:00 in parallel.
What was happening
Five active rows in checkin_schedules for the same user, all
named "Check-in", all with schedule_time=09:00:00. They had
been created the previous afternoon in a 20-minute window — most
likely a test script or a curl loop, definitely not the iOS app
(the iOS create path was 404'ing at the time and silently failing).
The cron fan-out:
- 09:00 — each schedule independently created a check-in request (5 pending requests)
- 09:30 — each expired (no response inside the 30-minute window)
- 09:33 — tier 1 escalation: 5 SMS
- 09:38 — tier 2 escalation: 5 more SMS
- 09:43 — tier 3 escalation: 0 SMS (no contacts in the fallback tier)
Plus 6 push notifications: one per duplicate createCheckinRequest
call plus the pre-alert.
The contact list for tiers 1 and 2 included me as my own emergency contact (a separate UX bug — the app lets you list yourself). So the escalations all landed back on my phone.
What I found
Three independent failures, none of them individually catastrophic, all together pretty loud:
POST /checkin/scheduleshad no idempotency check. Five identical creates created five rows.- The cron had no per-user-per-tick fan-out cap. Five expirations for the same user all escalated simultaneously to all contacts.
- The emergency contact CRUD allowed self-listing. A normal escalation became a self-SMS event.
The fix
Three layered defenses, committed together:
// 1. Idempotent POST /checkin/schedules
$existing = $db->query('checkin_schedules', [
'user_id' => $userId,
'name' => $name,
'schedule_time' => $scheduleTime,
'is_active' => true,
]);
if ($existing) {
return ['schedule_id' => $existing[0]['id'], 'duplicate' => true];
}
// 2. Per-user-per-cron-cycle dedup in cron_services.php
if (isset($triggeredUserKeys[$key])) {
markScheduleTriggered($schedule); // prevent re-fire later same day
continue;
}
$triggeredUserKeys[$key] = true;
// 3. Per-user escalation cap in processExpiredCheckins
if (isset($escalatedUsers[$req['user_id']])) {
resolveRequest($req, 'sibling_request_already_escalated_this_cycle');
continue;
}
$escalatedUsers[$req['user_id']] = true;
For the self-contact issue: short-term I just deleted my own contact row; longer-term the right answer is a warning at contact creation time, which is still open.
What I'd do differently
The duplicate-create part is one of those bugs that's obvious in retrospect. Any user-facing POST that creates a uniquely-named resource should be idempotent on the unique attributes by default, not by exception. I keep relearning that.
The cron fan-out cap is the more interesting lesson. The escalation code was written assuming "one request per user per window" as an invariant and never checked it. Code that depends on an invariant should either enforce it or assert it — silently trusting it leads to thirteen-SMS Fridays.