Skip to content

Discussion: standardize CRON jobs on UTC and reconsider the per-job timezone parameter #39

@knutties

Description

@knutties

Summary

We want to discuss standardizing CRON schedules on UTC and removing (or deprecating) the per-job timezone parameter. The cron expression should always be interpreted as UTC, and we explicitly do not want to rewrite/offset the cron string itself to encode a timezone.

This came out of #38, which fixed a deserialization failure on starts_at/ends_at and surfaced that the timezone parameter isn't doing what it appears to.

Current state

The per-job timezone (cron_timezone) is effectively cosmetic today, and in a misleading way:

  1. Firing is delegated to pg_cron. db::jobs::build_cron_command + cron.schedule(name, cron_expression, command) (crates/common/src/db/jobs.rs) hand the raw cron_expression to pg_cron. pg_cron evaluates it in its own cluster-global timezone (cron.timezone, default GMT) — it never sees the per-job cron_timezone.
  2. timezone is used in exactly one place: the API handler's compute_next_cron (crates/api/src/handlers/jobs.rs), purely to populate cron_next_run_at at create/update time.
  3. cron_next_run_at is write-once. The worker never recomputes it (no references to cron_next_run_at in crates/worker), so it's a stale snapshot after the first tick.

Net effect: a job with timezone: Asia/Kolkata, cron 0 9 * * * reports next_run_at as 09:00 IST but actually fires at 09:00 UTC (14:30 IST). The parameter promises local-time scheduling we don't deliver.

Proposed direction

  • Treat the cron expression as UTC always.
  • Do not rewrite the cron string to bake in an offset (e.g. turning 0 9 * * * into 30 3 * * *). String rewriting is brittle — it breaks across DST transitions, day/month rollovers, and field ranges/steps (0 9-17 * * MON-FRI), and it makes the stored expression no longer match what the user typed.
  • Use starts_at/ends_at as absolute UTC window bounds (this is already how cron_ends_at gates firing in pg_cron).
  • Deprecate the timezone parameter explicitly rather than silently: it currently sets an expectation (local-time, DST-aware recurrence) the system does not honor.

Trade-off to acknowledge

timezone and starts_at/ends_at are not substitutes for each other:

  • timezone would define what wall-clock the recurring cron fields mean and is inherently DST-aware for a recurring rule ("09:00 local every day" as the UTC offset shifts twice a year).
  • starts_at/ends_at are two absolute instants bounding when the schedule is active. You cannot reconstruct a DST-aware recurring rule from two instants.

So going UTC-only is a deliberate capability cut: we lose DST-correct local recurring schedules (e.g. "09:00 business hours in New York"). The decision here is that we'd rather have a simple, honest, consistent UTC model than carry a half-implemented timezone feature. Users who need local times convert to UTC at author time and accept fixed-offset behavior.

Scope of work (for discussion, not committing yet)

  • Decide: hard-remove timezone vs. deprecate-and-ignore (with a clear API warning/docs note).
  • Stop requiring timezone for CRON jobs in handlers::jobs::create (currently returns 400 when absent).
  • Recompute / stop persisting a misleading cron_next_run_at (compute in UTC, and ideally refresh it on tick, or drop it as a stored field).
  • Interpret starts_at/ends_at as UTC instants consistently (already the case after fix(api): accept datetime-local timestamps on job create/update #38; confirm and document).
  • Migration for existing jobs that carry a non-UTC cron_timezone (leave as metadata? backfill? document behavior change).
  • Update the dashboard job form (crates/dashboard/src/pages/workspace_detail.rs) and API docs/smithy model to reflect UTC-only semantics.

Alternative (rejected for now)

Make timezone authoritative — honor it in pg_cron (per-schedule timezone support / session TZ) and recompute next-runs DST-aware. Strictly more capable, but more moving parts and contrary to the "keep cron UTC, don't format the cron string" preference. Captured here so the trade-off is on record.

References

  • PR fix(api): accept datetime-local timestamps on job create/update #38 — datetime-local deserialization fix that surfaced this.
  • crates/common/src/db/jobs.rsbuild_cron_command, register_pg_cron.
  • crates/api/src/handlers/jobs.rscompute_next_cron, CRON create/update path.
  • crates/common/src/models/job.rsCreateJob / UpdateJob (timezone, starts_at, ends_at).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions