
A porta de mão única — o restart que mantinha o serviço mudo
A falha bem-educada
O chat do Capivara parou de responder com contexto. Não parou de responder.
Continuava devolvendo 200, continuava escrevendo um texto coerente, continuava rápido. A diferença estava num campo do payload: sources: []. Zero fontes. A memória do ecossistema — aquela que dá substância pra resposta — tinha sumido sem fazer barulho.
Esse é o tipo de falha que eu mais temo, porque ela não disputa atenção. Um 500 me acorda. Um 200 vazio me deixa dormir e acordar com o painel funcionando mal há três dias.
O diagnóstico de cinco segundos
Fui olhar o serviço de embeddings, o componente que transforma texto em vetor e sem o qual a busca semântica não existe. Porta fechada, conexão recusada.
O diagnóstico de cinco segundos é óbvio: “tá morto, reinicia”. Reiniciei.
A porta não abriu. Esperei um pouco, vi a mesma porta fechada, e concluí que o restart não tinha pegado — então reiniciei de novo. O que eu não sabia é que uma segunda sessão de agente, olhando a mesma porta fechada, estava fazendo exatamente a mesma coisa do outro lado.
Duas sessões reiniciando o mesmo container, cada restart zerando o relógio do anterior, e as duas concluindo “morto” porque a porta não abria. O serviço nunca esteve morto. Ele estava sendo interrompido a cada tentativa de acordar.
Por que reiniciar era o problema
O serviço de embeddings só abre a porta depois de terminar o benchmark de startup. Ele carrega o modelo, mede throughput real de inferência, e só então sobe o servidor que escuta conexão. Nessa ordem.
Numa máquina ociosa isso leva alguns minutos. Naquela madrugada o host estava com um treino pesado disputando recurso, e o cold load tinha caído na faixa de dez a quarenta minutos. Medido depois, num trecho menos pior: treze minutos com dois núcleos e meio.
Ou seja — porta fechada com o container Up e CPU alta é o estado normal de um processo trabalhando. Cada restart não curava nada: matava o progresso do load e começava o relógio do zero. Quem estava “curando” era justamente o que mantinha o serviço mudo.
Descobrir isso exigiu inverter a pergunta. Em vez de “por que ele morreu?”, “quem está reiniciando?”. E a resposta era eu.
A segunda porta de mão única
Depois que o embedder finalmente ficou de pé sozinho, o chat continuou sem fontes. Aí estava o bug de verdade, e ele era do meu lado.
O núcleo da memória tem um fallback sensato: se o embedder não responde, degrada pra uma busca por palavra-chave em SQLite. O problema é que essa degradação é um latch de mão única — uma flag que vira verdadeira e nunca volta a ser falsa.
Uma falha de trinta segundos degradava o processo inteiro. E como nada no caminho de leitura testava se o embedder tinha voltado, o único modo de recuperar a memória vetorial era reiniciar o backend do hub. Um evento transitório virava estado permanente, com uma porta de saída que só eu podia abrir, manualmente, no momento em que eu lembrasse que ela existia.
Os dois defeitos eram o mesmo defeito com roupas diferentes: um sistema tratando “ainda não” como “nunca”.
O auto-heal
O conserto foi no brain.py, na função que entrega o store pra todo mundo (_get_store). Em vez de aceitar a degradação como definitiva, o store degradado passa a ser revalidado de tempos em tempos com um probe barato — que pergunta ao embedder se ele está disponível, sem tocar no banco vetorial.
_STORE_RETRY_SECS = float(os.getenv("BRAIN_STORE_RETRY_SECS", "120"))
def _embedder_ok(store) -> bool:
"""Probe barato no embedder do store (nao toca no Qdrant)."""
emb = getattr(store, "_embedder", None)
if emb is None:
return False
try:
probe = getattr(emb, "is_available", None)
if callable(probe):
return bool(probe(timeout=2.0))
emb.embed(["ping"])
return True
except Exception as exc: # qualquer falha = ainda degradado
log.info("brain: embedder ainda indisponivel (%s)", type(exc).__name__)
return False
def _get_store():
global _store, _store_degraded_since
with _store_lock:
if _store is not None and getattr(_store, "_use_sqlite", False):
if _store_degraded_since is None:
_store_degraded_since = time.time()
elif time.time() - _store_degraded_since >= _STORE_RETRY_SECS:
if _embedder_ok(_store):
log.warning("brain: embedder recuperado — reconstruindo store (Qdrant)")
_store = None
else:
_store_degraded_since = time.time()
if _store is None:
from core.store import MemoryStore
store = MemoryStore(backend="auto", group=YURUMI_GROUP)
if getattr(store, "_use_sqlite", False):
_store_degraded_since = time.time()
log.warning("brain: store iniciou degradado (SQLite) — retry em %.0fs", _STORE_RETRY_SECS)
else:
_store_degraded_since = None
_store = store
return _store
Quatro decisões dentro de dezesseis linhas, e cada uma existe porque a versão ingênua dela queimou:
- Marca o instante da degradação em vez de tentar recuperar na hora. O retry imediato só aumenta o thrash.
- Não reconstrói antes do intervalo. O embedder pode estar em cold load de meia hora — testar a cada requisição seria reinventar o loop que causou o incidente.
- Embedder de volta descarta o store (
_store = None) e deixa a construção seguinte criar um limpo, voltando ao banco vetorial. - Embedder ainda fora re-arma o timer — mantém o SQLite servindo e tenta de novo depois. O fallback continua útil; ele só perde o direito de ser eterno.
O with _store_lock existe porque duas requisições simultâneas podem entrar no mesmo diagnóstico ao mesmo tempo. Sem lock, as duas reconstroem.
Os testes que fecham a porta
Oito casos em tests/test_brain_store_heal.py, e eles não usam embedder nem banco vetorial real — o store é um fake injetado, com um embedder falso que responde uma flag. Isso deixa o teste determinístico e rápido o suficiente pra rodar em CI.
| Caso | O que garante |
|---|---|
| store saudável | nasce sem flag de degradação |
| nasce degradado | registra o instante |
| degradou em runtime | detecta e data do mesmo jeito |
| antes do intervalo | não reconstrói (sem thrash) |
| embedder voltou | reconstrói e limpa a flag |
| embedder ainda fora | mantém SQLite e re-arma o timer |
| sem embedder | probe retorna falso |
| probe levanta exceção | exceção conta como indisponível, não como crash |
Os dois últimos são os que eu quase pulei. São o custo de pensar “e se o probe quebrar?” em vez de “e se o embedder estiver fora?”.
A sequência de diagnóstico que virou regra
Escrevi no AGENTS.md do Capivara, em caixa alta, a regra que aquele incidente custou a madrugada inteira pra produzir:
Nunca reiniciar o container do embedder para “curá-lo”.
E embaixo dela, a ordem que eu queria ter seguido:
- Medir o embedder direto — uma chamada mínima de embedding, com timeout curto. Isso responde “ele está processando?” em vez de “a porta abriu?”.
- Embedder saudável + resposta sem fontes = o problema é o store degradado. A cura é reiniciar o backend do hub (segundos), não o container (meia hora).
- Embedder mudo com uptime curto = esperar. Uptime menor que trinta minutos significa carregando, não travado.
Também plantei prevenção no outro ponto da falha: todo healer do ecossistema passou a ser warming-aware — mede o uptime antes de reiniciar e se recusa a matar um cold load em andamento. Reiniciar virou último recurso com critério, não reflexo.
O resto do mesmo dia
O 11/09 não foi só o embedder, e os outros dois consertos são da mesma espécie:
- Sync do espelho de backup. Duas execuções do mesmo sync (cron + manual) intercalaram escrita e geraram violação de chave única no destino. Virou
INSERT OR REPLACE+ lock de instância única com detecção de lock órfão — e, o mais importante, tabela que falha agora faz o processo inteiro sair com código de erro. Backup quente que anuncia “sucesso” mentindo é pior que backup que confessa. - CI do runner próprio. Um bootstrap de configuração lia um arquivo
.envincondicionalmente no import. No runner auto-hospedado o arquivo não existe, e todo job que importava o módulo morria comFileNotFoundErrorhavia dois dias. Ajuste de duas linhas, e a vergonha de dois dias de CI vermelho tratado como “normal do runner”.
Métricas
| Item | Antes | Depois |
|---|---|---|
Fontes no /api/chat/ask |
0 | 5 |
| Recuperação da memória vetorial | restart manual do backend | automática, em até ~2 min |
| Testes do caminho de heal | 0 | 8 |
| Suite do backend | — | 243 passed |
| Cold load do embedder no incidente | interrompido a cada restart | ~13 min e converge |
O número que importa não é o dos testes: é que, pela primeira vez, o serviço de memória se recupera sozinho de uma falha que nem é dele.
O que fica
Porta fechada não é diagnóstico — uptime é. “Sem resposta” cobre desde processo morto até processo carregando modelo. Separar os dois é a diferença entre uma cura e uma sabotagem com boas intenções.
Fallback sem caminho de volta não é resiliência, é vazamento. Todo degrade precisa de uma política de re-promote, nem que seja um probe barato a cada dois minutos. Um latch de mão única transforma qualquer transitório em permanente.
Um healer sem critério é um agressor com crachá. Quanto mais automatizado o seu loop de cura, mais barulho ele faz quando está errado — e mais difícil fica enxergar que ele é a causa. Foram literalmente duas sessões inteligentes se ajudando uma na outra pra manter um serviço no chão.
Quando duas sessões trabalham no mesmo sistema, falta protocolo, não inteligência. O reinício concorrente não foi burrice de ninguém: foram duas pessoas certas com a mesma heurística rápida e nenhuma visão do relógio uma da outra.