Coverage for /home/airflow/.local/lib/python3.12/site-packages/airflow/api_fastapi/core_api/services/ui/calendar.py: 15%

140 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 14:22 +0000

1# Licensed to the Apache Software Foundation (ASF) under one 

2# or more contributor license agreements. See the NOTICE file 

3# distributed with this work for additional information 

4# regarding copyright ownership. The ASF licenses this file 

5# to you under the Apache License, Version 2.0 (the 

6# "License"); you may not use this file except in compliance 

7# with the License. You may obtain a copy of the License at 

8# 

9# http://www.apache.org/licenses/LICENSE-2.0 

10# 

11# Unless required by applicable law or agreed to in writing, 

12# software distributed under the License is distributed on an 

13# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 

14# KIND, either express or implied. See the License for the 

15# specific language governing permissions and limitations 

16# under the License. 

17from __future__ import annotations 

18 

19import collections 

20from collections.abc import Sequence 

21from datetime import datetime 

22from typing import Literal, cast 

23 

24import sqlalchemy as sa 

25import structlog 

26from sqlalchemy.engine import Row 

27from sqlalchemy.orm import InstrumentedAttribute, Session 

28 

29from airflow._shared.timezones import timezone 

30from airflow.api_fastapi.common.parameters import RangeFilter 

31from airflow.api_fastapi.core_api.datamodels.ui.calendar import ( 

32 CalendarTimeRangeCollectionResponse, 

33 CalendarTimeRangeResponse, 

34) 

35from airflow.models.dagrun import DagRun 

36from airflow.serialization.definitions.dag import SerializedDAG 

37from airflow.timetables._cron import CronMixin 

38from airflow.timetables.base import DagRunInfo, DataInterval, TimeRestriction 

39from airflow.utils.sqlalchemy import get_dialect_name 

40 

41log = structlog.get_logger(logger_name=__name__) 

42 

43 

44class CalendarService: 

45 """Service class for calendar-related operations.""" 

46 

47 MAX_PLANNED_RUNS: int = 2000 

48 

49 def get_calendar_data( 

50 self, 

51 dag_id: str, 

52 session: Session, 

53 dag: SerializedDAG, 

54 logical_date: RangeFilter, 

55 partition_date: RangeFilter, 

56 granularity: Literal["hourly", "daily"] = "daily", 

57 ) -> CalendarTimeRangeCollectionResponse: 

58 """ 

59 Get calendar data for a Dag including historical and planned runs. 

60 

61 Args: 

62 dag_id: The Dag ID 

63 session: Database session 

64 dag: The Dag object 

65 logical_date: Date range filter for logical_date 

66 partition_date: Date range filter for partition_date 

67 granularity: Time granularity ("hourly" or "daily") 

68 

69 Returns: 

70 List of calendar time range results 

71 """ 

72 date_filter = partition_date if partition_date.is_active() else logical_date 

73 historical_data, raw_dag_states = self._get_historical_dag_runs( 

74 dag_id, 

75 session, 

76 date_filter, 

77 granularity, 

78 ) 

79 

80 planned_data = self._get_planned_dag_runs(dag, raw_dag_states, date_filter, granularity) 

81 

82 all_data = historical_data + planned_data 

83 return CalendarTimeRangeCollectionResponse( 

84 total_entries=len(all_data), 

85 dag_runs=all_data, 

86 ) 

87 

88 def _get_historical_dag_runs( 

89 self, 

90 dag_id: str, 

91 session: Session, 

92 date_filter: RangeFilter, 

93 granularity: Literal["hourly", "daily"], 

94 ) -> tuple[list[CalendarTimeRangeResponse], Sequence[Row]]: 

95 """Get historical Dag runs from the database.""" 

96 dialect = get_dialect_name(session) 

97 

98 effective_date = sa.func.coalesce(DagRun.partition_date, DagRun.logical_date) 

99 time_expression = self._get_time_truncation_expression(effective_date, granularity, dialect) 

100 

101 select_stmt = ( 

102 sa.select( 

103 time_expression.label("datetime"), 

104 DagRun.state, 

105 sa.func.max(DagRun.data_interval_start).label("data_interval_start"), 

106 sa.func.max(DagRun.data_interval_end).label("data_interval_end"), 

107 sa.func.max(DagRun.run_after).label("run_after"), 

108 sa.func.max(DagRun.partition_date).label("partition_date"), 

109 sa.func.count("*").label("count"), 

110 ) 

111 .where(DagRun.dag_id == dag_id) 

112 .group_by(time_expression, DagRun.state) 

113 .order_by(time_expression.asc()) 

114 ) 

115 

116 select_stmt = date_filter.to_orm(select_stmt) 

117 dag_states = session.execute(select_stmt).all() 

118 

