Skip to content

feat: evaluate cron schedules in a configured timezone (#1042) - #1044

Merged
milkway merged 2 commits into
mainfrom
feat/1042-scheduler-timezone
Jul 29, 2026
Merged

feat: evaluate cron schedules in a configured timezone (#1042)#1044
milkway merged 2 commits into
mainfrom
feat/1042-scheduler-timezone

Conversation

@milkway

@milkway milkway commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Closes #1042. Segue o #1043 (que consertou os timestamps das telas de admin): mesmo conceito faltando, mas aqui com consequência operacional, não visual.

O bug

O agendador casava as expressões cron contra o relógio UTC (jobs.rs:118). Quem escreve 0 8 * * * esperando 08h ganhava o job às 05h locais. E, diferente do #1041, isto não dá para resolver no navegador: quem dispara é o servidor, que não tem viewer.

A correção

Campo server.timezone no ruscker.yml, nome IANA (America/Recife). O cron passa a ser casado contra o relógio de parede daquela zona — croner faz o match em naive_local(), então basta entregar um DateTime<Tz> — e a página de Agendamentos mostra próxima/última execução na mesma zona, rotulada.

Ausente ⇒ UTC, o comportamento de hoje. Essa é a parte importante: box e hugo já têm agendamentos em produção, e um upgrade não pode mover o horário de disparo deles. Configurar a chave é ato deliberado, e a doc diz na cara que isso desloca os agendamentos existentes pelo offset.

Nome de zona, não offset fixo. O banco IANA carrega as regras históricas de horário de verão, então "08:00 local" continua significando isso atravessando uma transição. Um teste prende São Paulo em −03 em julho e −02 em janeiro de 2018, quando o Brasil ainda tinha DST — justamente o que um -03 hardcoded não expressaria.

Achado durante a implementação

A página de Agendamentos tinha uma cópia própria da chamada ao croner. Se eu tivesse deixado, ela seguiria calculando em UTC enquanto o runner migrava para a zona configurada — uma UI mentindo sobre quando o job dispara, que é pior que não ter UI. Agora ela delega para jobs::next_occurrence, e um teste prende as duas respostas juntas.

Modos de falha, que são silenciosos por natureza

  • Nome inválido (BRT é abreviação, não zona) → cai em UTC com warning de validação (Warning::UnknownTimezone + formatter no CLI), nunca derruba o boot por um typo.
  • O fuso efetivo entrou no banner de boot, senão uma chave posta na seção errada só apareceria quando um job noturno rodasse horas fora.
  • A ajuda do campo cron dizia "em UTC" nos 4 idiomas; agora fala do fuso do servidor e a página anexa a zona real, então ninguém precisa abrir o YAML para saber em que relógio está escrevendo.

Verificação ao vivo

Instância com timezone: America/Recife (máquina em UTC−3), Playwright/Chrome:

o quê resultado
0 8 * * * → próxima execução 2026-07-30 08:00 -03 (= 11:00 UTC; antes diria 08:00 UTC)
* * * * *disparou de fato 18:23:19Z = 15:23 local, o minuto exato que a página previu, exit 0
ajuda do formulário "…no fuso horário do servidor… (America/Recife)"
histórico de execuções 2026-07-29 15:23 -03

Gate: cargo test completo verde, clippy --all-targets -- -D warnings limpo, i18n-check OK (as 4 .ftl mudaram, então rodei a suíte inteira — o i18n-check sozinho não pega id duplicado).

Notas

  • chrono-tz 0.10.4 conferida no crates.io antes de entrar; é dado puro + aritmética, por isso é admissível no crate puro ruscker-config.
  • book/src/configuration.md inclui o YAML_SCHEMA.md, então a doc da chave já sai no ruscker.com.
  • Não mexi nos timestamps pré-formatados de users.rs:374 e mfa.rs:198, que seguem rotulados UTC — são horários de eventos passados, não de disparo; se for para alinhar, o lugar é o modelo do Auditoria (e mais 4 telas) exibem timestamps em UTC — 3h adiantados no fuso do Brasil #1041.

🤖 Generated with Claude Code

milkway and others added 2 commits July 29, 2026 15:26
The scheduler matched cron expressions against UTC, so an operator
writing `0 8 * * *` got a job at 05:00 their time. Found while fixing
the admin's UTC timestamps (#1041); same missing concept, but with an
operational consequence rather than a cosmetic one — and one the
browser can't fix, because the thing firing the job has no viewer.

Adds `server.timezone` to `ruscker.yml`, an IANA zone name. Cron is
then matched against the wall clock there (croner does the matching on
`naive_local()`, so handing it a `DateTime<Tz>` is the whole trick),
and the Schedules page shows next/last run in the same zone, labelled.

Absent ⇒ UTC, which is what every install does today. That is the
important part: box and hugo have schedules running in production, and
an upgrade must not move their firing times. Setting the key is a
deliberate act, and the docs say plainly that it shifts existing
schedules by the offset.

A zone NAME, not an offset: the zone database carries the historical
DST rules, so "08:00 local" keeps meaning that across a transition. A
test pins São Paulo at −03 in July and −02 in January 2018, back when
Brazil still had DST.

The Schedules page now computes "next run" through the scheduler's own
`jobs::next_occurrence` instead of its private copy of the croner call
— that copy would have kept computing in UTC while the runner moved to
the configured zone, i.e. a UI lying about when a job fires. A test
asserts the two agree.

Failure modes are quiet by design, so they're surfaced: an
unparseable name (`BRT` is an abbreviation, not a zone) falls back to
UTC with a validation warning rather than failing the boot, and the
effective zone joins the startup banner so a key set in the wrong
section doesn't hide until a nightly job runs hours off.

The cron field's help text said "UTC" in all four locales; it now
names the server timezone and the page appends the actual zone, so
nobody has to open the YAML to know which clock they're writing in.

Verified live with `timezone: America/Recife`: `0 8 * * *` shows
2026-07-30 08:00 -03, and a per-minute schedule fired at 18:23:19Z —
15:23 local, the exact minute the page predicted, exit 0.

Closes #1042.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewing my own PR, two claims turned out to be asserted rather than
verified. Both are now tests.

The doc comment on `next_occurrence` said a spring-forward gap means
"the next matching local time simply doesn't exist that day". It does
run: croner lands on the transition instant, so the job fires as the
clock jumps rather than being skipped. (The fall-back half was right —
the repeated hour fires only once.) Comment corrected, behaviour
pinned in a test against Europe/Lisbon, which still observes EU DST.

The second is operator-facing and was missing from the docs entirely:
adopting a zone can fire ONE EXTRA run on the switch-over day. The
fire marker is a UTC instant, so re-reading the same expression on a
westward clock puts today's occurrence after the marker — a job that
already ran at 08:00 UTC runs again at 08:00 local. It settles from
the next day. YAML_SCHEMA and the shipped example now say so, with the
remedy (disable, restart with the zone set, re-enable) for a
non-idempotent ETL. That matters concretely for box and hugo, which
have schedules running today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@milkway

milkway commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Auto-review (autor = revisor)

Dois achados, ambos meus, ambos virados em teste no efdd605.

1. Meu comentário sobre horário de verão estava errado — eu afirmei sem verificar. Escrevi que num gap de spring-forward "o horário local simplesmente não existe naquele dia". Sondei o croner de verdade (Europe/Lisbon, que ainda tem DST europeu):

transição o que o croner faz
2026-03-29, 01:00→02:00 (01:30 não existe) retorna 2026-03-29T01:00:00Z — o instante da virada. O job dispara quando o relógio pula; NÃO é pulado.
2026-10-25, 02:00→01:00 (01:30 acontece 2×) retorna a primeira (00:30Z) e depois salta para o dia seguinte — sem execução duplicada.

Ou seja: nem perde nem duplica execução, o que é o comportamento desejável — mas metade do que eu tinha escrito era falso. Comentário corrigido e comportamento preso em teste.

2. Consequência operacional que faltava na doc: adotar um fuso pode disparar UMA execução extra no dia da virada. O marcador de disparo é um instante UTC, então reler a mesma expressão num relógio a oeste coloca a ocorrência de hoje depois do marcador — um job que já rodou às 08:00 UTC roda de novo às 08:00 locais. Normaliza a partir do dia seguinte.

Isso importa concretamente para box e hugo, que têm agendamentos rodando hoje. Documentado no YAML_SCHEMA e no exemplo shipado, com a saída para um ETL não-idempotente: desabilitar o agendamento, reiniciar com o fuso setado, reabilitar. Preso em teste também.

Verificações que passaram:

  • Só existem dois usos de croner no código: o agendador (agora com fuso) e a checagem de parse na criação (independente de fuso). Não há terceiro lugar para divergir — era o risco que já tinha mordido na página de Agendamentos.
  • Sem migração, sem SQL dual-dialect — a mudança não toca persistência; o instante gravado continua UTC.
  • cargo-audit passou no CI com a dependência nova.

Gate local reexecutado: ruscker-config verde, clippy --all-targets -D warnings limpo.

@milkway
milkway marked this pull request as ready for review July 29, 2026 18:39
@milkway
milkway merged commit 6b3f1f7 into main Jul 29, 2026
6 checks passed
@milkway
milkway deleted the feat/1042-scheduler-timezone branch July 29, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agendador de cronjobs avalia expressões cron em UTC — jobs disparam fora do horário local esperado

1 participant