Documentação / Análise do Fluxo de Envio de Mensagens

Análise do Fluxo de Envio de Mensagens

Entrar

Análise do Fluxo de Envio de Mensagens

Documento técnico: análise detalhada do fluxo de envio de mensagens WhatsApp na Pilot Status, incluindo regras de negócio, sistemas de garantia de entrega, pontos de falha e sugestões de melhoria.


1. Visão Geral da Arquitetura

O sistema de envio é composto por três camadas principais que trabalham em conjunto:

┌───────────────────────────────────────────────────────────────────┐
│  CLIENTE (API Externa / Dashboard)                                │
│  POST /api/messages → Autenticação → Rate Limit → Validação      │
└────────────────────────────┬──────────────────────────────────────┘
                             │
                             ▼
┌───────────────────────────────────────────────────────────────────┐
│  FULLSTACK (Next.js)                                              │
│  MessageService.send() → Cria registro no DB (status=QUEUED)     │
│                        → Enfileira job no BullMQ (Redis)         │
└────────────────────────────┬──────────────────────────────────────┘
                             │
                             ▼
┌───────────────────────────────────────────────────────────────────┐
│  WORKER (Node.js)                                                 │
│  processMessage() → Renderiza template → Chama Evolution API     │
│                   → Atualiza status DB  → Dispara Webhooks       │
└───────────────────────────────────────────────────────────────────┘

Tecnologias-chave:

| Componente | Tecnologia | |---|---| | Fila de mensagens | BullMQ + Redis | | Banco de dados | PostgreSQL via Prisma | | Provider WhatsApp | Evolution API | | Lock distribuído | Redis (SET NX) | | Retry | BullMQ (backoff fixo 30s) |


2. Fluxo Completo de Envio (Passo a Passo)

Fase 1 — Recepção e Validação (Fullstack)

  1. Autenticação: a requisição chega com apiKey. A chave é validada e associa o tenant/projeto.
  2. Rate Limit (RateLimitService.check()):
    • Ambiente TEST: verifica se o destino é um número de perfil registrado (bypass livre). Se não, aplica limite diário de teste (TEST_MODE_DAILY_LIMIT).
    • Ambiente LIVE: verifica limite mensal baseado no plano + pacotes comprados. No plano FREE, também aplica limite diário. Aciona Auto-Recharge automaticamente se habilitado e o saldo cair abaixo de um percentual configurado.
  3. Validação de Opt-In (WhatsAppTransactionalOptInService.assertDestinationAuthorized()): verifica se o destinatário autorizou receber mensagens transacionais (pode ser pulado com skipOptInValidation).
  4. Cálculo de deliverUntil (prazo de expiração automática por categoria de template):
    • OTP → 2 minutos
    • UTILITY → 1 hora
    • MARKETING → 4 horas
  5. Criação do registro no banco com status=QUEUED.
  6. Enfileramento no BullMQ: job com jobId=messageId, attempts=10, backoff=fixed/30s.

Fase 2 — Processamento pelo Worker

  1. Lock Redis distribuído (lock:ps:message:<messageId>, TTL 60s, SET NX): garante que apenas uma instância do worker processe a mensagem por vez. Se não adquirir o lock, simplesmente ignora (sem erro).
  2. Busca da mensagem no DB: verifica existência e status=QUEUED. Se não for QUEUED, ignora (idempotência).
  3. Verificação de expiração (deliverUntil): se a mensagem expirou, marca FAILED e notifica webhook do cliente.
  4. Renderização do template: substitui variáveis {{variavel}} no corpo do template. Suporta texto simples e JSON com botões.
  5. Validação da instância WhatsApp: determina o evolutionInstance — vem do job, da mensagem, ou da variável de ambiente EVOLUTION_INSTANCE_NAME.
  6. Deduplicação (apenas para a instância "Pilot Status"): usa tryAcquirePilotStatusSendSlot() para evitar que o mesmo messageId seja enviado mais de uma vez (proteção contra retentativas do BullMQ ou race conditions). Isso permite que o mesmo conteúdo seja enviado repetidamente se forem jobs/mensagens diferentes.
  7. Simulação de digitação: chama trySimulateWhatsAppTyping() antes do envio real, para uma experiência mais natural.
  8. Chamada à Evolution API:
    • Se há botões → POST /message/sendButtons/:instance
    • Se não → POST /message/sendText/:instance (com linkPreview se URL detectada)
  9. Verificação de estado da instância: se a resposta indicar state=close/connecting, lança erro de desconexão.
  10. Atualiza o banco: salva evolutionKeyId e evolutionInstanceId. O status SENT é atualizado via webhook da Evolution API (confirmação assíncrona — SERVER_ACK).
  11. Webhook do cliente: disparado em caso de falha com payload completo do evento.

