simon-hoon opened a new issue, #71234:
URL: https://github.com/apache/airflow/issues/71234

   ### Under which category would you file this issue?
   
   Airflow Core
   
   ### Apache Airflow version
   
   3.3.0
   
   ### What happened and how to reproduce it?
   
     For a DAG using a plain cron-string `schedule` (e.g. `"0 8 * * *"`) on an 
Airflow instance whose `[core] default_timezone` is **not UTC** (e.g. 
`Asia/Seoul`, UTC+9), the **Calendar view's "planned" (future, not-yet-run) 
cells are shown at the wrong hour** — offset by the UTC/local difference.
   
     Concretely, with `AIRFLOW__CORE__DEFAULT_TIMEZONE=Asia/Seoul` and a DAG 
scheduled `"0 8 * * *"`:
     - The DAG actually fires at **08:00 KST**, confirmed by `airflow dags 
next-execution` and by the DAG detail page's "Next Run" / "Last Run" fields 
(both correctly show `08:00:00`).
     - Real, already-executed runs are shown correctly on the Calendar at the 
08:00 row.
     - **Future "planned" cells for the same DAG are drawn at the 17:00 row 
instead** (08:00 + 9h) — i.e. exactly a UTC-vs-KST offset.
   
     Historical (already-happened) runs are unaffected because they're read 
straight from `DagRun` rows already computed correctly by the scheduler. Only 
the *projected/planned* rows — computed on the fly by the Calendar API — are 
wrong.
   
     How to reproduce:
   
     Via the UI:
     1. Deploy Airflow with `AIRFLOW__CORE__DEFAULT_TIMEZONE=Asia/Seoul` (any 
non-UTC zone works; easiest to see with a schedule hour close to local 
midnight).
     2. Create a DAG with `schedule="0 8 * * *"` (no explicit timetable 
timezone override — it inherits the core default).
     3. Let it run at least once, or just open the DAG's Calendar tab.
     4. Compare the DAG detail page's "Next Run"/"Last Run" (correctly `08:00`) 
against the Calendar tab's *planned* cells for the same DAG (shown at `17:00`).
   
     Minimal Python repro (no webserver needed) — confirms the bad value is 
computed by the service itself, not introduced or fixed anywhere downstream:
   
     ```python
     from croniter import croniter
     from datetime import datetime
     import pendulum
     from airflow.timetables.trigger import CronTriggerTimetable
   
     tt = CronTriggerTimetable("0 8 * * *", timezone="Asia/Seoul")
     last_end_utc = pendulum.datetime(2026, 8, 5, 23, 0, 0, tz="UTC")  # = 
2026-08-06 08:00 KST
   
     # (A) what CalendarService._calculate_cron_planned_runs does:
     next_planned = next(croniter(tt._expression, start_time=last_end_utc, 
ret_type=datetime))
     print(next_planned, "->", 
next_planned.astimezone(pendulum.timezone("Asia/Seoul")))
     # 2026-08-06 08:00:00+00:00 -> 2026-08-06 17:00:00+09:00   (WRONG)
   
     # (B) what the real scheduler uses (CronMixin._get_next):
     next_real = tt._get_next(last_end_utc)
     print(next_real, "->", next_real.in_timezone("Asia/Seoul"))
     # 2026-08-06 23:00:00+00:00 -> 2026-08-07 08:00:00+09:00   (matches 
`airflow dags next-execution`)
     ```
   
     I also confirmed this end-to-end by calling 
`CalendarService.get_calendar_data()` (the exact function `GET 
/ui/calendar/{dag_id}` calls) against a live instance's real `DagRun` history 
for a `schedule="0 8 * * *"` DAG: the one real, already-executed run came back 
correct (08:00 KST), while every planned/future occurrence came back exactly 9 
hours later. This is the value returned by the API itself — nothing downstream 
corrects it. (Screenshot attached below, from a fresh local demo instance — no 
relation to the code snippets above.)
   
   <img width="1080" height="700" alt="Image" 
src="https://github.com/user-attachments/assets/6f55add7-c21c-43a9-a8e8-01a15abc7e33";
 />
   
   ### What you think should happen instead?
   
   Planned/future run cells in the Calendar view should be computed in the 
DAG's configured timetable timezone, the same way the real scheduler computes 
actual runs, so they land in the same hour/day as the runs that will actually 
execute.
   
   Root cause: `CalendarService._calculate_cron_planned_runs()` in 