119 calendar_results = [ 

120 CalendarTimeRangeResponse( 

121 # ds.datetime in sqlite and mysql is a string, in postgresql it is a datetime 

122 date=ds.datetime, 

123 state=ds.state, 

124 count=int(ds._mapping["count"]), 

125 ) 

126 for ds in dag_states 

127 ] 

128 

129 return calendar_results, dag_states 

130 

131 def _get_planned_dag_runs( 

132 self, 

133 dag: SerializedDAG, 

134 raw_dag_states: Sequence[Row], 

135 date_filter: RangeFilter, 

136 granularity: Literal["hourly", "daily"], 

137 ) -> list[CalendarTimeRangeResponse]: 

138 """Get planned Dag runs based on the Dag's timetable.""" 

139 if not self._should_calculate_planned_runs(dag, raw_dag_states): 

140 return [] 

141 

142 last_state = raw_dag_states[-1] 

143 

144 if dag.timetable.partitioned: 

145 last_run_after = timezone.coerce_datetime(last_state.run_after) 

146 last_partition_date = timezone.coerce_datetime(last_state.partition_date) 

147 if not last_run_after or not last_partition_date: 

148 return [] 

149 year = last_partition_date.year 

150 last_info = DagRunInfo( 

151 run_after=last_run_after, 

152 partition_date=last_partition_date, 

153 partition_key=None, 

154 data_interval=None, 

155 ) 

156 else: 

157 last_data_interval = self._get_last_data_interval(raw_dag_states) 

158 if not last_data_interval: 

159 return [] 

160 year = last_data_interval.end.year 

161 if isinstance(dag.timetable, CronMixin): 

162 return self._calculate_cron_planned_runs( 

163 dag, last_data_interval, year, date_filter, granularity 

164 ) 

165 last_info = DagRunInfo( 

166 run_after=last_data_interval.end, 

167 data_interval=last_data_interval, 

168 partition_date=None, 

169 partition_key=None, 

170 ) 

171 

172 restriction = TimeRestriction( 

173 timezone.coerce_datetime(dag.start_date) if dag.start_date else None, 

174 timezone.coerce_datetime(dag.end_date) if dag.end_date else None, 

175 False, 

176 ) 

177 

178 return self._calculate_timetable_planned_runs( 

179 dag, last_info, year, restriction, date_filter, granularity 

180 ) 

181 

182 def _should_calculate_planned_runs(self, dag: SerializedDAG, raw_dag_states: Sequence[Row]) -> bool: 

183 """Check if we should calculate planned runs.""" 

184 if not raw_dag_states or not dag.timetable.periodic: 

185 return False 

186 last = raw_dag_states[-1] 

187 return bool(last.data_interval_start and last.data_interval_end) or bool(last.partition_date) 

188 

189 def _get_last_data_interval(self, raw_dag_states: Sequence[Row]) -> DataInterval | None: 

190 """Extract the last data interval from raw database results.""" 

191 if not raw_dag_states: 

192 return None 

193 

194 last_state = raw_dag_states[-1] 

195 if not (last_state.data_interval_start and last_state.data_interval_end): 

196 return None 

197 

198 return DataInterval( 

199 timezone.coerce_datetime(last_state.data_interval_start), 

200 timezone.coerce_datetime(last_state.data_interval_end), 

201 ) 

202 

203 def _calculate_cron_planned_runs( 

204 self, 

205 dag: SerializedDAG, 

206 last_data_interval: DataInterval, 

207 year: int, 

208 date_filter: RangeFilter, 

209 granularity: Literal["hourly", "daily"], 

210 ) -> list[CalendarTimeRangeResponse]: 

211 """Calculate planned runs for cron-based timetables.""" 

212 dates: dict[datetime, int] = collections.Counter() 

213 

214 cron_timetable = cast("CronMixin", dag.timetable) 

215 dt = last_data_interval.end 

216 

217 # Step with CronMixin._get_next so planned instants match the scheduler exactly, 

218 # including its DST gap/fold handling. Cap the iteration like 

219 # _calculate_timetable_planned_runs does; a high-frequency expression (e.g. 

220 # "* * * * *", or a seconds-resolution cron) would otherwise take hundreds of 

221 # thousands of steps before hitting the year boundary. 

222 for _ in range(self.MAX_PLANNED_RUNS): 

223 dt = cron_timetable._get_next(dt) 

224 if dt.year != year: 

225 break 

226 if dag.end_date and dt > dag.end_date: 

227 break 

228 if not self._is_date_in_range(dt, date_filter): 

229 continue 

230 

231 dates[self._truncate_datetime_for_granularity(dt, granularity)] += 1 

232 

233 return [ 

234 CalendarTimeRangeResponse(date=dt, state="planned", count=count) for dt, count in dates.items() 

235 ] 

236 

