
O lock que nasceu no loop errado — e o browser pool que dizia 'não disponível neste worker'
⚡ “Browser agent não disponível neste worker”
Entre 08 e 09/08, o Arachne acordava intermitente. Um workflow de scraping rodava, e do nada: browser agent não disponível neste worker. Rodava de novo — funcionava. De novo — falhava.
No journal, o RuntimeError enigmático:
RuntimeError: <asyncio.locks.Lock object at 0x7f...> is bound to a different event loop
Um lock preso a um event loop que não era o do worker. Como um cadeado que foi montado numa porta e depois a fechadura mudou de casa — a chave certa, o lugar errado.
🧠 O contexto: o browser pool compartilha um lock
O Arachne mantém um browser pool (Camoufox + Playwright) — navegadores reutilizados entre scraping e VRT pra não subir um browser novo a cada chamada. Um pool assim precisa de um lock: dois workers não podem usar o mesmo browser ao mesmo tempo.
# O pool, simplificado
_browsers = [] # lista de browsers vivos
_last_gc = 0.0 # último garbage collect
async def _get_browser(headless=True):
async with _lock: # protege o acesso ao pool
# ... acha ou cria um browser
O lock era global, criado no import do módulo:
_lock = asyncio.Lock() # ❌ criado na hora do import
E aí morava o problema.
🔧 A luta: o event loop que muda de casa
No Python asyncio, um asyncio.Lock() criado fora de um loop fica preso ao loop que estiver rodando naquele momento — ou ao loop atual na primeira vez que é usado.
O Arachne tem dois caminhos de execução:
- Requests normais (FastAPI) → rodam no event loop principal do processo
- Workflows agendados (cron scheduler) → rodam via threadpool → cada thread ganha o seu próprio event loop
Quando o módulo era importado no processo principal, o lock nascia preso ao loop principal. Quando o workflow rodava na threadpool e tentava async with _lock, o Python comparava o loop do lock com o loop corrente → RuntimeError: bound to a different event loop.
O sintoma era duplo e confuso:
- O erro técnico (
bound to a different event loop) aparecia em logs internos - A mensagem de negócio (“browser agent não disponível neste worker”) aparecia pra quem consumia o workflow
Intermitente porque dependia de quando o import acontecia e em qual thread o workflow caía.
💡 A resolução: lazy init — nascer no loop certo
O fix (commit 457326e, 09/08 02:59): o lock deixa de ser criado no import e passa a ser criado dentro do loop corrente, na primeira vez que é usado.
# ❌ Antes — nascia preso ao loop do import
_lock = asyncio.Lock()
# ✅ Depois — nasce no loop que está rodando agora
_lock = None # type: Optional[asyncio.Lock]
async def _get_lock() -> asyncio.Lock:
global _lock
if _lock is None:
_lock = asyncio.Lock() # criado DENTRO do loop corrente
return _lock
E nos dois pontos de uso:
# async with _lock: → async with await _get_lock():
O await _get_lock() roda dentro da coroutine — o asyncio.Lock() é criado no loop certo, o loop que está executando aquele worker. 16 linhas adicionadas, 3 removidas. Só isso.
A história de ontem foi um generator que não fechava a sessão do Postgres. Hoje foi um lock que nascia no loop errado. O padrão é o mesmo: o recurso precisa nascer no contexto onde vai viver.
📊 Métricas
| Métrica | Valor |
|---|---|
| Commit | 457326e (09/08 02:59) |
| Arquivo | api/app/browser_agent/engine.py |
| Diff | +16 / -3 |
| Usos do lock corrigidos | 2 (_maybe_gc, _get_browser) |
| Erro | RuntimeError: bound to a different event loop |
| Sintoma de negócio | “browser agent não disponível neste worker” (intermitente) |
| Testes do Arachne | 2.732 |
🎯 Aprendizados
-
Recurso async global = problema de loop —
asyncio.Lock(),asyncio.Queue()e afins criados no import ficam presos ao loop do processo. Se o código roda em threadpool/outro loop (cron, workers), quebra com “bound to a different event loop”. -
Lazy init resolve o ciclo de vida — criar o recurso dentro da coroutine (no 1º uso) garante que ele nasce no loop que vai usá-lo. É o padrão pra qualquer singleton async.
-
Erro intermitente = pista de contexto — falha que aparece e some é quase sempre dependência de timing: qual loop, qual thread, qual import. O journal com o RuntimeError completo foi o que entregou a causa.
-
Dois sintomas, uma causa — a mensagem de negócio (“não disponível neste worker”) escondia o erro técnico. Sempre caçar a pilha completa antes de “resolver” o sintoma.