Incidente CRM-Multi
09/09/2026 · 09h42 às 09h51 BRT

Diagnóstico feito só com leitura: journald, log do gateway, estado do monitor de saturação e o código no disco. Nenhum serviço foi reiniciado, nenhum arquivo foi alterado, nada foi publicado.

A API congelou por 9 minutos. O monitor acertou.

Das 09:42:26 às 09:51:28 a API do CRM parou de responder. Não foi falso positivo da Cloudflare e não foi o Postgres: o pool de conexões da aplicação esgotou, e uma consulta síncrona dentro do auth_gate transformou isso em apagão do processo inteiro, em vez de lentidão.

Verificado Li da fonte hoje: log, banco, estado gravado ou o código no disco.
Suposição Leitura minha em cima do que li. Marcada onde aparece, nunca vestida de medição.
Não determinado Procurei e os registros não permitem afirmar. Fica em aberto, sem preencher.
Território O CRM e o monitor são da Bia. Isto é diagnóstico, não intervenção.

A resposta curta

Se você só ler esta parte, é isto.

Queda real de 9 minutos, das 09:42:26 às 09:51:28 BRT.

O alerta chegou às 09:50:29, depois de duas sondas consecutivas sem resposta. Ele estava certo: as sondas das 09:45 e das 09:50 não têm nenhuma resposta no log da API, enquanto as das 09:40 e das 09:51 estão lá.

A tela dos atendentes caiu

Dois operadores logados receberam 500 em várias rotas, e um delete de mensagem falhou.

O atendimento no WhatsApp não parou

Entraram 3 mensagens às 09:46 e 1 às 09:50, a Sofia respondeu nos dois minutos, e o gateway não registrou nenhuma falha de entrega de webhook.

Não foi infra e não foi o Postgres

Sem reboot, sem OOM, sem deploy na janela. O banco ficou em 46 de 100 conexões, com folga. Quem acabou foi o pool da aplicação.

A linha do tempo

Tudo em BRT, lido do journald da API e do log do container do gateway.

  1. 09:40:19
    Sonda da Cloudflare responde 200

    Vinda do IP 172.71.232.21. Ciclo normal de 5 em 5 minutos, como nas sondas anteriores das 09:20, 09:25, 09:30 e 09:35.

  2. 09:42:26
    Última resposta normal da API

    GET /api/conversations/views devolve 200. Depois deste ponto, nada mais completa em tempo normal.

  3. 9 minutos congelado 66 erros de pool, todos caindo em múltiplos exatos de 30 segundos
  4. 09:42:56
    Primeiro estouro do pool

    Uma requisição que começou às 09:42:26 espera 30 segundos por conexão e morre. É o primeiro dos 66 erros.

  5. 09:45
    A sonda das 09:45 não é respondida

    O worker desiste em 8 segundos. A primeira liberação do pool depois disso só acontece às 09:45:26, tarde demais. Primeira falha da sequência.

  6. 09:50:27
    Até o Redis do SSE expira

    redis.exceptions.TimeoutError: Timeout connecting to server. Uma conexão assíncrona falhando por tempo é a assinatura de event loop parado, não de Redis doente.

  7. 09:50:29
    O alerta sai para o Telegram

    Segunda falha consecutiva. O worker fez o que devia fazer.

  8. 09:51:28
    A API destrava e drena a fila

    Numa mesma linha do log voltam vários webhooks com 200 e um /health. Esse /health é a sonda das 09:50 sendo servida 88 segundos atrasada, muito depois de o worker ter abortado. As requisições estavam enfileiradas, não recusadas.

  9. 09:55:17
    Ciclo normal de volta

    Sonda respondida em tempo, sem erro de pool desde as 09:51:28.

    Verificado

A causa técnica

São duas coisas somadas: um pool pequeno que esgotou, e um detalhe do middleware que transforma pool esgotado em processo mudo.

1. O pool é o default do SQLAlchemy

backend/app/db/session.py sem parâmetros de pool
def make_engine(url: str) -> Engine:
    return create_engine(
        url, connect_args={"options": f"-c timezone={settings.db_timezone}"}
    )
Sem pool_size e sem max_overflow, vale o default: 5 mais 10, ou seja 15 conexões, com espera de 30 segundos. A mensagem de erro bate exatamente com isso.
sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached,
connection timed out, timeout 30.00
66 ocorrências entre 09:42:56 e 09:51:28, sempre em múltiplos exatos de 30 segundos. O tráfego na janela era baixo, entre 1 e 32 requisições por minuto, contra 125 por minuto às 09:39. Não foi pico de carga: foi travamento.

Não foi o banco