Fase 3 — Sistemas de Garantia e Monitoramento

Reconciliador de Fila (message-reconciler.ts)

Executa em intervalos regulares (padrão: 1 hora, configurável via MESSAGE_RECONCILER_INTERVAL_MS). É protegido por lock Redis (ps:message-reconciler:lock, TTL 55s) para evitar execução concorrente em múltiplos workers.

O reconciliador percorre todas as mensagens QUEUED e:

  • Expira mensagens com deliverUntil ultrapassado → marca FAILED.
  • Falha mensagens com mais de 7 dias em QUEUED → marca FAILED com "Falha por timeout de entrega".
  • Valida dados mínimos: whatsappInstanceName, templateVersionId, destinationNumber, payload.
  • Reenfileira mensagens órfãs: se o job sumiu da fila (ex: Redis reiniciou), cria um novo job com attempts=10.
  • Promove jobs "delayed" que já deveriam ter sido processados.
  • Reprocessa jobs completed ou failed que ainda estejam com a mensagem em QUEUED no banco.

Healthcheck de Instâncias (all-instances-healthcheck.ts)

Executa a cada 10 minutos via cron no BullMQ.

  • Fase 1 – Verificação: para cada instância com state=OPEN, checa connectionState + presence.
    • Se not_found (404): marca como CLOSE no banco, sem tentar reiniciar.
    • Se desconectada: adiciona à lista de "disconnected".
  • Fase 2 – Restart: chama restartWhatsappInstance() para todas as desconectadas em paralelo.
  • Aguarda (HEALTHCHECK_SLEEP_MS, padrão 2 min) para a instância se reconectar.
  • Fase 3 – Recheck e Alertas: verifica novamente as que ainda estão desconectadas:
    • Se ainda offline: envia alerta via Pushover (desenvolvedor) + notificação WhatsApp ao admin do tenant.
    • Usa deduplicação Redis (ps:instance:disconnect-alert-sent:<nome>) para não reenviar o alerta repetidamente.

Validação Individual de Instâncias (whatsapp-instance-validate-sweep)

Executa a cada 5 minutos. Para cada instância OPEN no banco, verifica connectionState + presence. Se inválida, envia alerta ao admin.

Sincronização de Workers

A cada 60 segundos (configurável via WHATSAPP_QUEUE_WORKER_REFRESH_MS), o worker sincroniza a lista de filas ativas com as instâncias OPEN no banco. Isso garante que novas instâncias ganhem workers e instâncias removidas parem de ser processadas.


3. Regras de Negócio do Fluxo

| Regra | Detalhe | |---|---| | Um job por mensagem | jobId = messageId — sem duplicatas no BullMQ | | Idempotência | Worker verifica status=QUEUED antes de processar | | Lock por mensagem | Redis SET NX 60s impede processamento concorrente | | Concorrência = 1 | Cada fila de instância tem concurrency=1 no worker | | Delay aleatório | Após processar, sleep(30–46s) (salvo urgente) para simular comportamento humano | | Prioridade | Jobs urgentes têm priority=1, normais priority=5 | | 10 tentativas | attempts=10, backoff=fixed/30s (~5 min de retry total) | | Instâncias separadas | Cada instância WhatsApp tem sua própria fila e worker |


4. Identificação de Pontos de Falha e Gargalos

🔴 Críticos

PF-01: Redis como Single Point of Failure

