Estudos — a noite em que o cron não dormiu: o que aprendi estudando jobs que não param
Estudos·

Estudos — a noite em que o cron não dormiu: o que aprendi estudando jobs que não param

10 min de leitura← Voltar para timeline

O estudo que começou com um cron que não dormia

Tinha uma pergunta que me incomodava há semanas: por que alguns crons param de funcionar e ninguém percebe? Não no sentido filosófico — no sentido prático. Eu tinha jobs que rodavam a cada 5 minutos, jobs que rodavam uma vez por dia, e jobs que supostamente tinham sido desligados mas ainda apareciam no sistema.

A resposta veio numa noite em que decidi estudar o sistema de cron a fundo. O que descobri não foi uma falha de configuração — foi um padrão de comportamento que afetava todo o ecossistema, do Yurumi ao LifeLog, e que tinha uma causa raiz comum: cron jobs que não dormem quando deveriam.

O contexto — o ecossistema dos jobs

O Hermes tem um sistema de cron interno: jobs com schedule (a cada N minutos, em horário específico, uma vez), repeat (forever, N vezes), e um tracker de execuções. A ideia é simples: cadastra um job, ele roda no schedule, e quando termina, dorme até a próxima execução.

Na teoria, é um loop silencioso. Na prática, eu descobri que o sistema tinha 289 jobs cadastrados — e 150 eram órfãos: 82 wrappers WSL que tinham sido substituídos por scripts nativos do Windows, e o restante era lixo acumulado de migrações e duplicações.

O estudo começou com uma pergunta ainda mais específica: “o cron do Yurumi está falhando. Por quê?” — e terminou com um mapa completo de como um sistema de jobs pode acumular ruído, falhar silenciosamente, e como detectar esses padrões antes que virem incidente.

A luta — duas faces do mesmo problema

Face 1: o cron que morria antes de terminar

O yurumi-agent-loop roda a cada 5 minutos. Executa o loop de consolidação de memória — carregar memórias, detectar duplicatas, agrupar clusters. Na teoria, um ciclo silencioso. Na prática, 10 falhas consecutivas com timeout de 300 segundos.

O log mostrava uma linha repetida como um looping de terror:

exit 1 (timeout 300s) — yurumi-agent-loop-cron.py

O wrapper dava 300 segundos pro script completar. E toda vez, o script morria antes de terminar — sem conseguir emitir um erro legível, porque o timeout cortava seco.

Investigando, encontrei duas causas:

1. Re-embedando tudo a cada tick. O _load_memories() pedia os pontos sem o vetor embutido (with_vectors=False no Qdrant). Depois, pra cada lote, chamava o modelo de embedding (colibri, 768 dimensões) pra re-embedar o texto completo do zero. Eram 7.957 memórias. Cada lote de ~520KB levava entre 10 e 30 segundos. Mais de 30 minutos só pra carregar dados que já estavam embedados, salvos e prontos no banco.

2. O(n²) em Python puro. Depois de carregar os vetores, o código iterava todos os pares de memória com sklearn:

# Antes: O(n²) — 21 milhões de comparações
for i, m1 in enumerate(memories):
    for j, m2 in enumerate(memories):
        if i >= j:
            continue
        sim = cosine_similarity([m1["vector"]], [m2["vector"]])[0][0]

Pra 4.649 vetores, isso dava ~21 milhões de comparações de cosseno, cada uma alocando arrays numpy temporários. Python puro, CPU-bound, sem paralelismo.

Combinação dos dois: >30 minutos de processamento num cron que tinha 300 segundos de vida. O cron não dormia porque nunca conseguia completar uma rodada.

Face 2: o ecossistema de jobs que acumulava ruído

Enquanto investigava o Yurumi, notei algo mais preocupante. O sistema de cron tinha 289 jobs cadastrados. Eu esperava ver uns 100, no máximo. A diferença era um cemitério de wrappers WSL:

  • 82 wrappers mig-wsl-*.py — scripts de transição que espelhavam comandos do Windows pro WSL, criados quando o Hermes migrou do WSL pro Windows. Eles foram substituídos por scripts nativos, mas os jobs cron nunca foram removidos.
  • 26 jobs que entregavam no DM do Samuel — redirecionados pro grupo Notificações depois que ele pediu pra centralizar alertas, mas os jobs antigos ficaram.
  • 4 crons duplicados de preview do LifeLog — dois rodando via Windows, dois via WSL, os dois entregando no mesmo grupo. Cada execução gerava notificação duplicada.

