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

pierrejeambrun 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 8a5212c855e Fix Dag run filtering for null start dates (#71654)
8a5212c855e is described below

commit 8a5212c855e8a47468ab62ab82a4d1d2acac1fa1
Author: Vincent Hsiao <[email protected]>
AuthorDate: Wed Oct 21 00:52:38 2026 +0800

    Fix Dag run filtering for null start dates (#71654)
    
    * Fix Dag run filtering for null start dates
    
    * Cover Dag list start date filtering with null latest runs
    
    * Preserve queued Dag filtering semantics
    
    * Add unit test for nullable datetime lower bound clause
    
    * Cover nullable datetime strict lower bounds
    
    * Use UTC in nullable datetime filter tests
---
 .../airflow/api_fastapi/common/parameters/range.py | 39 +++++++++++++++++-----
 .../api_fastapi/core_api/routes/public/dag_run.py  |  9 ++++-
 .../unit/api_fastapi/common/test_parameters.py     | 19 +++++++++++
 .../core_api/routes/public/test_dag_run.py         | 17 ++++++++++
 4 files changed, 74 insertions(+), 10 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/common/parameters/range.py 
b/airflow-core/src/airflow/api_fastapi/common/parameters/range.py
index 9dc587088c6..992e174de52 100644
--- a/airflow-core/src/airflow/api_fastapi/common/parameters/range.py
+++ b/airflow-core/src/airflow/api_fastapi/common/parameters/range.py
@@ -39,7 +39,7 @@ from airflow.typing_compat import Self
 
 if TYPE_CHECKING:
     from sqlalchemy.orm.attributes import InstrumentedAttribute
-    from sqlalchemy.sql import Select
+    from sqlalchemy.sql import ColumnElement, Select
 
 
 def _safe_parse_datetime(date_to_check: str) -> datetime:
@@ -136,12 +136,27 @@ class NullableDatetimeRangeFilter(RangeFilter):
     ``OR`` predicates so each branch can be satisfied by an independent index 
scan.
 
     NULL semantics: ``start_date=NULL`` means the task has not started yet; 
``end_date=NULL`` means
-    the task is still running. For lower bounds the NULL branch passes 
unconditionally — a not-yet-
-    started/ended task will eventually satisfy any past lower bound. For upper 
bounds the NULL branch
-    is ``col IS NULL AND now() <= x``, preserving the COALESCE(col, now()) 
semantics without the
-    function-wrap index penalty.
+    the task is still running. For lower bounds the NULL branch usually passes 
unconditionally, but
+    callers may narrow it with an extra clause when NULL cannot still evolve 
into an in-range value.
+    For upper bounds the NULL branch is ``col IS NULL AND now() <= x``, 
preserving the
+    COALESCE(col, now()) semantics without the function-wrap index penalty.
     """
 
+    def __init__(
+        self,
+        value: Range | None,
+        attribute: InstrumentedAttribute,
+        null_lower_bound_clause: ColumnElement[bool] | None = None,
+    ) -> None:
+        super().__init__(value, attribute)
+        self.null_lower_bound_clause = null_lower_bound_clause
+
+    def _get_null_lower_bound_clause(self) -> ColumnElement[bool]:
+        null_clause: ColumnElement[bool] = self.attribute.is_(None)
+        if self.null_lower_bound_clause is not None:
+            null_clause = and_(null_clause, self.null_lower_bound_clause)
+        return null_clause
+
     def to_orm(self, select: Select) -> Select:
         if self.skip_none is False:
             raise ValueError(f"Cannot set 'skip_none' to False on a 
{type(self)}")
@@ -151,10 +166,10 @@ class NullableDatetimeRangeFilter(RangeFilter):
 
         if self.value.lower_bound_gte:
             x = self.value.lower_bound_gte
-            select = select.where(or_(self.attribute >= x, 
self.attribute.is_(None)))
+            select = select.where(or_(self.attribute >= x, 
self._get_null_lower_bound_clause()))
         if self.value.lower_bound_gt:
             x = self.value.lower_bound_gt
-            select = select.where(or_(self.attribute > x, 
self.attribute.is_(None)))
+            select = select.where(or_(self.attribute > x, 
self._get_null_lower_bound_clause()))
         if self.value.upper_bound_lte:
             x = self.value.upper_bound_lte
             select = select.where(or_(self.attribute <= x, 
and_(self.attribute.is_(None), func.now() <= x)))
@@ -166,7 +181,11 @@ class NullableDatetimeRangeFilter(RangeFilter):
 
 
 def datetime_range_filter_factory(
-    filter_name: str, model: Base, attribute_name: str | None = None
+    filter_name: str,
+    model: Base,
+    attribute_name: str | None = None,
+    *,
+    null_lower_bound_clause: ColumnElement[bool] | None = None,
 ) -> Callable[[datetime | None, datetime | None, datetime | None, datetime | 
None], RangeFilter]:
     def depends_datetime(
         lower_bound_gte: datetime | None = Query(alias=f"{filter_name}_gte", 
default=None),
@@ -182,7 +201,9 @@ def datetime_range_filter_factory(
             upper_bound_lt=upper_bound_lt,
         )
         if filter_name in ("start_date", "end_date"):
-            return NullableDatetimeRangeFilter(range_val, attr)
+            return NullableDatetimeRangeFilter(
+                range_val, attr, 
null_lower_bound_clause=null_lower_bound_clause
+            )
         return RangeFilter(range_val, attr)
 
     return depends_datetime
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 68fd621cd3a..bbe47fbde6c 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
@@ -518,7 +518,14 @@ def get_dag_runs(
     offset: QueryOffset,
     run_after: Annotated[RangeFilter, 
Depends(datetime_range_filter_factory("run_after", DagRun))],
     logical_date: Annotated[RangeFilter, 
Depends(datetime_range_filter_factory("logical_date", DagRun))],
-    start_date_range: Annotated[RangeFilter, 
Depends(datetime_range_filter_factory("start_date", DagRun))],
+    start_date_range: Annotated[
+        RangeFilter,
+        Depends(
+            datetime_range_filter_factory(
+                "start_date", DagRun, 
null_lower_bound_clause=DagRun.end_date.is_(None)
+            )
+        ),
+    ],
     end_date_range: Annotated[RangeFilter, 
Depends(datetime_range_filter_factory("end_date", DagRun))],
     duration_range: Annotated[RangeFilter, 
Depends(float_range_filter_factory("duration", DagRun))],
     update_at_range: Annotated[RangeFilter, 
Depends(datetime_range_filter_factory("updated_at", DagRun))],
diff --git a/airflow-core/tests/unit/api_fastapi/common/test_parameters.py 
b/airflow-core/tests/unit/api_fastapi/common/test_parameters.py
index 3640f7646ea..641183a1f32 100644
--- a/airflow-core/tests/unit/api_fastapi/common/test_parameters.py
+++ b/airflow-core/tests/unit/api_fastapi/common/test_parameters.py
@@ -630,6 +630,25 @@ class TestDatetimeRangeFilterFactory:
         assert "now()" not in sql
         assert "coalesce" not in sql
 
+    @pytest.mark.parametrize("lower_bound_name", ["lower_bound_gte", 
"lower_bound_gt"])
+    def test_null_lower_bound_clause_is_added_to_null_branch(self, 
lower_bound_name):
+        """Custom NULL lower-bound clause is ANDed with the attribute IS NULL 
check."""
+        bound = datetime(2026, 5, 3, 12, 0, 0, tzinfo=UTC)
+        depends = datetime_range_filter_factory(
+            "start_date",
+            DagRun,
+            null_lower_bound_clause=DagRun.end_date.is_(None),
+        )
+        bounds = dict(lower_bound_gte=None, lower_bound_gt=None, 
upper_bound_lte=None, upper_bound_lt=None)
+        bounds[lower_bound_name] = bound
+        rf = depends(**bounds)
+
+        sql = _compile(rf.to_orm(select(DagRun)))
+
+        assert "start_date is null" in sql
+        assert "end_date is null" in sql
+        assert "start_date is null and" in sql
+
     def test_upper_bound_includes_now_for_running_tasks(self):
         """NULL branch on upper bounds uses now() to proxy the in-progress 
task's current time."""
         bound = datetime(2026, 5, 3, 12, 0, 0, tzinfo=UTC)
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 0fec3f0fe15..c0e3bf9ede8 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
@@ -834,6 +834,23 @@ class TestGetDagRuns:
         assert len(collected) == len(set(collected)), "cursor pages overlapped"
         assert set(collected) == full_ids, "cursor pagination dropped rows 
across the NULL boundary"
 
+    @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+    def 
test_start_date_lower_bound_excludes_finished_runs_with_null_start_date(self, 
test_client, session):
+        run = session.scalar(select(DagRun).where(DagRun.run_id == 
DAG1_RUN2_ID))
+        run.start_date = None
+        run.end_date = START_DATE1 + timedelta(minutes=5)
+        session.commit()
+
+        response = test_client.get(
+            "/dags/~/dagRuns",
+            params={"start_date_gte": START_DATE2.isoformat(), "order_by": 
"dag_run_id"},
+        )
+
+        assert response.status_code == 200, response.json()
+        body = response.json()
+        assert body["total_entries"] == 2
+        assert [run["dag_run_id"] for run in body["dag_runs"]] == 
[DAG2_RUN1_ID, DAG2_RUN2_ID]
+
     @pytest.mark.parametrize(
         ("dag_id", "query_params", "expected_dag_id_list"),
         [

Reply via email to