This is an automated email from the ASF dual-hosted git repository.

jscheffl pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new d33e887b717 Make duration in 'List Dag Run' page sortable (#51495)
d33e887b717 is described below

commit d33e887b717890c4a91f7d89d4530e0ad4d76a10
Author: Valentyn <[email protected]>
AuthorDate: Sun Jun 8 12:14:41 2025 +0300

    Make duration in 'List Dag Run' page sortable (#51495)
    
    Co-authored-by: Valentyn Druzhynin <[email protected]>
---
 .../api_fastapi/core_api/datamodels/dag_run.py     |  1 +
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |  6 +++++
 .../core_api/openapi/v2-rest-api-generated.yaml    |  6 +++++
 .../api_fastapi/core_api/routes/public/dag_run.py  |  1 +
 airflow-core/src/airflow/models/dagrun.py          | 28 +++++++++++++++++++++-
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 12 ++++++++++
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  1 +
 airflow-core/src/airflow/ui/src/pages/DagRuns.tsx  |  6 ++---
 .../src/airflow/ui/src/utils/datetimeUtils.ts      | 27 ++++++++++++++-------
 airflow-core/src/airflow/ui/src/utils/index.ts     |  2 +-
 .../core_api/routes/public/test_assets.py          |  1 +
 .../core_api/routes/public/test_dag_run.py         |  5 ++++
 .../tests/unit/cli/commands/test_asset_command.py  |  1 +
 .../src/airflowctl/api/datamodels/generated.py     |  1 +
 .../tests/unit/openlineage/utils/test_utils.py     |  3 +++
 15 files changed, 87 insertions(+), 14 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
index 367547a8d8e..dd04f493c97 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_run.py
@@ -66,6 +66,7 @@ class DAGRunResponse(BaseModel):
     queued_at: datetime | None
     start_date: datetime | None
     end_date: datetime | None
+    duration: float | None
     data_interval_start: datetime | None
     data_interval_end: datetime | None
     run_after: datetime
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 8b9c7811b14..920263e8f20 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -899,6 +899,11 @@ components:
             format: date-time
           - type: 'null'
           title: End Date
+        duration:
+          anyOf:
+          - type: number
+          - type: 'null'
+          title: Duration
         data_interval_start:
           anyOf:
           - type: string
@@ -961,6 +966,7 @@ components:
       - queued_at
       - start_date
       - end_date
+      - duration
       - data_interval_start
       - data_interval_end
       - run_after
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 0901c620b29..582807ad74b 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -8610,6 +8610,11 @@ components:
             format: date-time
           - type: 'null'
           title: End Date
+        duration:
+          anyOf:
+          - type: number
+          - type: 'null'
+          title: Duration
         data_interval_start:
           anyOf:
           - type: string
@@ -8672,6 +8677,7 @@ components:
       - queued_at
       - start_date
       - end_date
+      - duration
       - data_interval_start
       - data_interval_end
       - run_after
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
index 44370b37241..915c83d73bb 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
@@ -324,6 +324,7 @@ def get_dag_runs(
                     "end_date",
                     "updated_at",
                     "conf",
+                    "duration",
                 ],
                 DagRun,
                 {"dag_run_id": "run_id"},
diff --git a/airflow-core/src/airflow/models/dagrun.py 
b/airflow-core/src/airflow/models/dagrun.py
index cd5ec56a839..8dcd00fef0c 100644
--- a/airflow-core/src/airflow/models/dagrun.py
+++ b/airflow-core/src/airflow/models/dagrun.py
@@ -45,6 +45,7 @@ from sqlalchemy import (
     Text,
     UniqueConstraint,
     and_,
+    case,
     func,
     not_,
     or_,
@@ -54,9 +55,11 @@ from sqlalchemy import (
 from sqlalchemy.dialects import postgresql
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.associationproxy import association_proxy
+from sqlalchemy.ext.hybrid import hybrid_property
 from sqlalchemy.ext.mutable import MutableDict
 from sqlalchemy.orm import declared_attr, joinedload, relationship, synonym, 
validates
-from sqlalchemy.sql.expression import case, false, select
+from sqlalchemy.sql.elements import Case
+from sqlalchemy.sql.expression import false, select
 from sqlalchemy.sql.functions import coalesce
 from sqlalchemy_utils import UUIDType
 
@@ -95,6 +98,7 @@ if TYPE_CHECKING:
     from opentelemetry.sdk.trace import Span
     from pydantic import NonNegativeInt
     from sqlalchemy.orm import Query, Session
+    from sqlalchemy.sql.elements import Case
 
     from airflow.models.baseoperator import BaseOperator
     from airflow.models.dag import DAG
@@ -374,6 +378,28 @@ class DagRun(Base, LoggingMixin):
             return dag_versions[-1].version_number
         return None
 
+    @hybrid_property
+    def duration(self) -> float | None:
+        if self.end_date and self.start_date:
+            return (self.end_date - self.start_date).total_seconds()
+        return None
+
+    @duration.expression  # type: ignore[no-redef]
+    @provide_session
+    def duration(cls, session: Session = NEW_SESSION) -> Case:
+        dialect_name = session.bind.dialect.name
+        if dialect_name == "mysql":
+            return func.timestampdiff(text("SECOND"), cls.start_date, 
cls.end_date)
+        return case(
+            [
+                (
+                    (cls.end_date != None) & (cls.start_date != None),  # 
noqa: E711
+                    func.extract("epoch", cls.end_date - cls.start_date),
+                )
+            ],
+            else_=None,
+        )
+
     @provide_session
     def check_version_id_exists_in_dr(self, dag_version_id: UUIDType, session: 
Session = NEW_SESSION):
         select_stmt = (
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 7ffda75f426..56bb0fe4f1b 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -2402,6 +2402,17 @@ export const $DAGRunResponse = {
       ],
       title: "End Date",
     },
+    duration: {
+      anyOf: [
+        {
+          type: "number",
+        },
+        {
+          type: "null",
+        },
+      ],
+      title: "Duration",
+    },
     data_interval_start: {
       anyOf: [
         {
@@ -2513,6 +2524,7 @@ export const $DAGRunResponse = {
     "queued_at",
     "start_date",
     "end_date",
+    "duration",
     "data_interval_start",
     "data_interval_end",
     "run_after",
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 02a9746bf46..79a67a37f8d 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -626,6 +626,7 @@ export type DAGRunResponse = {
   queued_at: string | null;
   start_date: string | null;
   end_date: string | null;
+  duration: number | null;
   data_interval_start: string | null;
   data_interval_end: string | null;
   run_after: string;
diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagRuns.tsx
index e304537e4fa..ffea368655e 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns.tsx
@@ -42,7 +42,7 @@ import { Select } from "src/components/ui";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { dagRunTypeOptions, dagRunStateOptions as stateOptions } from 
"src/constants/stateOptions";
 import DeleteRunButton from "src/pages/DeleteRunButton";
-import { getDuration, useAutoRefresh, isStatePending } from "src/utils";
+import { renderDuration, useAutoRefresh, isStatePending } from "src/utils";
 
 type DagRunRow = { row: { original: DAGRunResponse } };
 const {
@@ -104,7 +104,8 @@ const runColumns = (translate: TFunction, dagId?: string): 
Array<ColumnDef<DAGRu
     header: translate("dags:runs.columns.endDate"),
   },
   {
-    cell: ({ row: { original } }) => getDuration(original.start_date, 
original.end_date),
+    accessorKey: "duration",
+    cell: ({ row: { original } }) => renderDuration(original.duration),
     header: translate("dags:runs.columns.duration"),
   },
   {
@@ -129,7 +130,6 @@ const runColumns = (translate: TFunction, dagId?: string): 
Array<ColumnDef<DAGRu
 
       return undefined;
     },
-    enableSorting: false,
     header: translate("dags:runs.columns.conf"),
   },
   {
diff --git a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts 
b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts
index 19819967da9..4691859d5d3 100644
--- a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts
+++ b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts
@@ -21,18 +21,27 @@ import dayjsDuration from "dayjs/plugin/duration";
 
 dayjs.extend(dayjsDuration);
 
-export const getDuration = (startDate?: string | null, endDate?: string | 
null) => {
-  const seconds = dayjs.duration(dayjs(endDate ?? undefined).diff(startDate ?? 
undefined)).asSeconds();
-
-  if (isNaN(seconds) || seconds <= 0) {
+export const renderDuration = (durationSeconds: number | null | undefined): 
string => {
+  if (
+    durationSeconds === null ||
+    durationSeconds === undefined ||
+    isNaN(durationSeconds) ||
+    durationSeconds <= 0
+  ) {
     return "00:00:00";
   }
 
-  if (seconds < 10) {
-    return `${seconds.toFixed(2)}s`;
+  if (durationSeconds < 10) {
+    return `${durationSeconds.toFixed(2)}s`;
   }
 
-  return seconds < 86_400
-    ? dayjs.duration(seconds, "seconds").format("HH:mm:ss")
-    : dayjs.duration(seconds, "seconds").format("D[d]HH:mm:ss");
+  return durationSeconds < 86_400
+    ? dayjs.duration(durationSeconds, "seconds").format("HH:mm:ss")
+    : dayjs.duration(durationSeconds, "seconds").format("D[d]HH:mm:ss");
+};
+
+export const getDuration = (startDate?: string | null, endDate?: string | 
null) => {
+  const seconds = dayjs.duration(dayjs(endDate ?? undefined).diff(startDate ?? 
undefined)).asSeconds();
+
+  return renderDuration(seconds);
 };
diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts 
b/airflow-core/src/airflow/ui/src/utils/index.ts
index 93f604e57e1..49924fabcad 100644
--- a/airflow-core/src/airflow/ui/src/utils/index.ts
+++ b/airflow-core/src/airflow/ui/src/utils/index.ts
@@ -19,7 +19,7 @@
 
 export { capitalize } from "./capitalize";
 export { pluralize } from "./pluralize";
-export { getDuration } from "./datetimeUtils";
+export { getDuration, renderDuration } from "./datetimeUtils";
 export { getMetaKey } from "./getMetaKey";
 export { useContainerWidth } from "./useContainerWidth";
 export * from "./query";
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
index 2ef61853130..800a7efccfb 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
@@ -1196,6 +1196,7 @@ class TestPostAssetMaterialize(TestAssets):
             "run_after": mock.ANY,
             "start_date": None,
             "end_date": None,
+            "duration": None,
             "data_interval_start": None,
             "data_interval_end": None,
             "last_scheduling_decision": None,
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
index 3c4d16072bc..baa7e503c83 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
@@ -177,6 +177,7 @@ def get_dag_run_dict(run: DagRun):
         "run_after": from_datetime_to_zulu_without_ms(run.run_after),
         "start_date": from_datetime_to_zulu_without_ms(run.start_date),
         "end_date": from_datetime_to_zulu(run.end_date),
+        "duration": run.duration,
         "data_interval_start": 
from_datetime_to_zulu_without_ms(run.data_interval_start),
         "data_interval_end": 
from_datetime_to_zulu_without_ms(run.data_interval_end),
         "last_scheduling_decision": (
@@ -308,6 +309,7 @@ class TestGetDagRuns:
             pytest.param("end_date", [DAG1_RUN1_ID, DAG1_RUN2_ID], 
id="order_by_end_date"),
             pytest.param("updated_at", [DAG1_RUN1_ID, DAG1_RUN2_ID], 
id="order_by_updated_at"),
             pytest.param("conf", [DAG1_RUN1_ID, DAG1_RUN2_ID], 
id="order_by_conf"),
+            pytest.param("duration", [DAG1_RUN1_ID, DAG1_RUN2_ID], 
id="order_by_duration"),
         ],
     )
     @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
@@ -1316,6 +1318,7 @@ class TestTriggerDagRun:
             "logical_date": expected_logical_date,
             "run_after": fixed_now.replace("+00:00", "Z"),
             "start_date": None,
+            "duration": None,
             "state": "queued",
             "data_interval_end": expected_data_interval_end,
             "data_interval_start": expected_data_interval_start,
@@ -1506,6 +1509,7 @@ class TestTriggerDagRun:
             "queued_at": now,
             "start_date": None,
             "end_date": None,
+            "duration": None,
             "run_after": now,
             "data_interval_start": now,
             "data_interval_end": now,
@@ -1593,6 +1597,7 @@ class TestTriggerDagRun:
             "run_after": mock.ANY,
             "start_date": None,
             "end_date": None,
+            "duration": None,
             "data_interval_start": mock.ANY,
             "data_interval_end": mock.ANY,
             "last_scheduling_decision": None,
diff --git a/airflow-core/tests/unit/cli/commands/test_asset_command.py 
b/airflow-core/tests/unit/cli/commands/test_asset_command.py
index c03b3bd9452..e5f85856945 100644
--- a/airflow-core/tests/unit/cli/commands/test_asset_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_asset_command.py
@@ -146,6 +146,7 @@ def test_cli_assets_materialize(parser: ArgumentParser) -> 
None:
         "dag_display_name": "asset1_producer",
         "dag_id": "asset1_producer",
         "end_date": None,
+        "duration": None,
         "last_scheduling_decision": None,
         "note": None,
         "run_type": "manual",
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py 
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 63056402f6a..330b2a91f72 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -1314,6 +1314,7 @@ class DAGRunResponse(BaseModel):
     queued_at: Annotated[datetime | None, Field(title="Queued At")] = None
     start_date: Annotated[datetime | None, Field(title="Start Date")] = None
     end_date: Annotated[datetime | None, Field(title="End Date")] = None
+    duration: Annotated[float | None, Field(title="Duration")] = None
     data_interval_start: Annotated[datetime | None, Field(title="Data Interval 
Start")] = None
     data_interval_end: Annotated[datetime | None, Field(title="Data Interval 
End")] = None
     run_after: Annotated[datetime, Field(title="Run After")]
diff --git a/providers/openlineage/tests/unit/openlineage/utils/test_utils.py 
b/providers/openlineage/tests/unit/openlineage/utils/test_utils.py
index 2ee3560d236..7cce6dbe8d8 100644
--- a/providers/openlineage/tests/unit/openlineage/utils/test_utils.py
+++ b/providers/openlineage/tests/unit/openlineage/utils/test_utils.py
@@ -78,6 +78,7 @@ class CustomOperatorFromEmpty(EmptyOperator):
     pass
 
 
[email protected]_test
 def test_get_airflow_job_facet():
     with DAG(dag_id="dag", schedule=None, start_date=datetime.datetime(2024, 
6, 1)) as dag:
         task_0 = BashOperator(task_id="task_0", bash_command="exit 0;")
@@ -130,6 +131,7 @@ def test_get_airflow_job_facet():
     }
 
 
[email protected]_test
 def test_get_airflow_dag_run_facet():
     with DAG(
         dag_id="dag",
@@ -238,6 +240,7 @@ def test_dag_run_version_no_versions():
 
 
 @pytest.mark.parametrize("key", ["bundle_name", "bundle_version", 
"version_id", "version_number"])
[email protected]_test
 def test_dag_run_version(key):
     dagrun_mock = MagicMock(DagRun)
     dagrun_mock.dag_versions = [

Reply via email to