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.
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.
Se você só ler esta parte, é isto.
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á.
Dois operadores logados receberam 500 em várias rotas, e um delete de mensagem falhou.
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.
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.
Tudo em BRT, lido do journald da API e do log do container do gateway.
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.
GET /api/conversations/views devolve 200. Depois deste ponto, nada mais completa em tempo normal.
Uma requisição que começou às 09:42:26 espera 30 segundos por conexão e morre. É o primeiro dos 66 erros.
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.
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.
Segunda falha consecutiva. O worker fez o que devia fazer.
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.
Sonda respondida em tempo, sem erro de pool desde as 09:51:28.
VerificadoSão duas coisas somadas: um pool pequeno que esgotou, e um detalhe do middleware que transforma pool esgotado em processo mudo.
def make_engine(url: str) -> Engine:
return create_engine(
url, connect_args={"options": f"-c timezone={settings.db_timezone}"}
)
sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached,
connection timed out, timeout 30.00
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.
O /health não toca em banco, em Redis, em nada. Ele deveria ter continuado respondendo mesmo com o pool no chão.
@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:
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, ...)
/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.
/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.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.
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.
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
)
typing_attendant_delay_ms de 4000 sendo cumprido do outro lado.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).
def post_refresh_avatar(
contact_id: int,
session: Session = Depends(get_session),
) -> AvatarRefreshResult:
result = refresh_contact_avatar(session, contact_id, redis_atual())
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 gatewayA distinção importa: o prejuízo foi de operação interna, não de atendimento perdido.
/api/conversations/views, /api/presence e /api/presence/ping.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.
Erros de QueuePool limit por dia, contados no journald da API.
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.
VerificadoEste ponto provavelmente é novidade, e é o motivo de os congelamentos de ontem terem passado em silêncio.
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 APIEm ordem de valor, não de esforço. São descrições, não intervenções: nada disso foi executado.
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.
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.
Subir pool_size e max_overflow compra folga imediata. É paliativo assumido, útil enquanto 1 e 2 não saem.
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.
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 monitorPreferi deixar em aberto a preencher com o que parece razoável.
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 determinadoTrê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.
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.
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.