`airflow-core/src/airflow/api_fastapi/core_api/services/ui/calendar.py`:
   
   ```python
   def _calculate_cron_planned_runs(
       self, dag, last_data_interval, year, date_filter, granularity,
   ):
       """Calculate planned runs for cron-based timetables."""
       dates: dict[datetime, int] = collections.Counter()
   
       dates_iter: Iterator[datetime | None] = croniter(
           cast("CronMixin", dag.timetable)._expression,
           start_time=last_data_interval.end,
           ret_type=datetime,
       )
       ...
   ```
   
   `last_data_interval.end` is a UTC-aware `datetime`. `croniter`'s documented 
contract (its README: "Be sure to init your croniter instance with a TZ aware 
datetime for this to work!") is that the caller localizes `start_time` to 
whatever timezone the cron fields should be matched against — croniter just 
reads the tzinfo off it (`self.tzinfo = start_time.tzinfo` in `set_current`). 
`_calculate_cron_planned_runs` never does that localization, so `"0 8 * * *"` 
gets matched against UTC wall-clock instead of `dag.timetable`'s configured 
`Asia/Seoul` (→ 08:00 UTC = 17:00 KST). This is croniter behaving exactly as 
documented — the bug is entirely on the caller's side.
   
   Contrast with how the real scheduler computes the next run — 
`CronMixin._get_next()` in `airflow-core/src/airflow/timetables/_cron.py`, 
which does the localize/delocalize correctly:
   
   ```python
   def _get_next(self, current: DateTime) -> DateTime:
       naive = make_naive(current, self._timezone)   # UTC -> local naive 
wall-clock
       ...                                             # croniter matches on 
local wall-clock
       return convert_to_utc(make_aware(scheduled, self._timezone))  # local -> 
UTC
   ```
   
   Notably, the sibling function in the same file, 
`_calculate_timetable_planned_runs()` (used for non-cron/partitioned 
timetables), does this correctly via `dag.timetable.next_dagrun_info_v2(...)`. 
Only the cron-specific fast path bypasses it. Still present, unchanged, on 
`main` (diffed byte-for-byte against the installed 3.3.0 function body).
   
   Related prior art (same class of bug, different surfaces):
   - #67477 (fixed) — a different layer: the React frontend ignored the user's 
selected UI timezone. Since it's fixed, the frontend now faithfully renders 
whatever value the API sends — which is why this backend bug is visible 
end-to-end.
   - #67717 (merged) — the same pattern (comparing UTC-parsed dates directly 
against a non-UTC timetable's dates) for `airflow dags clear`, fixed via a new 
`Timetable.resolve_day_bound()`.
   - #63631 — added the correct, timezone-aware `next_dagrun_info_v2` path for 
non-cron/partitioned timetables in this same `calendar.py` file; the cron fast 
path was left behind.
   
   Suggested fix — either:
   1. Route cron timetables through the same `next_dagrun_info_v2`-based path 
as `_calculate_timetable_planned_runs` (simplest, reuses already-correct logic, 
at some perf cost vs. croniter's fast iteration), or
   2. Keep the croniter fast path, but localize `start_time` into the 
timetable's own timezone before constructing `croniter` (mirroring 
`CronMixin._get_next`), converting results back to UTC afterward. This needs 
read access to the timetable's timezone from `calendar.py` — 
`CronMixin._timezone` is currently private; a small public accessor (in the 
spirit of the `resolve_day_bound()` added in #67717) would do it cleanly.
   
   ### Operating System
   
   Not specific to an OS — reproduced on macOS (local venv) and confirmed 
against a Kubernetes/Helm-chart deployment (Debian-based image).
   
   ### Deployment
   
   Virtualenv installation
   
   ### Apache Airflow Provider(s)
   
   _No response_
   
   ### Versions of Apache Airflow Providers
   
   _No response_
   
   ### Official Helm Chart version
   
   Not Applicable
   
   ### Kubernetes Version
   
   _No response_
   
   ### Helm Chart configuration
   
   _No response_
   
   ### Docker Image customizations
   
   _No response_
   
   ### Anything else?
   
   Deterministic, not intermittent — occurs for any cron-scheduled DAG on a 
non-UTC `default_timezone`, every time the Calendar view computes 
planned/future runs.
   
   ### Are you willing to submit PR?
   
   - [x] Yes I am willing to submit a PR!
   
   ### Code of Conduct
   
   - [x] I agree to follow this project's [Code of 
Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to