Envio direto de push de hábitos e lembretes via FCM em lote
TLDR: Hábitos e lembretes deixam de criar linhas
Notification(e opost_saveque dispara uma task/HTTP por device) e passam a enviar diretamente ao FCM em lote viamessaging.send_each, reduzindo banco, tasks Celery e chamadas HTTP; o modelNotificationfica reservado a envios pontuais (áudio, admin, particular).
Contexto
Hoje todo envio de hábito e de lembrete cria uma linha Notification por device. O post_save da Notification (apps/notifications/signals.py:8) dispara send_notification.delay() — uma task Celery por device — e cada task chama messaging.send() — uma chamada HTTP ao FCM por device.
Para N usuários com M devices, cada janela gera N×M linhas no banco + N×M tasks + N×M requisições FCM. Isso é custoso e lento, e os hábitos/lembretes são justamente os fluxos de maior volume.
Fluxos afetados hoje:
- HabitNotificationService.send_pending_notifications (apps/habits/services.py:273) — task send_habit_notifications, a cada 30 min
- ExecutionService._bulk_send_reminders (apps/habits/services.py:432) — task send_habit_reminders, a cada 60s, em 4 ondas
Ambos hoje usam HabitExecution.notification_ids (ArrayField) como gate:
- notification_ids__len=0 → notificação inicial ainda não enviada
- notification_ids__len__gt=0 → libera os lembretes
A SDK firebase-admin>=7.1.0 (já instalada) oferece messaging.send_each(messages), que envia uma lista de até 500 Message distintas (cada uma com título/corpo/token próprios — necessário porque cada usuário recebe nome do hábito e variação de lembrete diferentes) numa operação, retornando BatchResponse com status por mensagem.
Objetivos
- Hábitos e lembretes enviam push direto ao FCM em lote, sem criar
Notification - Manter o model
Notification+post_saveintactos para áudio, admin e envios particulares - Substituir o gate baseado em
notification_idspor campos próprios (is_notified+notified_at) emHabitExecution - Marcar
PushDevicescomo expirado a partir doBatchResponse(paridade com o tratamento atual de token expirado) - Remover o campo
notification_ids
Fora de escopo
apps/notifications/models.py, apps/notifications/signals.py, apps/notifications/services/notification.py e apps/notifications/tasks.py permanecem inalterados — continuam servindo o fluxo pontual de áudio/admin/particular.
Mudanças
1. Novo helper de envio em lote — apps/notifications/services/push_batch.py (novo)
- Recebe uma lista de itens
(device, title, body, data), monta osmessaging.Message(token-targeted) ignorando devices sem token ouis_expired, e envia em chunks de 500 viamessaging.send_each - Reaproveita
NotificationService.ensure_firebase_initialized()e o gatesettings.ENABLE_FIREBASE_NOTIFICATIONS - Inspeciona o
BatchResponse: para respostas comUnregisteredError/InvalidArgumentError/NotFoundError, coleta os devices e marcais_expired=True/expired_at=nowembulk_update - Retorna um resumo (enviados / falhas) para log
2. apps/habits/models/execution.py
- Adicionar
is_notified = BooleanField(default=False, db_index=True) - Adicionar
notified_at = DateTimeField(null=True, blank=True) - Remover
notification_ids
3. Migrations — apps/habits/migrations/
- Migration que adiciona
is_notifiedenotified_at, com data migration de backfill: executions comnotification_idsnão vazio →is_notified=True(notified_atpode ficar nulo, pois o gate dos lembretes usa apenasis_notified) - Remoção do campo
notification_ids(mesma migration ou subsequente)
4. apps/habits/services.py
HabitNotificationService.send_pending_notifications:- Trocar o filtro
notification_ids__len=0poris_notified=False - Adicionar
prefetch_related('habit__user__push_devices') - Montar a lista
(device, title, body, data)para todos os devices das execuções e chamar o helper de lote (semNotification.objects.create) - Marcar
is_notified=True/notified_at=nowviabulk_update
- Trocar o filtro
ExecutionService._send_first_reminders: trocarnotification_ids__len__gt=0poris_notified=TrueExecutionService._bulk_send_reminders: montar a lista de mensagens e chamar o helper de lote (removendoNotification.objects.bulk_createe o loopsend_notification.delay), mantendo a atualização defirst/second/third/fourth_reminderereminder_variation
5. Testes — tests/habits/
- Atualizar os testes que hoje verificam criação de
Notification/notification_idspara validar a chamada do envio em lote (mock demessaging.send_each) e os novos camposis_notified/notified_at - Cobrir: device sem token/expirado ignorado; marcação de device expirado a partir do
BatchResponse; gate dos lembretes viais_notified
Como verificar
- Unit tests dos services de hábito/lembrete passando com
messaging.send_eachmockado, verificando que nenhumaNotificationé criada e queis_notified/notified_atsão setados - Localmente com
ENABLE_FIREBASE_NOTIFICATIONS=True: dispararsend_habit_notificationsesend_habit_reminderse confirmar uma chamada em lote ao FCM (não uma por device) e ausência de novas linhas emNotification - Confirmar que envios de áudio/admin continuam criando
Notificatione disparando viapost_save make seede a suíte de testes de hábitos sem erro
Documentação
- reference/notifications/habit_push_notifications.md — envio direto em lote e gate
is_notified/notified_at - learnings/notifications_fcm_send_each_batch_expired_tokens.md — uso de
messaging.send_eache tratamento de tokens expirados viaBatchResponse - rules/notifications/notification_model_point_in_time_only.md — regra “Notification = pontual / hábitos+lembretes = envio direto”