Coverage for /home/airflow/.local/lib/python3.12/site-packages/airflow/api_fastapi/core_api/services/ui/dag_run.py: 26%
17 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 14:22 +0000
« 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.
18from __future__ import annotations
20import statistics
21from collections import Counter
23from airflow.api_fastapi.core_api.datamodels.ui.dag_runs import DurationStats
26def compute_duration_stats(durations: list[float]) -> DurationStats | None:
27 """
28 Compute duration statistics from a list of completed DAG run durations (in seconds).
30 Returns None when the list is empty (no completed runs exist yet).
31 Mode is computed on second-rounded values to avoid float precision noise; returns None
32 when every run has a unique duration.
33 Percentiles use linear interpolation between adjacent sorted values.
34 """
35 if not durations:
36 return None
38 # On Postgres 14+, DagRun.duration comes back as Decimal (EXTRACT(epoch ...) returns
39 # numeric); coerce to float so the percentile interpolation below (Decimal * float) works.
40 sorted_d = sorted(float(d) for d in durations)
42 counts = Counter(round(d) for d in sorted_d)
43 max_count = max(counts.values())
44 mode_val: float | None = (
45 float(min(k for k, v in counts.items() if v == max_count)) if max_count > 1 else None
46 )
48 def _percentile(p: float) -> float:
49 idx = (len(sorted_d) - 1) * p / 100
50 lo = int(idx)
51 hi = min(lo + 1, len(sorted_d) - 1)
52 return sorted_d[lo] + (sorted_d[hi] - sorted_d[lo]) * (idx - lo)
54 return DurationStats(
55 mean=round(statistics.mean(sorted_d), 3),
56 mode=round(mode_val, 3) if mode_val is not None else None,
57 p50=round(_percentile(50), 3),
58 p90=round(_percentile(90), 3),
59 p95=round(_percentile(95), 3),
60 p99=round(_percentile(99), 3),
61 )