From 7da04e96f11e11677d4d4471dff43f30acd55f50 Mon Sep 17 00:00:00 2001 From: Pierre Jeambrun Date: Mon, 3 Aug 2026 07:31:37 +0200 Subject: [PATCH] [v3-3-test] Fix Dag run duration stats crash on PostgreSQL 14+ (#70833) PostgreSQL 14 changed EXTRACT(epoch FROM ...) to return numeric rather than double precision, so DagRun.duration is hydrated as Decimal on Postgres 14+. The duration-stats percentile interpolation then multiplies a Decimal by a float, which Python disallows, so the Dag run stats endpoint raises a TypeError for any Dag that has completed runs. (cherry picked from commit 30006eb9f8c76b42beb349b5c1d859678a641253) Co-authored-by: Pierre Jeambrun --- .../core_api/services/ui/dag_run.py | 4 +- .../core_api/services/ui/test_dag_run.py | 41 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 airflow-core/tests/unit/api_fastapi/core_api/services/ui/test_dag_run.py diff --git a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/dag_run.py b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/dag_run.py index a9ba75417c87b..2a3ab18325239 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/dag_run.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/dag_run.py @@ -35,7 +35,9 @@ def compute_duration_stats(durations: list[float]) -> DurationStats | None: if not durations: return None - sorted_d = sorted(durations) + # On Postgres 14+, DagRun.duration comes back as Decimal (EXTRACT(epoch ...) returns + # numeric); coerce to float so the percentile interpolation below (Decimal * float) works. + sorted_d = sorted(float(d) for d in durations) counts = Counter(round(d) for d in sorted_d) max_count = max(counts.values()) diff --git a/airflow-core/tests/unit/api_fastapi/core_api/services/ui/test_dag_run.py b/airflow-core/tests/unit/api_fastapi/core_api/services/ui/test_dag_run.py new file mode 100644 index 0000000000000..afa9c5277e256 --- /dev/null +++ b/airflow-core/tests/unit/api_fastapi/core_api/services/ui/test_dag_run.py @@ -0,0 +1,41 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from airflow.api_fastapi.core_api.services.ui.dag_run import compute_duration_stats + + +@pytest.mark.parametrize("cast", [float, Decimal], ids=["float", "decimal"]) +def test_compute_duration_stats_handles_float_and_decimal(cast): + """Durations arrive as Decimal on Postgres 14+ (EXTRACT(epoch ...) returns numeric) and as + float/int on other backends; the stats must compute for either without raising TypeError.""" + durations = [cast(v) for v in (10, 20, 20, 30, 40)] + + stats = compute_duration_stats(durations) + + assert stats is not None + assert stats.mean == 24.0 + assert stats.mode == 20.0 + assert stats.p50 == 20.0 + assert stats.p90 == 36.0 + assert stats.p95 == 38.0 + assert stats.p99 == 39.6