237 def _calculate_timetable_planned_runs( 

238 self, 

239 dag: SerializedDAG, 

240 last_info: DagRunInfo, 

241 year: int, 

242 restriction: TimeRestriction, 

243 date_filter: RangeFilter, 

244 granularity: Literal["hourly", "daily"], 

245 ) -> list[CalendarTimeRangeResponse]: 

246 """Calculate planned runs for generic timetables.""" 

247 dates: dict[datetime, int] = collections.Counter() 

248 prev_run_after = last_info.run_after 

249 total_planned = 0 

250 

251 while total_planned < self.MAX_PLANNED_RUNS: 

252 curr_info = dag.timetable.next_dagrun_info_v2( 

253 last_dagrun_info=last_info, 

254 restriction=restriction, 

255 ) 

256 

257 if curr_info is None: 

258 break 

259 if curr_info.run_after <= prev_run_after: 

260 break 

261 

262 effective_date = curr_info.partition_date or curr_info.logical_date 

263 if not effective_date: 

264 break 

265 if effective_date.year != year: 

266 break 

267 

268 if not self._is_date_in_range(effective_date, date_filter): 

269 last_info = curr_info 

270 prev_run_after = curr_info.run_after 

271 total_planned += 1 

272 continue 

273 

274 dt = self._truncate_datetime_for_granularity(effective_date, granularity) 

275 dates[dt] += 1 

276 last_info = curr_info 

277 prev_run_after = curr_info.run_after 

278 total_planned += 1 

279 

280 return [ 

281 CalendarTimeRangeResponse(date=dt, state="planned", count=count) for dt, count in dates.items() 

282 ] 

283 

284 def _get_time_truncation_expression( 

285 self, 

286 column: InstrumentedAttribute[datetime | None] | sa.sql.elements.ColumnElement, 

287 granularity: Literal["hourly", "daily"], 

288 dialect: str | None, 

289 ) -> sa.sql.elements.ColumnElement: 

290 """ 

291 Get database-specific time truncation expression for SQLAlchemy. 

292 

293 We want to return always timestamp for both hourly and daily truncation. 

294 Unfortunately different databases have different functions for truncating datetime, so we need to handle 

295 them separately. 

296 

297 Args: 

298 column: The datetime column to truncate 

299 granularity: Either "hourly" or "daily" 

300 dialect: Database dialect ("postgresql", "mysql", "sqlite") 

301 

302 Returns: 

303 SQLAlchemy expression for time truncation 

304 

305 Raises: 

306 ValueError: If the dialect is not supported 

307 """ 

308 if granularity == "hourly": 

309 if dialect == "postgresql": 

310 expression = sa.func.date_trunc("hour", column) 

311 elif dialect == "mysql": 

312 expression = sa.func.date_format(column, "%Y-%m-%dT%H:00:00Z") 

313 elif dialect == "sqlite": 

314 expression = sa.func.strftime("%Y-%m-%dT%H:00:00Z", column) 

315 else: 

316 raise ValueError(f"Unsupported dialect: {dialect}") 

317 else: 

318 if dialect == "postgresql": 

319 expression = sa.func.timezone("UTC", sa.func.cast(sa.func.cast(column, sa.Date), sa.DateTime)) 

320 elif dialect == "mysql": 

321 expression = sa.func.date_format(column, "%Y-%m-%dT%00:00:00Z") 

322 elif dialect == "sqlite": 

323 expression = sa.func.strftime("%Y-%m-%dT00:00:00Z", column) 

324 else: 

325 raise ValueError(f"Unsupported dialect: {dialect}") 

326 return expression 

327 

328 def _truncate_datetime_for_granularity( 

329 self, 

330 dt: datetime, 

331 granularity: Literal["hourly", "daily"], 

332 ) -> datetime: 

333 """ 

334 Truncate datetime based on granularity for planned tasks grouping. 

335 

336 Args: 

337 dt: The datetime to truncate 

338 granularity: Either "hourly" or "daily" 

339 

340 Returns: 

341 Truncated datetime 

342 """ 

343 if granularity == "hourly": 

344 return dt.replace(minute=0, second=0, microsecond=0) 

345 return dt.replace(hour=0, minute=0, second=0, microsecond=0) 

346 

347 def _is_date_in_range(self, dt: datetime, date_filter: RangeFilter) -> bool: 

348 """Check if a date is within the specified range filter.""" 

349 if not date_filter.value: 

350 return True 

351 

352 if date_filter.value.lower_bound_gte and dt < date_filter.value.lower_bound_gte: 

353 return False 

354 if date_filter.value.lower_bound_gt and dt <= date_filter.value.lower_bound_gt: 

355 return False 

356 if date_filter.value.upper_bound_lte and dt > date_filter.value.upper_bound_lte: 

357 return False 

358 if date_filter.value.upper_bound_lt and dt >= date_filter.value.upper_bound_lt: 

359 return False 

360 

361 return True