O Redis é o único broker de filas. Se o Redis cair:

  • Nenhum novo job pode ser enfileirado.
  • Workers param de processar.
  • O reconciliador não consegue adquirir lock.
  • Consequência: mensagens ficam "travadas" em QUEUED no banco até o Redis voltar.

PF-02: Worker sem persistência de estado

O worker não persiste quais jobs estão sendo processados localmente. Se o processo cair no meio do processamento:

  • O job pode ter sido executado pela Evolution API mas o banco ainda marca como QUEUED.
  • O lock Redis de 60s vai expirar e o reconciliador vai reenfileirar — podendo resultar em envio duplicado.

PF-03: Status SENT dependente de webhook assíncrono da Evolution

O status só vai para SENT quando a Evolution API envia o webhook SERVER_ACK de volta. Se esse webhook falhar (rede, reinício, etc.):

  • A mensagem fica como QUEUED no banco mesmo tendo sido enviada com sucesso.
  • O reconciliador vai tentar reenviar após 7 dias → duplicata.

PF-04: Webhook do cliente sem retry

Em dispatchCustomerWebhook(), se o endpoint do cliente retornar erro ou timeout, o sistema apenas loga e segue em frente. Notificações de status perdidas não têm mecanismo de retry.

🟠 Importantes

PF-05: Expiração silenciosa por deliverUntil

Mensagens OTP expiram em 2 minutos. O reconciliador roda de hora em hora por padrão. Se o intervalo padrão for usado, mensagens OTP jamais serão processadas pelo reconciliador antes de expirar — dependem exclusivamente do BullMQ processar a tempo.

PF-06: Instância desconectada mantém mensagens em QUEUED por 7 dias

Quando a Evolution API retorna erro de desconexão, a mensagem volta para QUEUED (sem falhar) e fica esperando o reconciliador ou a reconexão. O healthcheck tenta reconectar, mas se a instância não reconectar, as mensagens ficam acumulando.

PF-07: Sem fila por tenant/projeto separados

Todos os projetos de um mesmo tenant/instância compartilham a mesma fila. Um burst de mensagens de um projeto pode atrasar mensagens de outro no mesmo tenant.

PF-08: coercePayload pode falhar silenciosamente

No reconciliador, se o payload da mensagem não for um JSON de objetos (coercePayload retorna null), a mensagem é marcada como FAILED com "Payload não disponível". Mensagens com bodyOverride=true têm payload com estrutura diferente e podem ser incorretamente descartadas pelo reconciliador.

PF-09: Lock do reconciliador com TTL curto (55s)

Se a reconciliação demorar mais de 55 segundos (ex: banco lento, muitas mensagens), o lock expira e um segundo worker pode iniciar uma reconciliação concorrente, causando reenfileiramento duplicado.

🟡 Menores

PF-10: Ausência de monitoramento de DLQ

Jobs que falharam todas as 10 tentativas vão para a Dead Letter Queue do BullMQ. Não há alarme automático quando jobs entram na DLQ.

PF-11: Delay aleatório pode atrasar mensagens urgentes do reconciliador

O delay de 30–46s é aplicado no finally do handler, mesmo em jobs criados pelo reconciliador que são urgentes por natureza (já atrasados).

PF-12: Risco de starving em concorrência=1

Com concurrency=1 por instância, mensagens se processam sequencialmente. Em cenários de alto volume, a fila pode crescer indefinidamente.


5. Diagrama de Estados da Mensagem

                     ┌─────────────┐
                     │   QUEUED    │ ◄─── Re-enfileirado pelo reconciliador
                     └──────┬──────┘
                            │
              ┌─────────────┼──────────────┐
              │             │              │
        Expirou       Enviou com       Erro de envio
       deliverUntil    sucesso        (não-fatal)
              │             │              │
              ▼             ▼              │
           FAILED    (aguarda webhook)     │ (retry BullMQ)
                           │              │
                    Evolution envia        │
                    SERVER_ACK webhook     │
                           │              │
                           ▼              │
                         SENT  ◄──────────┘
                           │
                    Usuário lê
                           │
                           ▼
                          READ

6. Sugestões de Melhoria para Máxima Resiliência

