Unsubscribe de tópicos FCM ao expirar dispositivo
TLDR: Sempre que um
PushDevicesfor marcado como expirado, remover seu token dos tópicos FCM (ex.:all), para acabar com a duplicação de notificações do canal “All”.
Contexto
Notificações do canal “All” chegam duplicadas (às vezes triplicadas) nos aparelhos.
O canal “All” é enviado como mensagem para o tópico FCM all (all_staging em staging) — o Firebase faz o fan-out para todos os tokens inscritos no tópico (apps/notifications/services/notification.py:78-79,90-91). Esse envio ignora a flag is_expired do banco, porque quem mantém a lista de inscritos é o Firebase, não o nosso banco.
Hoje, quando um device é marcado como is_expired=True, o token continua inscrito no tópico all no lado do Firebase — nada chama o unsubscribe. Enquanto o token antigo ainda for entregável pelo FCM (tokens rotacionados seguem válidos por um período), o mesmo aparelho recebe a notificação em cada token que continua no tópico → 2 ou 3 cópias.
A função unsubscribe() já existe (apps/notifications/services/topic_subscription.py:72-98), mas é código morto — só é referenciada em teste, nunca em produção.
Existem 4 pontos que marcam is_expired=True e nenhum faz unsubscribe:
apps/notifications/tasks.py:63-71—save_device, ao registrar novodevice_id, expira em massa os devices antigos do mesmo usuário/plataforma. É o ponto que mais causa duplicação (o token antigo em geral ainda está vivo)apps/notifications/services/notification.py:161-166— erro de envio direto (token morto)apps/notifications/services/push_batch.py:78-83— erro em lote (token morto)apps/notifications/services/topic_subscription.py:57-63— rejeição no próprio subscribe (token morto)
Nos pontos 2, 3 e 4 o token já foi rejeitado pelo FCM (morto), então o unsubscribe é praticamente inócuo — mas por consistência todos passam pelo mesmo helper.
Objetivos
- Ao expirar um device, remover seu token dos tópicos FCM em que ele está inscrito
- Reaproveitar a função
unsubscribejá existente, chamando-a em cada ponto que marcais_expired(sem helper novo) - Eliminar a duplicação de notificações do canal “All” na origem (tokens novos que se acumulam no tópico)
Fora de escopo
- Task periódica (Celery beat) de reconciliação do passivo já órfão — decisão do usuário: não criar
- Alterações no app (
onion-app) — a inscrição direta no tópico no login é redundante, mas não é o multiplicador - Observação (não corrigido aqui): o caminho de “reativação” do
save_device(tasks.py:55-61) troca o token na mesma linha sem reinscrever o token novo nem remover o antigo. Como ele não marca como expirado, fica fora do princípio desta spec
Mudanças
Abordagem: sem helper novo. Em cada ponto que marca is_expired, chamar a função unsubscribe(device, device.topics) já existente em topic_subscription.py.
apps/notifications/tasks.py—save_device: trocar o.update(is_expired=True, ...)em massa por iteração dos devices antigos — para cada device,unsubscribe(device, device.topics)e marcaris_expiredapps/notifications/services/notification.py— noexceptde token inválido, chamarunsubscribe(push_device, push_device.topics)(import lazy para evitar import circular comtopic_subscription)apps/notifications/services/push_batch.py—_mark_expired_by_token: buscar os devices por token e, para cada um,unsubscribe(device, device.topics)e marcaris_expired(import lazy)apps/notifications/services/topic_subscription.py— na rejeição dentro dosubscribe, chamarunsubscribe(device, device.topics)(mesmo módulo)
Nota de import: topic_subscription importa NotificationService; para não criar ciclo, os usos em notification.py e push_batch.py fazem import lazy de unsubscribe dentro da função.
Fases de implementação
- test: cobrir que expirar um device chama
unsubscribe_from_topicdo FCM para o token — arquivos:tests/notifications/test_expired_fcm_token.py,tests/notifications/test_topic_subscription.pysave_devicecom novodevice_id: device antigo é expirado e desinscrito dos tópicosNotificationService.send()com token inválido: device expirado e desinscritopush_batch._mark_expired_by_token: devices expirados e desinscritostopic_subscription.subscribecom rejeição: device expirado e desinscritoexpire_devicecom Firebase desabilitado: marca expirado sem quebrar
- fix: chamar
unsubscribe(device, device.topics)nos 4 pontos que marcamis_expired - refactor: garantir ausência de import circular e limpar
- perf: (pular) sem gargalo medido; o unsubscribe em lote poderia ser agrupado, mas não há necessidade agora
Como verificar
pytest tests/notificationsverde- Manual: registrar o device A (token1) → inscrito em
all; registrar novodevice_id(token2) para o mesmo usuário → o device A é expirado emessaging.unsubscribe_from_topicé chamado para token1; enviar para o canal “All” → cada aparelho físico recebe uma cópia
Documentação
- rules/notifications/notification_model_point_in_time_only.md — o envio “All” é por tópico e ignora
is_expired, portanto expirar device exige unsubscribe no FCM