Skip to content

docs(adr): ADR-0022 schedule timezone (per task, default system local) (#166) - #168

Merged
chinkan merged 3 commits into
mainfrom
docs/adr-schedule-timezone
Oct 6, 2026
Merged

chinkan merged 3 commits into
mainfrom
docs/adr-schedule-timezone

Conversation

@chinkan

@chinkan chinkan commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Docs only. Adds ADR-0022 (Accepted) for #166: evaluate schedules in a per-task timezone instead of UTC. No code changes.

Refs #166

Summary of the decision

  • Default timezone: config [general] timezone (IANA, via chrono-tz), else system local (TZ / OS zone), else UTC. Logged at boot with its source; warn when it ends up UTC (typical in Docker).
  • Portal Tasks page shows the effective default timezone (new GET /api/schedule/timezone) and each task's timezone.
  • Per-task timezone column (PO option C): new rows get the effective default; boot migration stamps existing recurring rows UTC so their fire times do not change. Existing one-shot rows get the system-detected zone, which matches today's chrono::Local reading of naive datetimes. No automatic rewrite of cron fields; users change a task's timezone in the portal.
  • API / tool: timezone on POST/PUT /api/tasks (400 invalid_timezone), in GET /api/tasks items, and as an optional schedule_task argument.
  • Config crons (summarize_cron, user_model_cron, heartbeat) use the effective default timezone.
  • Evaluation: one next_fire(expr, tz, after) on croner 4 with DateTime<Tz>, used for both the live job and next_run_at. No hand-rolled offsets.
  • DST (PO locks): a skipped local time fires once right after the jump; an ambiguous local time fires once, at the first occurrence. Applies to all patterns, not only fixed-time ones.
  • Arming: tokio-cron-scheduler 0.15.1 freezes a FixedOffset when the job is created (and parses with croner 3), so it cannot follow DST. Recurring jobs become self-rescheduling one-shots behind a stable handle.
  • Copy: remove the fix(scheduler): accept 5-field cron by padding seconds 0 (ADR-0021) #167 portal hint (Times are in UTC for now. / 時間而家係 UTC。), replace Evaluated in UTC. in the schedule_task description, and show "now" in the default timezone in the system prompt.
  • Docker: document setting [general] timezone and/or TZ.

Rejected: (A) re-reading existing rows as local time, which can shift them 8h without warning; (B) rewriting cron fields once, which breaks on DOW/DOM/L/ranges/steps/DST/half-hour offsets; a single global timezone; hand-rolled offsets; Job::new_async_tz.

Also adds a Schedule timezone entry to GLOSSARY.md.

Does not block #155 (already merged in #167).

chinkan commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

TL ACCEPT (docs) on tip 4f6be68 — ADR-0022 for #166.

Locks baked in: per-task timezone (option C); legacy recurring rows → UTC on boot; new rows → default TZ (config override → TZ/system → UTC); portal shows default + per-task TZ; remove temporary UTC portal hint and Evaluated in UTC. tool copy; DST skip → fire once after jump, ambiguous → first occurrence; croner 4 one-shot chain (tokio-cron-scheduler DST limitation noted).

Waiting Product GO + QA GO + CI before merge. No tag.

…ion (#166)

- Bottom line: after migration no task's fire time may change.
- Absolute (RFC 3339) one-shot: timezone = system zone, display-only.
- Naive one-shot: timezone = UTC, like recurring rows.
- Tests: fire-time-unchanged checks; QA must verify one legacy one-shot.
@chinkan

chinkan commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner Author

ADR-0022 amended: legacy one-shot migration (TL lock + PO Product GO) (new tip 86e227f)

This replaces the earlier amendment at 8d25c6a, which stamped legacy naive one-shots as UTC. That was wrong: today naive one-shots are read with chrono::Local, so on a Hong Kong host a UTC stamp would delay them by 8 hours and break the PO bottom line. The PO withdrew "naive → UTC" and gave Product GO on the corrected rules (2026-10-06). Product GO follows the new tip 86e227f. Merge should use 86e227f, not 8d25c6a or 4f6be68.

Decision 3 (boot migration), locked

  • Bottom line (PO): after migration, no task's fire time may change.
  • Legacy recurring rows → timezone = UTC.
  • Legacy naive one-shot rows (%Y-%m-%dT%H:%M:%S) → system local at migration (the zone chrono::Local uses: TZ, else the OS zone), not UTC and not the [general] timezone override. This keeps today's fire instant.
  • Legacy absolute one-shot rows (RFC 3339) → system local too, for display consistency. Display-only.
  • Zone detection must use the same source as chrono::Local. If it cannot be resolved to an IANA name, the migration logs warn naming the affected naive one-shots.
  • The "raise with TL / PO before coding" note is removed. This is locked.

Decision 4 (after migration)

  • parse_one_shot_target reads naive values in the row's own timezone, never a hard-coded chrono::Local. This is needed when [general] timezone differs from the system zone (e.g. Docker on UTC with config Asia/Hong_Kong).

Tests / QA

  • Migration test: one-shot rows (naive and absolute) get the system zone. Fire-time-unchanged check runs with process zone Asia/Hong_Kong: naive 2099-01-01T09:00:00 → 01:00:00Z, not 09:00:00Z.
  • New parse_one_shot_target tests: row timezone used, result independent of process TZ, config ≠ system case, RFC 3339 ignores the row timezone.
  • Manual QA on a non-UTC host: one legacy naive one-shot keeps its fire time and gets the system zone (not UTC); one legacy recurring row gets UTC with next run unchanged; a new naive one-shot with a config zone ≠ system fires at the config wall time.

Consequences and the GLOSSARY entry were updated to match. New commit on top, no force-push. Not merged; waiting for QA + CI.

#166)

TL lock implementing the PO bottom line (no fire time may change):
- Recurring legacy rows -> UTC.
- Legacy one-shot rows (naive and absolute) -> system local zone at
  migration (the zone chrono::Local reads naive values in today), not
  UTC and not the config override. Stamping UTC would delay naive
  one-shots by 8h on HKT hosts.
- After migration parse_one_shot_target reads naive values in the
  row's own timezone, never a hard-coded chrono::Local.
- Remove the "raise with TL/PO" note; update Consequences, Tests, QA
  and the glossary entry.

chinkan commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

TL ACCEPT on tip 86e227f (supersedes 8d25c6a / 4f6be68).

Locked: legacy recurring → UTC; legacy naive one-shot → system Local (not UTC); absolute one-shot timezone display-only; post-migration parse_one_shot_target uses row timezone. Product GO follows this tip. Waiting QA GO + CI before merge. No tag.

@chinkan
chinkan merged commit d98f255 into main Oct 6, 2026
13 of 14 checks passed
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.

1 participant