🚀 Alta Prioridade

ME-01: Implementar idempotência baseada em evolutionKeyId antes de reenfileirar

Problema: PF-02 e PF-03 — duplicatas por reenfileiramento sem saber se a Evolution já enviou.

Solução: Antes de reenfileirar via reconciliador, consultar a Evolution API pelo evolutionKeyId (se existir no banco) para verificar se a mensagem já foi enviada. Se confirmado, atualizar para SENT sem reenviar.

// No reconciliador, antes de reenfileirar:
if (msg.evolutionKeyId) {
    const alreadySent = await checkEvolutionMessageStatus(msg.evolutionKeyId);
    if (alreadySent) {
        await prisma.message.update({ where: { id: msg.id }, data: { status: 'SENT', sentAt: new Date() } });
        continue;
    }
}

ME-02: Implementar retry com fila para webhooks do cliente

Problema: PF-04 — notificações perdidas.

Solução: Mover dispatchCustomerWebhook() para uma fila BullMQ separada (webhook-delivery) com próprias tentativas e backoff exponencial. Isso também evita que erros de webhook bloqueiem o processamento da mensagem.

// Em vez de await dispatchCustomerWebhook(...) diretamente:
await webhookQueue.add('deliver', { webhookId, event, payload }, {
    attempts: 5,
    backoff: { type: 'exponential', delay: 5_000 },
});

ME-03: Reconciliador com intervalo adaptativo para OTP

Problema: PF-05 — mensagens OTP expiram antes do reconciliador rodar.

Solução: Adicionar um segundo reconciliador de alta frequência (a cada 30–60s) apenas para mensagens OTP ou qualquer mensagem com deliverUntil próximo. O reconciliador existente continua para as demais.

ME-04: Persistir estado pós-chamada Evolution antes do ACK

Problema: PF-03 — banco não sabe que a mensagem foi enviada enquanto aguarda SERVER_ACK.

Solução: Adicionar campo evolutionSubmittedAt ao modelo Message. Atualizar esse campo imediatamente após a chamada bem-sucedida à Evolution API. O reconciliador deve respeitar esse campo e não retentar mensagens onde evolutionSubmittedAt IS NOT NULL.

⚡ Média Prioridade

ME-05: Redis com replicação (Sentinel ou Cluster)

Problema: PF-01 — Redis como SPOF.

Solução: Configurar Redis com Sentinel (mínimo) ou Cluster para HA. O código já suporta resolução de primário via resolvePrimaryRedisUrl(), então a mudança seria apenas na configuração do ambiente.

ME-06: Aumentar TTL do lock do reconciliador dinamicamente

Problema: PF-09 — lock expira em reconciliações longas.

Solução: Usar o padrão de renovação periódica de lock (lock extension/watchdog): uma goroutine renova o TTL do lock a cada ttl/2 segundos enquanto a função ainda executa.

export async function withMessageReconcileLock(params) {
    // ... acquire lock ...
    const extender = setInterval(() => connection.pexpire(key, ttlMs), ttlMs / 2);
    try {
        return await params.fn();
    } finally {
        clearInterval(extender);
        // ... release lock ...
    }
}

ME-07: Corrigir reconciliador para suportar bodyOverride

Problema: PF-08 — mensagens com bodyOverride têm payload estruturado diferente e são descartadas.

Solução: No reconciliador, verificar se o payload contém "bodyOverride":true. Se sim, usar o fluxo sendRaw ao reenfileirar em vez de exigir templateVersionId.

ME-08: Alertas automáticos para DLQ

Problema: PF-10 — falhas silenciosas na DLQ.

Solução: Adicionar listener no evento failed do BullMQ para quando attemptsMade >= maxAttempts (job entrou em DLQ). Disparar alerta via Pushover/notificação para que o time possa investigar.

worker.on('failed', (job, err) => {
    if (job && job.attemptsMade >= job.opts.attempts) {
        pushoverService.sendDeveloperAlert({ title: 'Mensagem em DLQ', ... });
    }
});

ME-09: Dashboard de saúde das filas

Problema: Falta visibilidade operacional em tempo real.