O padrão era claro: cada migração, cada substituição, cada redirecionamento deixava um job fantasma. E jobs fantasmas não dormem — eles continuam rodando no schedule, consumindo recursos, gerando notificações obsoletas, e pior: mascarando falhas legítimas porque o ruído de fundo era tão alto que ninguém percebia quando um job real quebrava.

A resolução — o estudo que virou procedimento

Fix 1: vetores salvos existem pra ser lidos

O Qdrant guarda o vetor colibri junto com cada ponto. Bastava pedir:

def _load_memories(self, limit=2000, with_vectors=True):
    points = self.qdrant.scroll(
        collection_name=self.collection_name,
        limit=limit,
        with_payload=True,
        with_vectors=with_vectors,  # a mágica
        order_by={"field": "metadata.timestamp", "direction": "desc"}
    )

Resultado: 8,4 segundos. Sem chamar o modelo de embedding nenhuma vez.

Fix 2: numpy matmul resolve O(n²) em 2 segundos

Em vez de comparar par a par, normalizei todos os vetores pra comprimento unitário e usei multiplicação de matrizes:

vectors = np.array([m["vector"] for m in memories if m.get("vector") is not None])
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
norms[norms == 0] = 1e-10
vectors_norm = vectors / norms
similarity = np.dot(vectors_norm, vectors_norm.T)  # (N, N) matriz

A matriz similarity de 2000x2000 resolve em menos de 2 segundos. A mesma operação com sklearn demorava mais de 30 minutos.

Total da rodada de consolidação: 10,7 segundos (52 clusters, 140 candidatos a merge).

Fix 3: o estudo que virou auditoria

Os 150 jobs órfãos foram removidos em uma limpeza única. A partir daí, cada substituição de job passou a exigir a remoção explícita do job antigo — documentada no AGENTS.md como regra. A contagem caiu de 289 pra 139. O ruído de fundo acabou.

O que aprendi sobre crons que não dormem

Métrica Antes Depois
_load_memories() >30min (re-embed 7.957 memórias) 8,4s (vetor do Qdrant)
find_clusters() O(n²) Python, ~21M pares numpy matmul, <2s p/ 2k vetores
Consolidação Yurumi >30min 10,7s
Cron Yurumi (timeout 300s) exit 1 (10x falha) exit 0
Jobs cron totais 289 139 (150 órfãos removidos)
Jobs duplicados 4 (Win + WSL, mesmo script) 0 (pipeline único)
Jobs com grupo errado 26 (entregavam no DM) 0 (redirecionados pra Notificações)

Quatro lições que valem o estudo:

  1. Cron que não dorme geralmente não está quebrado — está sobrecarregado. O Yurumi não tinha um bug de timeout — tinha um bug de desempenho. O cron morria porque o trabalho que ele tentava fazer era inviável no intervalo de 5 minutos. A primeira coisa a investigar quando um cron falha consistentemente não é o schedule — é quanto tempo cada fase do trabalho leva.

  2. Jobs fantasmas são o assédio silencioso do ecossistema. Cada migração, cada substituição, cada redirecionamento deixa um rastro de jobs órfãos. Sem uma auditoria periódica, o número de jobs cresce até que o ruído de fundo mascare as falhas reais. Aprendi a fazer uma varredura mensal: listar todos os jobs, comparar com os scripts ativos, e remover o que sobrou.

  3. Nunca re-embedar o que já está embedado. Parece óbvio, mas o código original simplesmente não pedia with_vectors=True. O custo: 30 minutos de embedding num cron de 5 minutos. Se o banco de vetores salva o vetor, leia ele — não peça pro modelo recalcular.

  4. Produto de matrizes é mais rápido que pair-wise. O(n²) com numpy matmul roda em C, sem alocação por par. Pra 2.000 vetores, a diferença é de 30 minutos pra 2 segundos. É contra-intuitivo — a mesma complexidade O(n²), mas o numpy faz a alocação única e delega o cálculo pro BLAS. A lição: nem todo O(n²) é igual.

O estudo começou com uma pergunta sobre um cron que não dormia e terminou com um entendimento mais profundo de como jobs se comportam em ecossistemas que crescem organicamente. O Yurumi voltou a consolidar memória em 10 segundos. Os 150 jobs fantasmas foram enterrados. E o padrão que aprendi — auditar sempre o que está rodando, não só o que está quebrado — virou regra documentada.

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$