Seu Job de Cron Disparou Uma Hora Antes: o Bug de DST Que o Hermes Acabou de Corrigir


É a noite em que os relógios atrasam, e seu relatório noturno das 01:30 roda duas vezes — ou seu briefing das 09:00 chega às 08:00. Você não mudou nada, o servidor nunca reiniciou, e os logs mostram o scheduler fazendo exatamente o que lhe foi mandado. Nada na sua configuração está errado. O bug era aritmética: o Python soma e subtrai datetimes aware dentro de uma mesma timezone por wall clock, e a hora repetida é justamente onde wall clock e tempo real deixam de concordar.

O Hermes corrigiu isso (#120314, resgatando #119710). Se você roda qualquer job com uma timezone configurada, vale dez minutos da sua atenção.

A hora repetida é uma armadilha para o fold

Cada job que o Hermes executa a partir de um agendamento carrega um instante. A zona vem do ZoneInfo configurado no profile — hermes_time.get_timezone() o retorna, ou None para uma instalação no horário local do servidor. O hermes cron create não tem flag de timezone própria; a configuração do profile é a que conta.

Agora considere uma zona que observa horário de verão. Na transição de outono, 01:00–02:00 no horário local acontecem duas vezes: uma no offset antigo (EDT) e uma no novo (EST). O Python mantém as duas anexando uma flag fold — fold=0 para a primeira passagem, fold=1 para a segunda.

O detalhe: quando você compara ou subtrai dois datetimes aware que compartilham o mesmo objeto tzinfo, o Python os compara por wall clock e ignora o fold completamente. Então 01:11 EST, que é 06:11 UTC, era tratado como devido em 01:14 EDT, que é 05:14 UTC. O mesmo ponto cego está em aware + timedelta, que também é aritmética de wall clock e descarta o fold na saída.

Uma linha do código-fonte diz isso melhor do que um parágrafo conseguiria:

def _seconds_after(dt, seconds):
    """*dt* plus real *seconds*, in *dt*'s zone. Aware ``+ timedelta`` is wall-clock arithmetic
    that drops ``fold``, so inside a fall-back hour it lands an hour off."""
    return (dt.astimezone(timezone.utc) + timedelta(seconds=seconds)).astimezone(dt.tzinfo)

O corrigido para todo o subsistema é essa única ideia, aplicada em todo lugar: comparar e medir em UTC, converter de volta para a zona do chamador só para exibição.

O que realmente mudou

A varredura de vencimento foi reescrita em torno de instantes UTC e de segundos reais decorridos. A lista é longa o bastante para mostrar o quão fundo a aritmética de wall clock havia chegado:

  • o próprio gate de vencimento, a janela de graça do fast-forward e a janela de graça do one-shot,
  • o atraso do dispatch,
  • a liveness de claim — se o claim de outro worker sobre um job ainda está fresco,
  • o re-arm de stale-error, o pré-gate de reparo de timezone e a recuperação de one-shot,
  • o backstop de misfire de provider externo que dispara jobs atrasados.

A mesma classe de bug apareceu em mais quatro lugares, e todos os quatro agora são baseados em instantes:

  • unreachable_retry.plan_retry. Um job que falhou e pediu retry em 300 segundos calculava now + 300 s às 01:30 EST e caía em 01:35 EDT — 55 minutos no passado. O retry então disparava no tique seguinte, em vez de esperar cinco minutos.
  • quota_hold. O fim da janela de uso de um provider podia ficar uma hora errado, e hold_active comparava wall clocks.
  • resume_job. A verificação de “o slot passou enquanto você estava pausado” comparava wall clocks.
  • _repeat_alert_withheld. A janela de alerta repetido era medida em wall time.

Um lugar foi examinado e deliberadamente deixado como estava: a verificação de skew em claim_job_for_fire já funcionava com instantes, porque o horário agendado com o qual ela compara é armazenado como uma string UTC, então os dois valores carregam objetos tzinfo diferentes e o Python cai na comparação em tempo real por acidente de construção. É exatamente o tipo de detalhe do qual essa classe de bug se alimenta.

Duas armadilhas relacionadas que o Hermes já trata

Enquanto você está no código do seu próprio scheduler, dois problemas vizinhos vale a pena conhecer, porque o Hermes também esbarra neles:

Timestamps legados naive. Linhas mais antigas armazenavam horários sem timezone alguma. O Hermes as lê como wall time local do sistema — que é o que as criou — e converte, em vez de assumir que eram UTC. Essa escolha existe para evitar declarar um job futuro como “não devido” só porque a zona da máquina difere da do profile.

Um offset obsoleto depois de uma mudança de timezone. Se você move seu profile de uma zona para outra, um timestamp armazenado pode carregar um offset que faz uma execução futura parecer atrasada — o comentário do código dá o exemplo de 21:00+10 virando 13:00+02. O Hermes compara os wall clocks naive para separar “seu relógio ainda não chegou aqui” de “isso genuinamente nunca rodou”.

Há um terceiro caso, e é a razão pela qual um next_run_at obsoleto é classificado em vez de simplesmente descartado: uma migração de timezone legítima e uma edição manual de uma expressão cron precisam de respostas opostas. Normalizar uma linha legada em UTC para a timezone do profile muda sua representação sem mudar a intenção, então reancorá-la como se alguém tivesse editado a expressão pularia uma ocorrência devida que nunca disparou. O Hermes, portanto, verifica se o wall clock armazenado ainda é uma ocorrência legal da expressão atual: se for, a linha veio de uma migração; se não, veio de uma edição.

O que isso significa para você

  • Só jobs com uma timezone configurada foram afetados. Uma instalação no horário local do servidor nunca errou.
  • A janela é estreita e rara — cerca de uma hora, duas vezes por ano, em zonas que observam horário de verão. Se você nunca notou, provavelmente não estava rodando nada dentro daquela hora.
  • O sintoma nem sempre é “cedo”. Dependendo de qual lado da transição uma comparação caía, um job podia chegar uma hora atrasado também.
  • Nada para configurar. Não há flag para definir nem chave de config para virar; os jobs pegam o corrigido junto com o código.
  • Se você não pode atualizar hoje, a mitigação prática é manter agendamentos críticos fora da hora repetida — nos dois dias de mudança de horário de verão, mova um job das 01:30 para as 03:30.

Para ver o que seus próprios jobs acham que é a próxima execução, hermes cron list mostra os jobs agendados e hermes cron status diz se o scheduler está rodando — muitas queixas de “meu job disparou cedo” acabam sendo um segundo processo de scheduler. hermes cron runs mostra as tentativas de execução duráveis por job, hermes cron incidents lista incidentes de falha registrados, e hermes cron doctor checa seus jobs quanto a problemas comuns de saúde, que é a primeira parada mais rápida depois de qualquer disparo estranho.

O corrigido está na main depois da tag v0.21.4, então chega com o seu próximo pull da main ou na próxima release com tag. Para o quadro mais amplo, nosso guia do cron doctor percorre as verificações de saúde em detalhe e o guia de automação com cron cobre agendamentos e modos de entrega desde o começo.