Solução: Expor endpoint interno (ou via BullMQ Board) com métricas das filas: tamanho, jobs em delay, jobs failed, throughput/hora por instância. Alertar quando fila crescer além de threshold.

🔧 Baixa Prioridade

ME-10: Delay aleatório configurável por prioridade no reconciliador

Problema: PF-11 — delay desnecessário em mensagens já atrasadas.

Solução: Jobs criados pelo reconciliador ou com urgent=true devem ser marcados com um flag para que o handler aplique delayMs=0.

ME-11: Aumentar concorrência com controle por rate-limit da Evolution

Problema: PF-12 — concurrency=1 cria gargalo em alto volume.

Solução: Aumentar para concurrency=3–5 com rate-limiter baseado nos limites da Evolution API por instância. O BullMQ suporta rate-limiting nativo via rateLimit nas opções do worker.

ME-12: Circuit Breaker para a Evolution API

Solução: Implementar padrão de Circuit Breaker: se N chamadas seguidas à Evolution falharem, "abrir" o circuito por X segundos, marcando mensagens como QUEUED sem tentar, até que o circuito "feche" novamente.

ME-13: Retry com backoff exponencial em vez de fixo

Atual: backoff.type = 'fixed', delay = 30_000 (sempre 30s entre tentativas).

Melhoria: Usar backoff.type = 'exponential' para aguardar progressivamente mais entre tentativas (ex: 30s → 1min → 2min → 4min...), reduzindo pressão sobre a Evolution API em caso de interrupção prolongada.


7. Resumo dos Riscos por Severidade

| ID | Risco | Severidade | Impacto | |---|---|---|---| | PF-01 | Redis SPOF | 🔴 Crítico | Sistema para completamente | | PF-02 | Worker crash → duplicata | 🔴 Crítico | Mensagem enviada 2x | | PF-03 | Sem ACK → reenfileiramento | 🔴 Crítico | Mensagem enviada 2x | | PF-04 | Webhook sem retry | 🟠 Importante | Cliente não é notificado | | PF-05 | OTP expira antes do reconciliador | 🟠 Importante | Mensagem nunca chega | | PF-06 | Instância offline por 7 dias | 🟠 Importante | Volume de QUEUED cresce | | PF-07 | Sem isolamento de projetos | 🟠 Importante | Starvation entre projetos | | PF-08 | bodyOverride descartado | 🟠 Importante | Preview nunca reenviado | | PF-09 | Lock reconciliador curto | 🟡 Menor | Dupla reconciliação rara | | PF-10 | DLQ silenciosa | 🟡 Menor | Falhas passam despercebidas | | PF-11 | Delay em jobs atrasados | 🟡 Menor | Latência extra desnecessária | | PF-12 | Concorrência=1 gargalo | 🟡 Menor | Alto volume acumula fila |


8. Roadmap de Resiliência (Ordem de Implementação Sugerida)

Semana 1-2 (Crítico):
  ✅ ME-04: Campo evolutionSubmittedAt
  ✅ ME-01: Verificar status na Evolution antes de reenfileirar
  ✅ ME-02: Fila de entrega de webhooks com retry

Semana 3-4 (Importante):
  ✅ ME-07: Corrigir reconciliador para bodyOverride
  ✅ ME-03: Reconciliador de alta frequência para OTP
  ✅ ME-06: Renovação de lock do reconciliador
  ✅ ME-08: Alertas automáticos DLQ

Mês 2 (Infraestrutura):
  ✅ ME-05: Redis com Sentinel/HA
  ✅ ME-09: Dashboard de saúde das filas
  ✅ ME-11: Concorrência aumentada com rate-limit
  ✅ ME-13: Backoff exponencial

Mês 3+ (Otimização):
  ✅ ME-10: Delay configurável por prioridade
  ✅ ME-12: Circuit Breaker para Evolution API

Documento gerado em: 2026-03-21
Analisado por: Antigravity (AI)
Arquivos analisados: send-message.ts, message-reconciler.ts, index.ts, all-instances-healthcheck.ts, message.service.ts, rate-limit.service.ts, message-reconcile-lock.ts