O pg_saturation_monitor roda de 2 em 2 minutos e registrou a janela inteira como ok: 34 conexões às 09:42, subindo para 46 de 100 no pico e voltando para 35 às 09:52. O Postgres tinha folga. As 11 conexões a mais são exatamente o pool da aplicação enchendo até o teto e ficando lá por 8 minutos.

  • Sem reboot: a máquina estava de pé há 2 dias e 20 horas.
  • Sem deploy e sem restart: a API subiu em 08/09 às 23:47 e não caiu.
  • O gateway Evolution respondeu em sub-milissegundo durante todo o congelamento.

2. Por que vira apagão, e não lentidão

O /health não toca em banco, em Redis, em nada. Ele deveria ter continuado respondendo mesmo com o pool no chão.

backend/app/api/main.py o endpoint que o monitor bate
@app.get("/health")
def health() -> dict:
    return {"status": "ok"}

Ele parou mesmo assim, e o motivo está no middleware global que roda antes de qualquer rota:

backend/app/api/main.py auth_gate
def _usuario_ativo(user_id: int) -> bool:
    session = SessionLocal()
    try:
        user = session.get(User, user_id)
        return user is not None and user.is_active
    finally:
        session.close()


@app.middleware("http")
async def auth_gate(request: Request, call_next):
    ...
        payload = decode_access_token(token) if token else None
        if payload is None or not _usuario_ativo(int(payload["sub"])):
            return JSONResponse(status_code=401, ...)
