Skip to content

Evitar e-mails duplicados na importação de inscrições (retry por destinatário, idempotência e DLQ) #53

Open

Description

@Junior-Shyko

Contexto

  • O consumer ImportRegistrationCommand (rabbitmq:import-registration-command) recebe do RabbitMQ uma mensagem contendo uma lista de N inscritos e dispara ImportRegistrationEvent.
  • O ImportRegistrationListener não é ShouldQueue: percorre a lista e, para cada inscrito, cria o EmailDispatch e chama Mail::to(...)->send(...) de forma síncrona.
  • O $msg->ack() só acontece depois do loop inteiro (ImportRegistrationCommand.php:62). O ImporteRegistrationMail também não é enfileirado.
  • Não existe retentativa controlada, DLQ nem idempotência por destinatário. O catch (\Exception) do consumer não faz ack nem nack.
  • Consequência: a falha de um envio no meio do lote deixa a mensagem unacked. Ela é reentregue inteira quando o canal/conexão cai (restart do worker, deploy, timeout, OOM). Os destinatários já atendidos recebem o e-mail repetido.

Cenários que geram duplicidade

  1. Falha de SMTP/timeout no inscrito k de N: os inscritos 1..k-1 já receberam e voltam a receber na reentrega.
  2. Worker morto durante o lote (deploy, OOM, kill, \Error que não é capturado por catch (\Exception)).
  3. Lote longo: o callback bloqueia o loop AMQP; heartbeat ou consumer_timeout (padrão 30 min) do RabbitMQ pode fechar o canal e reentregar a mensagem enquanto o processamento original continua enviando.
  4. Poison message: JSON inválido (return sem ack) ou registro que sempre falha é reentregue indefinidamente.

Problemas relacionados

  • EmailDispatch::create ocorre antes do send: falha no envio deixa registro de auditoria "despachado" sem e-mail enviado, e a reentrega cria outro registro.
  • \Sentry\captureMessage($e, ...) recebe uma exceção onde deveria receber string (o correto é captureException).
  • QUEUE_CONNECTION=database já está configurado e há precedente de job com $tries/$backoff (App\Jobs\NotificationAccountability), mas o fluxo de importação não usa.

Objetivo

Como dev/mantenedor da api-email
Quero que cada e-mail de importação de inscrição seja processado de forma isolada, com retentativa controlada e idempotente
Para que uma falha de envio nunca cause reenvio de e-mail a quem já foi notificado e que falhas pontuais sejam retentadas automaticamente.

Especificação Técnica

1. Persistência / idempotência (email_dispatches)

Nova migration adicionando à tabela email_dispatches:

