save_device: expirar push devices duplicados no registro
TLDR: Quando um novo push device se registra para um par
(user, platform)que já tem registros ativos, expirar os antigos antes de criar o novo — evitando notificações duplicadas causadas por atualizações do iOS que rotacionam tanto odevice_idquanto o token FCM.
Contexto
Device.osInternalBuildId (usado como device_id no app mobile) retorna o build number do iOS (ex.: 23D8133 para iOS 17.3, 23F77 para iOS 17.5). Quando um usuário atualiza o iOS, tanto o device_id quanto o token FCM rotacionam. save_device faz lookup por (device_id, platform, user_id) — não encontra nada — e cria um novo registro de PushDevices. O registro antigo permanece ativo. O service de notificações envia para todos os registros ativos do usuário, resultando em push duplicada.
O app mobile já está em produção. Os usuários não atualizarão para uma versão corrigida imediatamente, então o backend precisa ser a correção definitiva, independente de qual device_id o cliente envia.
Objetivos
- Garantir no máximo um registro de
PushDevicesativo por(user, platform)em qualquer momento - Expirar registros obsoletos automaticamente a cada novo registro, sem depender de correção no cliente
- Manter intacto o caminho de upsert existente (encontrado por
device_id→ atualiza token)
Fora de escopo
— (não registrado na spec original)
Mudanças
apps/notifications/tasks.py — save_device
Fluxo atual:
1. Lookup por (device_id, platform, user_id) → se encontrado, atualiza o token e reativa
2. Se não encontrado → PushDevices.objects.create(**data)
Fluxo novo:
1. Lookup por (device_id, platform, user_id) → se encontrado, atualiza o token e reativa (inalterado)
2. Se não encontrado:
a. Expirar todos os outros registros ativos de (user_id, platform) — setar is_expired=True, expired_at=now()
b. Criar o novo registro normalmente
python
if not device:
now = timezone.now()
expired_count = PushDevices.objects.filter(
user_id=user_id,
platform=platform,
is_expired=False,
).exclude(device_id=device_id).update(is_expired=True, expired_at=now)
if expired_count:
logger.info(f"Expired {expired_count} stale device(s) for user {user_id} on {platform}")
# segue criando como antes
tests/notifications/test_serializers_views_urls.py
Testes cobrindo:
- Novo registro expira os registros ativos existentes do mesmo (user, platform)
- Novo registro não expira registros de outra plataforma
- Device existente encontrado por device_id → caminho de update continua funcionando (sem expiração)
- Múltiplos registros obsoletos são todos expirados numa chamada
Como verificar
- Criar dois
PushDevicesativos para o mesmo user+platform, comdevice_idetokendiferentes - Chamar
save_devicecom um terceirodevice_idpara o mesmo user+platform - Verificar que apenas o registro recém-criado está ativo (
is_expired=False) - Verificar que os dois anteriores estão expirados (
is_expired=True,expired_atpreenchido) - Chamar
save_devicede novo com o mesmodevice_id(caminho de reativação) - Verificar que o registro é atualizado e nenhum registro extra foi expirado
Documentação
- learnings/notifications_fcm_send_each_batch_expired_tokens.md — causa raiz (build number do iOS como
device_id) e decisão de o backend ser o dono da deduplicação