feat: evaluate cron schedules in a configured timezone (#1042) - #1044
Conversation
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>
Auto-review (autor = revisor)Dois achados, ambos meus, ambos virados em teste no 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):
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:
Gate local reexecutado: |
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 escreve0 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.timezonenoruscker.yml, nome IANA (America/Recife). O cron passa a ser casado contra o relógio de parede daquela zona — croner faz o match emnaive_local(), então basta entregar umDateTime<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
-03hardcoded 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
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.Verificação ao vivo
Instância com
timezone: America/Recife(máquina em UTC−3), Playwright/Chrome:0 8 * * *→ próxima execução2026-07-30 08:00 -03(= 11:00 UTC; antes diria 08:00 UTC)* * * * *→ disparou de fato18:23:19Z= 15:23 local, o minuto exato que a página previu, exit 02026-07-29 15:23 -03Gate:
cargo testcompleto verde,clippy --all-targets -- -D warningslimpo,i18n-checkOK (as 4.ftlmudaram, então rodei a suíte inteira — oi18n-checksozinho não pega id duplicado).Notas
chrono-tz0.10.4 conferida no crates.io antes de entrar; é dado puro + aritmética, por isso é admissível no crate puroruscker-config.book/src/configuration.mdinclui oYAML_SCHEMA.md, então a doc da chave já sai no ruscker.com.users.rs:374emfa.rs:198, que seguem rotuladosUTC— 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