_usuario_ativo é síncrono e é chamado direto de dentro de um middleware async, sem passar por threadpool. Em toda requisição autenticada de /api/* ele pede uma conexão ao pool no meio do event loop.

Com o pool vazio, essa chamada não retorna: ela fica esperando os 30 segundos do pool_timeout segurando o event loop. Nesse intervalo o processo inteiro para. Não é a rota lenta, é o loop parado.

Pool esgotado 15 de 15 conexões ocupadas auth_gate chama _usuario_ativo() consulta síncrona no event loop em toda requisição autenticada Event loop travado até 30 segundos, o pool_timeout /health morre calado webhooks ficam na fila SSE Redis expira O processo inteiro fica mudo a sonda externa vê queda total, não lentidão
O caminho é este, e ele explica os três sintomas de uma vez: o /health que não toca em nada some do log, os webhooks do gateway ficam presos até 09:51:28 e o Redis do SSE expira por tempo. Nenhuma dessas três coisas tem a ver com a outra, a não ser pelo event loop que elas dividem.

Os dois endpoints que seguram conexão

Ambos recebem a sessão por Depends(get_session), o que prende a conexão do pool pela duração inteira da requisição, e fazem chamada HTTP bloqueante ao gateway lá dentro.

post_typing backend/app/api/inbox.py:990

O atendente digitando no compositor dispara o indicador de digitação no WhatsApp do cliente. A sessão do banco fica aberta enquanto a chamada ao gateway acontece.

4,00s por chamada front chama a cada 3s
o trecho sessão presa durante o HTTP
def post_typing(
    conversation_id: int,
    session: Session = Depends(get_session),
    _: User | None = Depends(get_current_user_optional),
) -> dict:
    ...
    _sender_da_conversa(session, conversation_id).send_presence(
        number, state="composing",
        delay_ms=settings.typing_attendant_delay_ms
    )
Os 4,00 segundos não são estimativa: estão medidos no log do gateway, linha a linha, entre 09:41:24 e 09:42:28. É o typing_attendant_delay_ms de 4000 sendo cumprido do outro lado.
post_refresh_avatar backend/app/api/crm.py:170

Recupera a foto do contato. São duas chamadas HTTP ao gateway por contato, uma pelo telefone e outra pelo @lid, e o front dispara sozinho quando a ficha do contato abre (ContactFicha.tsx:138, autoRefreshAvatar).

o trecho mesmo padrão
def post_refresh_avatar(
    contact_id: int,
    session: Session = Depends(get_session),
) -> AvatarRefreshResult:
    result = refresh_contact_avatar(session, contact_id, redis_atual())

O que isso significa na prática

Com 15 conexões no total, qualquer coisa que segure uma conexão por segundos durante uma chamada de rede reduz a folga do sistema inteiro. Não estou dizendo que estes dois endpoints causaram o incidente de hoje: estou dizendo que eles são o mecanismo pelo qual o pool fica apertado, e que a conta de 15 é pequena demais para esse padrão.

Verificado no código e no log do gateway

O que quebrou e o que não quebrou

A distinção importa: o prejuízo foi de operação interna, não de atendimento perdido.

Quebrou

  • A tela dos atendentes. Dois operadores logados receberam 500 em /api/conversations/views, /api/presence e /api/presence/ping.
  • Um delete de mensagem falhou no meio da janela, às 09:43:56.
  • O carregamento de tela travou: entre 09:46:26 e 09:51:28 quase nada completou.

Não quebrou

  • Mensagens continuaram entrando. 3 inbound às 09:46 e 1 às 09:50, conferidos direto na tabela por consulta de leitura.
  • A Sofia continuou respondendo. Saída registrada às 09:46 e às 09:50.
  • Nenhuma falha de webhook no log do gateway, e a fila drenou com 200 às 09:51:28.

Sem indício de mensagem ou lead perdido. Ressalva honesta: não fiz reconciliação mensagem a mensagem entre o gateway e o banco, então isso é ausência de indício, não prova de completude.

Recorrência

Erros de QueuePool limit por dia, contados no journald da API.

05/09
0
06/09
0
07/09
0
08/09
88
09/09
66
Em 08/09 foram duas janelas: aproximadamente 11:53 às 12:20, e por volta das 16:59. Em 09/09, uma só, das 09:42 às 09:51.

O journald desta unidade só cobre desde 05/09 às 10:41. Não afirmo que isso nunca aconteceu antes disso, porque não tenho como olhar. O que dá para dizer é que apareceu em dois dias seguidos dentro da janela que existe.

Verificado

O buraco do monitor

Este ponto provavelmente é novidade, e é o motivo de os congelamentos de ontem terem passado em silêncio.

Sondas da Cloudflare que chegaram na API em 08/09, por hora
12 sondas por hora das 00h às 10h20 e das 18h50 às 24h, o esperado para o ciclo de 5 minutos.
Quase zero das 10h20 às 18h50, cerca de 8 horas e 30 minutos.

Foi exatamente aí que o CRM travou duas vezes

As duas janelas de congelamento de 08/09, por volta das 11:53 e das 16:59, caíram inteiras dentro do intervalo em que o monitor não estava sondando. Por isso nenhum alerta saiu naquele dia. O monitor ficou cego 8 horas e meia e ninguém soube.

Não investiguei a causa dessa lacuna, porque o worker é seu e eu não ia mexer nele. O que dá para dizer com certeza é que a lacuna existe no lado do servidor: as requisições simplesmente não chegaram.

Verificado no log da API

As 5 recomendações

Em ordem de valor, não de esforço. São descrições, não intervenções: nada disso foi executado.

1
Tirar a consulta do event loop

Mover _usuario_ativo para threadpool, ou trocá-la por um cache curto. Só isso já impede que aperto de pool vire queda total: o /health e o SSE continuariam vivos, e o monitor externo não reportaria apagão.

Maior impacto
2
Nunca segurar sessão de banco durante chamada ao gateway

Fechar ou commitar a sessão antes de send_presence e fetch_avatar_url, ou empurrar essas chamadas para o consumer. É o que reduz a pressão sobre o pool na origem.

Corrige a causa
3
Aumentar o pool e ligar pool_pre_ping

Subir pool_size e max_overflow compra folga imediata. É paliativo assumido, útil enquanto 1 e 2 não saem.

Paliativo
4
Instrumentar antes do próximo episódio

Ligar log_min_duration_statement no Postgres, dar leitura do log do banco ao usuário de operação e ativar a coleta do sysstat. Sem isso, o próximo incidente vai ficar tão em aberto quanto o gatilho deste.

Destrava diagnóstico
5
Investigar as 8h30 sem sonda em 08/09

Um monitor que fica cego por 8 horas sem avisar é um problema separado do CRM, e mais silencioso que ele. Vale entender se foi o cron do worker, o estado do Durable Object ou o caminho até o servidor.

Confiança no monitor

O que não foi determinado

Preferi deixar em aberto a preencher com o que parece razoável.

O que consumiu as 15 conexões às 09:42:26

Sei o mecanismo e sei o minuto exato, mas não sei qual trabalho ocupou o pool. Para responder isso eu precisaria de query lenta ou lock no log do Postgres, e não consegui chegar lá.

Não determinado
Por que não cheguei lá

Três portas fechadas para o usuário mari: o log do Postgres é postgres:adm com permissão 640 e não há sudo para lê-lo; o sysstat está instalado mas não coleta, então não existe histórico de CPU nem de disco; e o /etc/caddy/Caddyfile também é ilegível, sem access log do domínio do CRM.

Limite de acesso
A correlação com a entrega de fotos

O erro aparece pela primeira vez em 08/09, o mesmo dia da entrega registrada em ops/ENTREGA-FOTOS-SETORES-2026-09-08.md, e o post_refresh_avatar é justamente um dos endpoints que segura conexão durante chamada HTTP. Isso é coincidência de data mais um mecanismo plausível. Não medi nada que ligue os dois, então trato como pista para você conferir, nunca como causa.

Correlação, não prova

Achado lateral, sem relação com este incidente

A linha app.services.inbound_durable: Inbox <uuid> aguardando nova tentativa (RuntimeError) se repete o dia inteiro, dentro e fora da janela do congelamento. É problema pré-existente e independente, não sintoma nem causa do que aconteceu hoje. Fica só registrado, para alguém olhar quando fizer sentido.

Verificado, pré-existente