Coluna Tipo Observação
status string(20) pending | sent | failed; default pending; indexado
idempotency_key string unique; ex.: `sha1(mailable_type . '
attempts unsignedSmallInteger default 0
error_message text nullable último erro
sent_at timestamp nullable preenchido ao confirmar envio
  • dispatched_at deixa de significar "enviado": passar a ser nullable ou manter como data de criação; a data real de envio é sent_at.
  • Atualizar $fillable, $casts e $auditInclude (incluir status) em App\Models\EmailDispatch.
  • Cuidado com registros legados: backfill status = 'sent', sent_at = dispatched_at e idempotency_key calculada a partir de meta (ou nula, aceitando múltiplos nulos na unique).

2. Job por destinatário

Criar App\Jobs\SendImportRegistrationMail (ShouldQueue, Dispatchable, InteractsWithQueue, Queueable, SerializesModels), seguindo o padrão de NotificationAccountability:

  • Recebe um $registration (array).
  • public $tries = 3; e backoff() progressivo (ex.: [60, 300, 900]); public $timeout definido.
  • handle():
    1. Obtém/cria o EmailDispatch por idempotency_key (firstOrCreate) com status = pending e content renderizado.
    2. Se status === 'sent', retorna sem enviar (idempotência).
    3. Incrementa attempts, envia com Mail::to($registration['agent_email'])->send($mailable).
    4. Em sucesso, grava status = sent, sent_at = now(), limpa error_message.
  • failed(\Throwable $e): grava status = failed e error_message, loga e reporta ao Sentry via \Sentry\captureException.
  • Considerar ShouldBeUnique/lock por idempotency_key para evitar execução concorrente do mesmo destinatário.
  • Atenção à janela entre send e status = sent: se o processo morrer ali, o retry reenviará. Esse risco residual deve ser documentado (garantia at-least-once, minimizada, não exactly-once).

3. Listener

ImportRegistrationListener::handle:

  • Mantém a validação (isValidRegistration) e o log de registros inválidos.
  • Em vez de enviar, faz SendImportRegistrationMail::dispatch($registration) por inscrito (opcional: Bus::batch para agrupar).
  • Dispatch rápido: nenhuma chamada SMTP no ciclo do consumer.
  • Definir queue dedicada (ex.: emails) e documentar no supervisor/Docker o worker php artisan queue:work --queue=emails.

4. Consumer (ImportRegistrationCommand)

  • Capturar \Throwable (não apenas \Exception).
  • JSON inválido / formato inesperado: logar e fazer nack sem requeue (ou encaminhar à DLQ), em vez de return sem ack.
  • Falha ao enfileirar os jobs (ex.: banco indisponível): nack com requeue, com contador de tentativas (header x-retry-count) e limite; ao exceder, enviar à DLQ.
  • Sucesso ao enfileirar todos os jobs: ack.
  • Corrigir captureMessage($e, ...) para captureException($e).
  • Como o dispatch do job é idempotente pela chave, a reentrega da mensagem do RabbitMQ não duplica e-mails.

5. RabbitMQ (RabbitMQService::consume + config)

  • basic_qos(null, 1, null) (prefetch 1) antes do basic_consume.
  • Declarar DLX/DLQ: exchange *.dlx, fila queue_import_registration.dlq e argumentos x-dead-letter-exchange / x-dead-letter-routing-key na fila principal.
    • Atenção: alterar argumentos de uma fila existente exige recriá-la (PRECONDITION_FAILED). Planejar a migração (criar fila nova + mover mensagens, ou policy no RabbitMQ em vez de argumentos de declaração).
  • Novas chaves em config/rabbitmq.php (DLX, DLQ, limite de retentativas) com env() correspondentes e .env.example atualizado.

6. Testes (PHPUnit, em tests/Unit)

  • SendImportRegistrationMailTest: envia e marca sent; segunda execução com a mesma chave não reenvia (Mail::assertSentCount(1)); falha de envio grava failed e relança para o retry; failed() registra erro.
  • ImportRegistrationListenerTest (ajustar o existente): dispara um job por inscrito válido (Queue::fake()), ignora inválidos, não chama Mail.
  • ImportRegistrationCommandTest (ajustar o existente): ack em sucesso; nack sem requeue em JSON inválido; nack/requeue em falha ao enfileirar; captura \Throwable.
  • Teste de regressão do cenário principal: lote com 3 inscritos, falha no 2º → apenas o 2º é retentado, o 1º não recebe e-mail duplicado.

Critérios de Aceitação

  • Falha no meio do lote não reenvia para quem já recebeu
    Dado que uma mensagem com 5 inscritos e o envio do 3º falha
    Quando o job do 3º é retentado ou a mensagem é reentregue
    Então os inscritos 1, 2, 4 e 5 recebem exatamente um e-mail e apenas o 3º é retentado

  • Reentrega da mesma mensagem não duplica e-mails
    Dado que todos os inscritos de uma mensagem já têm EmailDispatch.status = sent
    Quando a mesma mensagem é consumida novamente
    Então nenhum e-mail é enviado e nenhum novo registro de EmailDispatch é criado

  • Retentativa automática com backoff
    Dado que o SMTP está indisponível temporariamente
    Quando o job falha
    Então é retentado até $tries vezes com backoff e, ao esgotar, o EmailDispatch fica failed com error_message e o erro vai ao Sentry

  • Auditoria fiel
    Dado que um envio falhou
    Quando consulto email_dispatches
    Então o registro não consta como sent e attempts reflete o número de tentativas

  • Mensagem inválida não fica presa
    Dado que chega uma mensagem com JSON inválido
    Quando o consumer a processa
    Então ela é rejeitada (nack sem requeue / DLQ) e não é reentregue em loop

  • Consumer não bloqueia no envio
    Dado que uma mensagem com muitos inscritos
    Quando o consumer a processa
    Então o ack ocorre após enfileirar os jobs, sem aguardar o SMTP

  • Testes unitários cobrindo os cenários acima passam no CI.

Escopo

  • Migration e atualização do model EmailDispatch (status, idempotency_key, attempts, error_message, sent_at) + backfill
  • Job SendImportRegistrationMail com tries, backoff, failed() e idempotência
  • Refatorar ImportRegistrationListener para apenas despachar jobs
  • Ajustar ImportRegistrationCommand (ack/nack, \Throwable, captureException)
  • basic_qos e DLX/DLQ no RabbitMQService e config/rabbitmq.php (+ .env.example)
  • Documentar worker da queue emails no Docker/supervisor (incluindo docker/prod)
  • Testes unitários novos/ajustados
  • Verificar com o time do efomento se as mensagens são publicadas como persistent (delivery_mode=2)

Fora de Escopo

  • Aplicar o mesmo padrão aos demais consumers (opinion_management, published_recourses, accountability); sugerir issues separadas.
  • Migrar a QUEUE_CONNECTION de database para Redis/RabbitMQ.
  • Alterar o conteúdo/template do e-mail.
  • Garantia exactly-once absoluta (a janela entre send e a gravação de sent permanece como risco residual aceito).

Observações

  • Análise de origem: o ack é dado somente após todos os envios, e não há retry/DLQ, então a reentrega reprocessa o lote completo.
  • NotificationAccountability ($tries = 3, $backoff = 60) serve de referência de padrão para o novo job.
  • Rollout: aplicar a migration antes de subir o novo worker; subir o worker queue:work --queue=emails antes de trocar o listener, para não acumular jobs sem consumidor.

Metadata

Metadata

Labels

Eq.DevbugSomething isn't workingenhancementNew feature or request

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions