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

Lee-W pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new fe615e70f35 [v3-3-test] Fix pending partition run lookups for slash 
keys and duplicate rows (#69700) (#69844)
fe615e70f35 is described below

commit fe615e70f35cc71786a84cab0f1581e23d2615a4
Author: Wei Lee <[email protected]>
AuthorDate: Tue Jul 14 21:57:39 2026 +0800

    [v3-3-test] Fix pending partition run lookups for slash keys and duplicate 
rows (#69700) (#69844)
---
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |   4 +-
 .../core_api/routes/ui/partitioned_dag_runs.py     |  19 ++-
 .../ui/openapi-gen/requests/services.gen.ts        |   6 +-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |   2 +-
 .../routes/ui/test_partitioned_dag_runs.py         | 161 +++++++++++++++++++--
 5 files changed, 174 insertions(+), 18 deletions(-)

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 d786e259b4e..a8adab13d4c 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
@@ -181,7 +181,7 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/HTTPValidationError'
-  /ui/pending_partitioned_dag_run/{dag_id}/{partition_key}:
+  /ui/pending_partitioned_dag_run/{dag_id}:
     get:
       tags:
       - PartitionedDagRun
@@ -199,7 +199,7 @@ paths:
           type: string
           title: Dag Id
       - name: partition_key
-        in: path
+        in: query
         required: true
         schema:
           type: string
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
index e7f949e92cf..96ab8c1c385 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
@@ -342,7 +342,7 @@ def get_partitioned_dag_runs(
 
 
 @partitioned_dag_runs_router.get(
-    "/pending_partitioned_dag_run/{dag_id}/{partition_key}",
+    "/pending_partitioned_dag_run/{dag_id}",
     dependencies=[Depends(requires_access_asset(method="GET")), 
Depends(requires_access_dag(method="GET"))],
 )
 def get_pending_partitioned_dag_run(
@@ -351,6 +351,9 @@ def get_pending_partitioned_dag_run(
     session: SessionDep,
 ) -> PartitionedDagRunDetailResponse:
     """Return full details for pending PartitionedDagRun."""
+    # partition_key is a query param, not a path segment: it is a free-form key
+    # (up to 250 chars) that may itself contain "/", which would otherwise be
+    # ambiguous (or mis-routed) as a path segment.
     partitioned_dag_run = session.execute(
         select(
             AssetPartitionDagRun.id,
@@ -366,7 +369,11 @@ def get_pending_partitioned_dag_run(
             AssetPartitionDagRun.partition_key == partition_key,
             AssetPartitionDagRun.created_dag_run_id.is_(None),
         )
-    ).one_or_none()
+        # Duplicate pending rows for the same (dag_id, partition_key) can exist
+        # after a crash; mirror _get_or_create_apdr and work on the latest one.
+        .order_by(AssetPartitionDagRun.id.desc())
+        .limit(1)
+    ).first()
 
     if partitioned_dag_run is None:
         raise HTTPException(
@@ -440,9 +447,15 @@ def get_pending_partitioned_dag_run(
             required_keys = []
             received_count = 0
             required_count = 1
-        else:
+        elif is_rollup:
             received_count = len(received_keys)
             required_count = len(required_keys)
+        else:
+            # Match the list route's _compute_received_count: a non-rollup 
asset is
+            # satisfied by any single received event, so credit caps at 1 even 
if
+            # several distinct upstream keys mapped onto this one target key.
+            required_count = len(required_keys)
+            received_count = 1 if received_keys else 0
         assets.append(
             PartitionedDagRunAssetResponse(
                 asset_id=asset_row.id,
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index b934568d8ad..810e9a9a0f5 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -4696,9 +4696,11 @@ export class PartitionedDagRunService {
     public static getPendingPartitionedDagRun(data: 
GetPendingPartitionedDagRunData): 
CancelablePromise<GetPendingPartitionedDagRunResponse> {
         return __request(OpenAPI, {
             method: 'GET',
-            url: '/ui/pending_partitioned_dag_run/{dag_id}/{partition_key}',
+            url: '/ui/pending_partitioned_dag_run/{dag_id}',
             path: {
-                dag_id: data.dagId,
+                dag_id: data.dagId
+            },
+            query: {
                 partition_key: data.partitionKey
             },
             errors: {
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 c27b0d8e163..6433c3bfcd6 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
@@ -8238,7 +8238,7 @@ export type $OpenApiTs = {
             };
         };
     };
-    '/ui/pending_partitioned_dag_run/{dag_id}/{partition_key}': {
+    '/ui/pending_partitioned_dag_run/{dag_id}': {
         get: {
             req: GetPendingPartitionedDagRunData;
             res: {
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
index 3bea3b87d01..2075aca6ac1 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
@@ -544,11 +544,13 @@ class TestGetPartitionedDagRuns:
 
 class TestGetPendingPartitionedDagRun:
     def test_should_response_401(self, unauthenticated_test_client):
-        response = 
unauthenticated_test_client.get("/pending_partitioned_dag_run/any_dag/any_key")
+        response = unauthenticated_test_client.get(
+            "/pending_partitioned_dag_run/any_dag?partition_key=any_key"
+        )
         assert response.status_code == 401
 
     def test_should_response_403(self, unauthorized_test_client):
-        response = 
unauthorized_test_client.get("/pending_partitioned_dag_run/any_dag/any_key")
+        response = 
unauthorized_test_client.get("/pending_partitioned_dag_run/any_dag?partition_key=any_key")
         assert response.status_code == 403
 
     @pytest.mark.parametrize(
@@ -583,7 +585,15 @@ class TestGetPendingPartitionedDagRun:
             )
             session.commit()
 
-        resp = 
test_client.get(f"/pending_partitioned_dag_run/{dag_id}/{partition_key}")
+        resp = 
test_client.get(f"/pending_partitioned_dag_run/{dag_id}?partition_key={partition_key}")
+        assert resp.status_code == 404
+
+    def test_missing_partition_key_query_param_returns_422(self, test_client):
+        resp = test_client.get("/pending_partitioned_dag_run/any_dag")
+        assert resp.status_code == 422
+
+    def test_empty_partition_key_query_param_returns_404(self, test_client):
+        resp = 
test_client.get("/pending_partitioned_dag_run/any_dag?partition_key=")
         assert resp.status_code == 404
 
     @pytest.mark.parametrize(
@@ -644,7 +654,7 @@ class TestGetPendingPartitionedDagRun:
             )
         session.commit()
 
-        resp = 
test_client.get("/pending_partitioned_dag_run/detail_dag/2024-07-01")
+        resp = 
test_client.get("/pending_partitioned_dag_run/detail_dag?partition_key=2024-07-01")
         assert resp.status_code == 200
         body = resp.json()
         assert body["dag_id"] == "detail_dag"
@@ -677,7 +687,7 @@ class TestGetPendingPartitionedDagRun:
         session.add(AssetPartitionDagRun(target_dag_id="nr_detail_dag", 
partition_key="2024-07-01"))
         session.commit()
 
-        resp = 
test_client.get("/pending_partitioned_dag_run/nr_detail_dag/2024-07-01")
+        resp = 
test_client.get("/pending_partitioned_dag_run/nr_detail_dag?partition_key=2024-07-01")
         assert resp.status_code == 200
         assets = resp.json()["assets"]
         assert len(assets) == 2
@@ -713,7 +723,7 @@ class TestGetPendingPartitionedDagRun:
         session.add(AssetPartitionDagRun(target_dag_id="rollup_default_dag", 
partition_key="2024-06-03"))
         session.commit()
 
-        resp = 
test_client.get("/pending_partitioned_dag_run/rollup_default_dag/2024-06-03")
+        resp = 
test_client.get("/pending_partitioned_dag_run/rollup_default_dag?partition_key=2024-06-03")
         assert resp.status_code == 200
         body = resp.json()
         assert body["total_required"] == 14
@@ -761,7 +771,7 @@ class TestGetPendingPartitionedDagRun:
         )
         session.commit()
 
-        resp = 
test_client.get("/pending_partitioned_dag_run/rollup_detail_dag/2024-06-03")
+        resp = 
test_client.get("/pending_partitioned_dag_run/rollup_detail_dag?partition_key=2024-06-03")
         assert resp.status_code == 200
         body = resp.json()
         assert body["total_required"] == 7
@@ -832,7 +842,9 @@ class TestGetPendingPartitionedDagRun:
             ),
             
mock.patch("airflow.api_fastapi.core_api.routes.ui.partitioned_dag_runs.log") 
as mock_log,
         ):
-            resp = 
test_client.get("/pending_partitioned_dag_run/rollup_warn_detail_dag/2024-06-03")
+            resp = test_client.get(
+                
"/pending_partitioned_dag_run/rollup_warn_detail_dag?partition_key=2024-06-03"
+            )
 
         assert resp.status_code == 200
         a = resp.json()["assets"][0]
@@ -873,7 +885,7 @@ class TestGetPendingPartitionedDagRun:
         session.delete(asset_active_row)
         session.commit()
 
-        resp = 
test_client.get("/pending_partitioned_dag_run/inactive_detail_dag/2024-07-01")
+        resp = 
test_client.get("/pending_partitioned_dag_run/inactive_detail_dag?partition_key=2024-07-01")
         assert resp.status_code == 200
         assets = resp.json()["assets"]
         assert len(assets) == 1
@@ -896,8 +908,137 @@ class TestGetPendingPartitionedDagRun:
         session.add(AssetPartitionDagRun(target_dag_id="active_detail_dag", 
partition_key="2024-07-01"))
         session.commit()
 
-        resp = 
test_client.get("/pending_partitioned_dag_run/active_detail_dag/2024-07-01")
+        resp = 
test_client.get("/pending_partitioned_dag_run/active_detail_dag?partition_key=2024-07-01")
         assert resp.status_code == 200
         assets = resp.json()["assets"]
         assert len(assets) == 1
         assert assets[0]["asset_inactive"] is False
+
+    def test_partition_key_containing_slash_round_trips(self, test_client, 
dag_maker, session):
+        """
+        A partition key containing ``/`` must survive as a query parameter.
+
+        As a path segment, a URL-encoded ``/`` (``%2F``) is decoded before 
Starlette
+        routing sees it, so any key containing a literal ``/`` would 404. 
Sending it
+        as a query parameter instead avoids that ambiguity entirely.
+        """
+        asset = Asset(uri="s3://bucket/slash_key", name="slash_key")
+        with dag_maker(
+            dag_id="slash_key_dag",
+            schedule=PartitionedAssetTimetable(assets=asset),
+            serialized=True,
+        ):
+            EmptyOperator(task_id="t")
+        dag_maker.create_dagrun()
+        dag_maker.sync_dagbag_to_db()
+
+        session.add(AssetPartitionDagRun(target_dag_id="slash_key_dag", 
partition_key="region/us"))
+        session.commit()
+
+        resp = test_client.get(
+            "/pending_partitioned_dag_run/slash_key_dag", 
params={"partition_key": "region/us"}
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["dag_id"] == "slash_key_dag"
+        assert body["partition_key"] == "region/us"
+
+    def test_duplicate_pending_apdr_rows_return_latest(self, test_client, 
dag_maker, session):
+        """
+        Duplicate pending APDR rows for the same (dag_id, partition_key) must 
not 500.
+
+        The model docstring for ``AssetPartitionDagRun`` says callers should 
always
+        work on the latest row when duplicates exist; the route must do the 
same
+        instead of raising ``MultipleResultsFound`` from ``.one_or_none()``.
+        """
+        asset = Asset(uri="s3://bucket/dup_apdr", name="dup_apdr")
+        with dag_maker(
+            dag_id="dup_apdr_dag",
+            schedule=PartitionedAssetTimetable(assets=asset),
+            serialized=True,
+        ):
+            EmptyOperator(task_id="t")
+        dag_maker.create_dagrun()
+        dag_maker.sync_dagbag_to_db()
+
+        asset = session.scalar(select(AssetModel).where(AssetModel.uri == 
"s3://bucket/dup_apdr"))
+
+        # Older duplicate row: no received events.
+        stale_pdr = AssetPartitionDagRun(target_dag_id="dup_apdr_dag", 
partition_key="2024-08-01")
+        session.add(stale_pdr)
+        session.flush()
+
+        # Newer duplicate row (higher id): has a received event.
+        latest_pdr = AssetPartitionDagRun(target_dag_id="dup_apdr_dag", 
partition_key="2024-08-01")
+        session.add(latest_pdr)
+        session.flush()
+        event = AssetEvent(asset_id=asset.id, timestamp=pendulum.now())
+        session.add(event)
+        session.flush()
+        session.add(
+            PartitionedAssetKeyLog(
+                asset_id=asset.id,
+                asset_event_id=event.id,
+                asset_partition_dag_run_id=latest_pdr.id,
+                source_partition_key="2024-08-01",
+                target_dag_id="dup_apdr_dag",
+                target_partition_key="2024-08-01",
+            )
+        )
+        session.commit()
+
+        resp = 
test_client.get("/pending_partitioned_dag_run/dup_apdr_dag?partition_key=2024-08-01")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["id"] == latest_pdr.id
+        assert body["total_received"] == 1
+
+    def test_non_rollup_many_to_one_received_capped_at_one(self, test_client, 
dag_maker, session):
+        """
+        Detail route must cap non-rollup received credit at 1, matching the 
list route.
+
+        Multiple distinct upstream ``source_partition_key`` values logged 
against the
+        same non-rollup asset (a many-to-one mapper) must not inflate 
``received_count``
+        past ``required_count`` — otherwise the detail view shows e.g. "2/1" 
while the
+        list view shows "1/1" for the same APDR.
+        """
+        asset = Asset(uri="s3://bucket/many_to_one", name="many_to_one")
+        with dag_maker(
+            dag_id="many_to_one_dag",
+            schedule=PartitionedAssetTimetable(assets=asset),
+            serialized=True,
+        ):
+            EmptyOperator(task_id="t")
+        dag_maker.create_dagrun()
+        dag_maker.sync_dagbag_to_db()
+
+        asset = session.scalar(select(AssetModel).where(AssetModel.uri == 
"s3://bucket/many_to_one"))
+        pdr = AssetPartitionDagRun(target_dag_id="many_to_one_dag", 
partition_key="2024-08-01")
+        session.add(pdr)
+        session.flush()
+
+        for source_key in ("2024-08-01", "different-key"):
+            event = AssetEvent(asset_id=asset.id, timestamp=pendulum.now())
+            session.add(event)
+            session.flush()
+            session.add(
+                PartitionedAssetKeyLog(
+                    asset_id=asset.id,
+                    asset_event_id=event.id,
+                    asset_partition_dag_run_id=pdr.id,
+                    source_partition_key=source_key,
+                    target_dag_id="many_to_one_dag",
+                    target_partition_key="2024-08-01",
+                )
+            )
+        session.commit()
+
+        resp = 
test_client.get("/pending_partitioned_dag_run/many_to_one_dag?partition_key=2024-08-01")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["total_required"] == 1
+        assert body["total_received"] == 1
+        asset_resp = body["assets"][0]
+        assert asset_resp["required_count"] == 1
+        assert asset_resp["received_count"] == 1
+        assert asset_resp["received"] is True

Reply via email to