Створення надійної системи push-сповіщень з урахуванням часових поясів
Додаток для здоров'я та благополуччя потребував надсилання персоналізованих щоденних нагадувань — про їжу, перевірку настрою, підказки щодо гідратації, сигнали для сну та власні нагадування — тисячам користувачів по всьому світу. Заковика: кожне сповіщення мало надходити в правильний місцевий час, рівно один раз, і ніколи на неактивний пристрій. Ми розробили та побудували розподілений конвеєр, який це забезпечує.
Виклик
- Коректність часового поясу в масштабі. "Нагадування про сніданок о 9 ранку" означає щось різне для кожного користувача. Відправлення за серверним часом надіслало б сповіщення комусь у Сіднеї о 3 ранку. Кожне сповіщення повинно було надходити в місцевий час користувача.
- Без спаму, без дублювання. Планування cron-завдань неминуче призводить до перекриття та повторного запуску. Без суворих гарантій один користувач міг би отримати одне й те саме нагадування "час обіду 🥗" двічі або тричі — швидкий шлях до видалення програми.
- Пристрої ненадійні. Користувачі видаляють додатки, відкликають дозволи та постійно змінюють push-токени. Відправлення сповіщень на застарілі токени наосліп витрачає ресурси та псує метрики доставки.
- Точний час без планувальника "грубої сили". Доставка сотень сповіщень з точністю до хвилини — без cron-завдання, яке "довбає" базу даних кожні 60 секунд — вимагала розумнішого механізму, ніж наївне опитування.
Наше рішення
Ми побудували триетапний конвеєр, який чітко розділяє що надсилати, коли надсилати та власне надсилання — щоб кожен етап міг незалежно відмовляти та відновлюватися. База даних є джерелом істини, черга повідомлень обробляє точний час, а єдиний рівень воркера взаємодіє з провайдером push-повідомлень.

Архітектура
- Expo-notifications — це React Native клієнт з нативними каналами, унікальними звуками та глибокими посиланнями, який використовує єдиний формат токенів та API доставки для iOS та Android.
- NestJS backend з expo-server-sdk як уніфікованою абстракцією push-повідомлень над FCM та APNs.
- MongoDB як джерело істини — колекції NotificationMessage, NotificationToken та NotificationCounter.
- Черги затримки ActiveMQ (STOMP), по одній для кожної категорії (їжа, настрій, активність, безпека, нагадування), для точної запланованої доставки.
- Creator crons, що генерують записи сповіщень з урахуванням часових поясів для кожного користувача.
- Consumer workers, які підписуються на кожну чергу та виконують остаточну перевірку перед відправкою.
- AWS ECS Fargate, що запускає cron-завдання та споживачів; ActiveMQ на виділеному екземплярі EC2.
Ключові особливості
- Планування з урахуванням часових поясів. Використовуючи date-fns-tz, обчислюється місцевий час надсилання для кожного користувача, конвертується назад у UTC для зберігання та обмежується вікнами дат UTC, щоб гарантувати одне нагадування на день.
- Ідемпотентність, забезпечена базою даних. Частковий унікальний індекс для повідомлень у стані "очікування" унеможливлює створення дублікатів — навіть якщо cron запускається двічі:
| // Унікальний лише поки повідомлення перебуває в стані PENDING і не видалене schema.index( { userId: 1, notificationTokenId: 1, category: 1, label: 1, scheduledAt: 1 }, { unique: true, partialFilterExpression: { status: 'pending', isDeleted: false } } ); |
3. Точна доставка через черги затримки. Замість cron-завдання, що запускається щохвилини, планувальник ставить повідомлення в чергу зараз, але відкладає доставку до точної хвилини, використовуючи заголовок scheduled-delay в ActiveMQ:
| client.send(`/queue/${queueName}`, { persistent: 'true', 'AMQ_SCHEDULED_DELAY': String(delayMs), // доставлено точно в призначений час }, JSON.stringify(message)); |
4. Один активний токен на пристрій. Частковий унікальний індекс гарантує наявність лише одного активного токена на пристрій; нові входи акуратно деактивують старий токен, з експоненційними відстрочками повторних спроб для виживання при одночасних входах.
5. Перевірка квитанцій + автоматичне очищення. Після відправлення ми опитуємо квитанції Expo. Відповідь DeviceNotRegistered негайно деактивує "мертвий" токен, щоб ми більше ніколи не витрачали на нього відправлення.
| f (receipt.status === 'error' && receipt.details?.error === 'DeviceNotRegistered') { await this.deactivateToken(token); // припинити надсилання на неактивні пристрої } |
6. Межа відмов. Кожен пристрій має лічильник повторних спроб; після 3 послідовних збоїв токен автоматично деактивується — без нескінченних циклів, без "зомбі" токенів.
7. Повага до намірів користувача під час відправлення. Переваги сповіщень повторно перевіряються споживачем під час доставки, а не лише під час планування — тому користувач, який відмовився за годину до нагадування, ніколи його не отримає. Повідомлення переходять у чесні кінцеві стани: success, failed або is_missed.
Результати
- Кожен користувач отримує нагадування в правильний місцевий час, по всьому світу — нуль сповіщень у неробочий час.
- Дубльовані сповіщення повністю усунені завдяки ідемпотентності на рівні бази даних.
- "Мертві" та застарілі токени пристроїв виявляються та автоматично деактивуються, підтримуючи чистоту доставки.
- Надійність підвищується, а навантаження на базу даних зменшується завдяки точній доставці з хвилинною точністю без планувальника "грубої сили".
Стек технологій
React Native · Expo Notifications · NestJS · TypeScript · MongoDB · ActiveMQ (STOMP) · expo-server-sdk · date-fns-tz · AWS ECS Fargate

