Repository navigation
Evitar e-mails duplicados na importação de inscrições (retry por destinatário, idempotência e DLQ) #53
Description
Contexto
- O consumer
ImportRegistrationCommand(rabbitmq:import-registration-command) recebe do RabbitMQ uma mensagem contendo uma lista de N inscritos e disparaImportRegistrationEvent. - O
ImportRegistrationListenernão éShouldQueue: percorre a lista e, para cada inscrito, cria oEmailDispatche chamaMail::to(...)->send(...)de forma síncrona. - O
$msg->ack()só acontece depois do loop inteiro (ImportRegistrationCommand.php:62). OImporteRegistrationMailtambém não é enfileirado. - Não existe retentativa controlada, DLQ nem idempotência por destinatário. O
catch (\Exception)do consumer não fazacknemnack. - 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
- Falha de SMTP/timeout no inscrito k de N: os inscritos 1..k-1 já receberam e voltam a receber na reentrega.
- Worker morto durante o lote (deploy, OOM,
kill,\Errorque não é capturado porcatch (\Exception)). - 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. - Poison message: JSON inválido (
returnsem ack) ou registro que sempre falha é reentregue indefinidamente.
Problemas relacionados
EmailDispatch::createocorre antes dosend: 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=databasejá 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_atdeixa de significar "enviado": passar a ser nullable ou manter como data de criação; a data real de envio ésent_at.- Atualizar
$fillable,$castse$auditInclude(incluirstatus) emApp\Models\EmailDispatch. - Cuidado com registros legados: backfill
status = 'sent',sent_at = dispatched_ateidempotency_keycalculada a partir demeta(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;ebackoff()progressivo (ex.:[60, 300, 900]);public $timeoutdefinido.handle():- Obtém/cria o
EmailDispatchporidempotency_key(firstOrCreate) comstatus = pendingecontentrenderizado. - Se
status === 'sent', retorna sem enviar (idempotência). - Incrementa
attempts, envia comMail::to($registration['agent_email'])->send($mailable). - Em sucesso, grava
status = sent,sent_at = now(), limpaerror_message.
- Obtém/cria o
failed(\Throwable $e): gravastatus = failedeerror_message, loga e reporta ao Sentry via\Sentry\captureException.- Considerar
ShouldBeUnique/lock poridempotency_keypara evitar execução concorrente do mesmo destinatário. - Atenção à janela entre
sendestatus = 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::batchpara agrupar). - Dispatch rápido: nenhuma chamada SMTP no ciclo do consumer.
- Definir
queuededicada (ex.:emails) e documentar no supervisor/Docker o workerphp artisan queue:work --queue=emails.
4. Consumer (ImportRegistrationCommand)
- Capturar
\Throwable(não apenas\Exception). - JSON inválido / formato inesperado: logar e fazer
nacksem requeue (ou encaminhar à DLQ), em vez dereturnsem ack. - Falha ao enfileirar os jobs (ex.: banco indisponível):
nackcom requeue, com contador de tentativas (headerx-retry-count) e limite; ao exceder, enviar à DLQ. - Sucesso ao enfileirar todos os jobs:
ack. - Corrigir
captureMessage($e, ...)paracaptureException($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 dobasic_consume.- Declarar DLX/DLQ: exchange
*.dlx, filaqueue_import_registration.dlqe argumentosx-dead-letter-exchange/x-dead-letter-routing-keyna fila principal.- Atenção: alterar argumentos de uma fila existente exige recriá-la (
PRECONDITION_FAILED). Planejar a migração (criar fila nova + mover mensagens, oupolicyno RabbitMQ em vez de argumentos de declaração).
- Atenção: alterar argumentos de uma fila existente exige recriá-la (
- Novas chaves em
config/rabbitmq.php(DLX, DLQ, limite de retentativas) comenv()correspondentes e.env.exampleatualizado.
6. Testes (PHPUnit, em tests/Unit)
SendImportRegistrationMailTest: envia e marcasent; segunda execução com a mesma chave não reenvia (Mail::assertSentCount(1)); falha de envio gravafailede 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 chamaMail.ImportRegistrationCommandTest(ajustar o existente):ackem sucesso;nacksem 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êmEmailDispatch.status = sent
Quando a mesma mensagem é consumida novamente
Então nenhum e-mail é enviado e nenhum novo registro deEmailDispatché criado -
Retentativa automática com backoff
Dado que o SMTP está indisponível temporariamente
Quando o job falha
Então é retentado até$triesvezes combackoffe, ao esgotar, oEmailDispatchficafailedcomerror_messagee o erro vai ao Sentry -
Auditoria fiel
Dado que um envio falhou
Quando consultoemail_dispatches
Então o registro não consta comosenteattemptsreflete 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 (nacksem 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 oackocorre 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
SendImportRegistrationMailcomtries,backoff,failed()e idempotência - Refatorar
ImportRegistrationListenerpara apenas despachar jobs - Ajustar
ImportRegistrationCommand(ack/nack,\Throwable,captureException) -
basic_qose DLX/DLQ noRabbitMQServiceeconfig/rabbitmq.php(+.env.example) - Documentar worker da queue
emailsno Docker/supervisor (incluindodocker/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_CONNECTIONdedatabasepara Redis/RabbitMQ. - Alterar o conteúdo/template do e-mail.
- Garantia exactly-once absoluta (a janela entre
sende a gravação desentpermanece 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=emailsantes de trocar o listener, para não acumular jobs sem consumidor.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsIn progress
Activity