O ETL do Equity Tracker roda dentro de um container Docker, e o entrypoint desse container é etl/scheduler.py, executado como PID 1 (etl/Dockerfile:25). A rotina diária está ancorada às 00:05 UTC, fixada em DAILY_ANCHOR_HOUR=0 e DAILY_ANCHOR_MINUTE=5 (etl/pipeline_runner.py:27-28). Um dos steps desse scheduler chamava sys.exit().
Esse sys.exit() era meu. Eu escrevi achando que estava encerrando aquele passo. Estava encerrando o interpretador inteiro, e o Docker, cumprindo a política de restart, subia o container do zero.
Descobri no mesmo dia em que liguei um alerta de Telegram no fim do pipeline. O alerta não chegou. Foi a ausência da mensagem, e não uma linha vermelha no log, que me fez ir olhar o que estava acontecendo com o container.
O problema não era o pipeline, era controle de fluxo
Nenhum cálculo estava errado. Nenhuma consulta ao banco voltava torta. O step fazia o que devia fazer. Ao chegar numa condição sem mais nada a processar, chamava sys.exit() para terminar bem — do jeito que se termina um script de linha de comando que vive três segundos e some.
Só que esse step não vive três segundos. Ele roda dentro do mesmo processo Python que é o PID 1 do container, o processo que precisa continuar de pé até a próxima âncora diária. sys.exit() levanta SystemExit, e SystemExit sobe a pilha inteira. Não encerra a função que chamou. Encerra o interpretador.
PID 1 morto é container morto. A partir daí quem decide é o Docker, que cumpre a política de restart e sobe tudo de novo, zerado. O scheduler reaparece limpo, sem memória nenhuma do que estava fazendo antes.
O ETL inteiro cabe num container, num scheduler e num requests.post
O deploy é Docker com docker-compose.yml orquestrando o container do ETL. Dentro dele, etl/scheduler.py é o entrypoint e etl/pipeline_runner.py cuida da ordem dos steps. É uma montagem pequena de propósito: um processo, um agendador, nenhum broker de fila no meio.
Os alertas seguem a mesma linha. Não uso python-telegram-bot, não tem async, não tem polling. É requests.post cru contra a Bot API do Telegram, e requests==2.32.5 é a única dependência de transporte do projeto. Duas funções resolvem o assunto:
# etl/telegram_alerts.py
TG_MSG_LIMIT = 4000 # o limite real do Telegram é 4096; a folga é proposital
def send_alert(message: str): # linha 24
...
def format_pipeline_summary(engine, pipeline_name, run_date, all_ok): # linha 49
...
Ao fim de cada rodada do pipeline "daily", etl/pipeline_runner.py:362 monta o resumo e dispara a mensagem:
try:
send_alert(format_pipeline_summary(engine, pipeline_name, run_date, all_ok))
except Exception:
...
Aquele except Exception parecia cobrir o pipeline inteiro. Não cobria.
SystemExit não é Exception, e foi por isso que o reinício foi silencioso
Em Python, SystemExit não herda de Exception. Herda direto de BaseException, na mesma vizinhança de KeyboardInterrupt. A consequência prática cabe em duas linhas de console:
>>> issubclass(SystemExit, Exception)
False
>>> issubclass(SystemExit, BaseException)
True
Um except Exception genérico, inclusive o de etl/pipeline_runner.py:362, não captura um sys.exit(). A exceção atravessa qualquer rede de proteção escrita para erro de aplicação, chega ao topo e encerra o processo. Não existia captura de nível superior capaz de agir, porque a que existia estava olhando para a classe errada.
A ironia é que o alerta de Telegram tinha sido ligado naquela mesma manhã. Sem ele, o padrão continuaria invisível, porque um reinício silencioso não produz erro nenhum para alguém ler. Foi a mensagem que não chegou que denunciou o problema.
LEIA TAMBÉM · OpiniãoA única checagem confiável num pipeline agendado é a idade do artefatoO painel diz que a tarefa está pronta para rodar, mas isso só significa que ela existe. Para saber se ela funcionou de verdade, olhe se o resultado dela é recente: o arquivo, a linha nova, o dado do dia.O conserto custa uma camada de código e não elimina o risco
O bug cobrou reinícios silenciosos do container, consumindo tempo de processamento sem alarme e sem log. O conserto é simples de enunciar: nenhum step chama sys.exit(). Ele levanta uma exceção de domínio, e quem decide o que fazer com ela é o step runner.
# antes, dentro de um step
if not barras:
sys.exit()
# depois
if not barras:
raise StepSemDados("nenhuma barra para a data")
Enunciar é simples, manter não é. O raise sozinho não resolve. Ele exige uma hierarquia de exceções de domínio e um step runner que capture cada uma e decida, step a step, entre registrar e seguir para o próximo ou abortar o pipeline sem derrubar o processo. Recomendo esse caminho — e recomendo sabendo que é mais uma camada de código para manter, escrita para um erro que aparece uma vez a cada muito tempo.
O risco residual é a parte que me incomoda, porque disciplina própria não dá conta dela. Bibliotecas de terceiros chamam sys.exit() por conta própria: argparse e click fazem exatamente isso quando o parsing da linha de comando falha. Se um step invocar qualquer coisa que passe por um parser de CLI, o mesmo padrão se repete sem que eu tenha escrito sys.exit() em nenhum lugar do meu código.
Sair do interpretador é decisão do orquestrador, não do step
O que eu faria diferente não é escrever menos sys.exit(). É tratar qualquer saída do interpretador como decisão que só o orquestrador pode tomar. Num processo que roda como PID 1 de um container, ou como processo único de um serviço, nenhuma chamada deveria ter o poder de encerrar tudo por conta própria. Nem a minha, nem a de uma biblioteca que importei sem ler por dentro.
sys.exit() é ferramenta de script curto. Abre, faz uma coisa, sai, e o sistema operacional recolhe o que sobrou. Um step de scheduler é o oposto disso — ele é um trecho de um processo que precisa continuar existindo depois que o trecho termina. Essa diferença parece semântica até o dia em que separa um erro registrado no log de um container que reinicia sozinho sem ninguém saber.
