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

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


The following commit(s) were added to refs/heads/v3-2-test by this push:
     new 707010a2520 [v3-2-test] fixed sort order for mapped task instances 
(#67551) (#68164)
707010a2520 is described below

commit 707010a25206c30ce5fa5328ed2e317ef28a9068
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Jun 7 14:50:51 2026 +0200

    [v3-2-test] fixed sort order for mapped task instances (#67551) (#68164)
    
    * fixed sort order for mapped task instances
    
    * Updated tests
    
    * Fixed pagination crash
    
    * Fixed comments
    
    * Fix duplicate order_by attributes in OpenAPI spec and SortParam 
description
    
    When `rendered_map_index` (and other keys like `run_after`, `logical_date`,
    `data_interval_start`, `data_interval_end`) are present in both 
`allowed_attrs`
    and `to_replace`, `dynamic_depends` was appending them twice to `all_attrs`,
    causing the generated `order_by` parameter description to list those 
attributes
    twice. This caused the `generate-openapi-spec` static check to fail.
    
    Fix: deduplicate `to_replace_attrs` by excluding keys already in 
`allowed_attrs`
    before building `all_attrs`. Update the committed OpenAPI spec to match.
    
    * Static check fix
    
    ---------
    (cherry picked from commit 94fa4d2b4f61856152a7bea66c8ec351065c0569)
    
    Co-authored-by: manipatnam <[email protected]>
    Co-authored-by: AI Assistant <[email protected]>
---
 .../src/airflow/api_fastapi/common/cursors.py      |  23 ++++-
 .../src/airflow/api_fastapi/common/parameters.py   |  26 ++++-
 .../core_api/openapi/v2-rest-api-generated.yaml    |  12 +--
 .../core_api/routes/public/task_instances.py       |   9 ++
 .../ui/openapi-gen/queries/ensureQueryData.ts      |   4 +-
 .../src/airflow/ui/openapi-gen/queries/prefetch.ts |   4 +-
 .../src/airflow/ui/openapi-gen/queries/queries.ts  |   4 +-
 .../src/airflow/ui/openapi-gen/queries/suspense.ts |   4 +-
 .../ui/openapi-gen/requests/services.gen.ts        |   4 +-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |   4 +-
 .../tests/unit/api_fastapi/common/test_cursors.py  |  20 ++++
 .../core_api/routes/public/test_task_instances.py  | 113 +++++++++++++++++++--
 12 files changed, 196 insertions(+), 31 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/common/cursors.py 
b/airflow-core/src/airflow/api_fastapi/common/cursors.py
index 21bfa23d4a4..2eb5569f890 100644
--- a/airflow-core/src/airflow/api_fastapi/common/cursors.py
+++ b/airflow-core/src/airflow/api_fastapi/common/cursors.py
@@ -44,12 +44,31 @@ def _b64url_decode_padded(token: str) -> bytes:
 
 
 def _nonstrict_bound(col: ColumnElement, value: Any, is_desc: bool) -> 
ColumnElement[bool]:
-    """Inclusive range edge on the leading column at each nesting level 
(``>=`` / ``<=``)."""
+    """
+    Inclusive range edge on the leading column at each nesting level (``>=`` / 
``<=``).
+
+    When *value* is ``None`` the column is nullable and the cursor sits at a
+    NULL boundary.  ``col IS NULL`` is used instead of ``col >= NULL`` (which
+    SQLAlchemy rejects and SQL evaluates as UNKNOWN).
+    """
+    if value is None:
+        return col.is_(None)
     return col <= value if is_desc else col >= value
 
 
 def _strict_bound(col: ColumnElement, value: Any, is_desc: bool) -> 
ColumnElement[bool]:
-    """Strict inequality for ``or_`` branches (``<`` / ``>``)."""
+    """
+    Strict inequality for ``or_`` branches (``<`` / ``>``).
+
+    When *value* is ``None`` the cursor is at a NULL boundary.  The only rows
+    that can be "strictly after" a NULL are non-NULL rows (regardless of
+    whether the database sorts NULLs first or last), so ``col IS NOT NULL`` is
+    used.  When the surrounding ``_nonstrict_bound`` already constrains
+    ``col IS NULL``, this branch evaluates to FALSE and the inner keyset
+    predicate takes over — which is the correct behaviour.
+    """
+    if value is None:
+        return col.is_not(None)
     return col < value if is_desc else col > value
 
 
diff --git a/airflow-core/src/airflow/api_fastapi/common/parameters.py 
b/airflow-core/src/airflow/api_fastapi/common/parameters.py
index 2688c0090a5..37bdd03a076 100644
--- a/airflow-core/src/airflow/api_fastapi/common/parameters.py
+++ b/airflow-core/src/airflow/api_fastapi/common/parameters.py
@@ -521,7 +521,10 @@ class SortParam(BaseParam[list[str]]):
     MAX_SORT_PARAMS = 10
 
     def __init__(
-        self, allowed_attrs: list[str], model: Base, to_replace: dict[str, str 
| Column] | None = None
+        self,
+        allowed_attrs: list[str],
+        model: Base,
+        to_replace: dict[str, str | Column | list[Column]] | None = None,
     ) -> None:
         super().__init__()
         self.allowed_attrs = allowed_attrs
@@ -560,6 +563,16 @@ class SortParam(BaseParam[list[str]]):
                 replacement = self.to_replace.get(lstriped_orderby, 
lstriped_orderby)
                 if isinstance(replacement, str):
                     lstriped_orderby = replacement
+                elif isinstance(replacement, list):
+                    # Compound sort: expand the list into multiple sort 
entries.
+                    # Each column's ORM key becomes its attr_name so that
+                    # row_value() can read the corresponding attribute via
+                    # getattr(row, attr_name) without further to_replace 
lookups.
+                    is_desc = order_by_value.startswith("-")
+                    for col in replacement:
+                        col_attr_name = col.key
+                        resolved.append((col_attr_name, col, is_desc))
+                    continue
                 else:
                     column = replacement
 
@@ -610,7 +623,7 @@ class SortParam(BaseParam[list[str]]):
             replacement = self.to_replace.get(name)
             if isinstance(replacement, str):
                 return getattr(row, replacement, None)
-            if replacement is not None:
+            if replacement is not None and not isinstance(replacement, list):
                 # TODO: Column-form ``to_replace`` (e.g. ``{"last_run_state": 
DagRun.state}``)
                 # isn't supported for cursor pagination — no endpoint that 
uses cursor
                 # pagination needs it today. When one does, decide how the row 
exposes the
@@ -622,6 +635,10 @@ class SortParam(BaseParam[list[str]]):
                     f"``{name}``. Use a string alias in ``to_replace`` or sort 
by a primary-model "
                     f"attribute."
                 )
+            # List-form replacements are expanded in _resolve() into 
individual entries
+            # each using the column's own ORM key as attr_name, so ``name`` at 
this point
+            # is already a concrete model attribute (e.g. 
``_rendered_map_index`` or
+            # ``map_index``) — fall through to the getattr below.
         return getattr(row, name, None)
 
     def get_primary_key_column(self) -> Column:
@@ -637,7 +654,10 @@ class SortParam(BaseParam[list[str]]):
         raise NotImplementedError("Use dynamic_depends, depends not 
implemented.")
 
     def dynamic_depends(self, default: str | Sequence[str] | None = None) -> 
Callable:
-        to_replace_attrs = list(self.to_replace.keys()) if self.to_replace 
else []
+        # Include to_replace keys that are not already in allowed_attrs to 
avoid
+        # duplicate entries in the spec description.
+        allowed_set = set(self.allowed_attrs)
+        to_replace_attrs = [k for k in self.to_replace if k not in 
allowed_set] if self.to_replace else []
 
         all_attrs = self.allowed_attrs + to_replace_attrs
 
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 92f5dcade8a..268b0807a79 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
@@ -6963,16 +6963,14 @@ paths:
           description: 'Attributes to order by, multi criteria sort is 
supported.
             Prefix with `-` for descending order. Supported attributes: `id, 
state,
             duration, start_date, end_date, map_index, try_number, 
logical_date, run_after,
-            data_interval_start, data_interval_end, rendered_map_index, 
operator,
-            run_after, logical_date, data_interval_start, data_interval_end`'
+            data_interval_start, data_interval_end, rendered_map_index, 
operator`'
           default:
           - map_index
           title: Order By
         description: 'Attributes to order by, multi criteria sort is 
supported. Prefix
           with `-` for descending order. Supported attributes: `id, state, 
duration,
           start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start,
-          data_interval_end, rendered_map_index, operator, run_after, 
logical_date,
-          data_interval_start, data_interval_end`'
+          data_interval_end, rendered_map_index, operator`'
       responses:
         '200':
           description: Successful Response
@@ -8125,16 +8123,14 @@ paths:
           description: 'Attributes to order by, multi criteria sort is 
supported.
             Prefix with `-` for descending order. Supported attributes: `id, 
state,
             duration, start_date, end_date, map_index, try_number, 
logical_date, run_after,
-            data_interval_start, data_interval_end, rendered_map_index, 
operator,
-            logical_date, run_after, data_interval_start, data_interval_end`'
+            data_interval_start, data_interval_end, rendered_map_index, 
operator`'
           default:
           - map_index
           title: Order By
         description: 'Attributes to order by, multi criteria sort is 
supported. Prefix
           with `-` for descending order. Supported attributes: `id, state, 
duration,
           start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start,
-          data_interval_end, rendered_map_index, operator, logical_date, 
run_after,
-          data_interval_start, data_interval_end`'
+          data_interval_end, rendered_map_index, operator`'
       responses:
         '200':
           description: Successful Response
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
index 2432d8f283a..1664fe43475 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
@@ -210,6 +210,13 @@ def get_mapped_task_instances(
                     "logical_date": DagRun.logical_date,
                     "data_interval_start": DagRun.data_interval_start,
                     "data_interval_end": DagRun.data_interval_end,
+                    # Compound sort: when _rendered_map_index is NULL (no 
map_index_template),
+                    # all primary values tie and the integer map_index is the 
effective key,
+                    # giving correct numeric ordering (0, 1, 2, 10…) rather 
than lexicographic
+                    # ("0", "1", "10", "2"…).  When _rendered_map_index is set 
(map_index_template
+                    # used), TIs are ordered by their human-readable label 
first, then by
+                    # map_index for identical labels.
+                    "rendered_map_index": [TI._rendered_map_index, 
TI.map_index],
                 },
             ).dynamic_depends(default="map_index")
         ),
@@ -502,6 +509,8 @@ def get_task_instances(
                     "run_after": DagRun.run_after,
                     "data_interval_start": DagRun.data_interval_start,
                     "data_interval_end": DagRun.data_interval_end,
+                    # Compound sort: see the listMapped endpoint comment for 
rationale.
+                    "rendered_map_index": [TI._rendered_map_index, 
TI.map_index],
                 },
             ).dynamic_depends(default="map_index")
         ),
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index 63adabe5d2e..eb386538ccd 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -871,7 +871,7 @@ export const 
ensureUseTaskInstanceServiceGetTaskInstanceData = (queryClient: Que
 * @param data.renderedMapIndexPrefixPattern Prefix match — returns items whose 
value starts with the given string (case-sensitive, index-friendly). Use the 
pipe `|` operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. 
Wildcard characters (`%`, `_`) are treated as literal characters. Trailing 
non-alphanumeric characters in the prefix are stripped before matching so the 
range scan stays index-compatible under locale-aware collations — e.g. `test_` 
effectively matches items start [...]
 * @param data.limit
 * @param data.offset
-* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, 
operator, run_after, logical_date, data_interval_start, data_interval_end`
+* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, operator`
 * @returns TaskInstanceCollectionResponse Successful Response
 * @throws ApiError
 */
@@ -1090,7 +1090,7 @@ export const 
ensureUseTaskInstanceServiceGetMappedTaskInstanceData = (queryClien
 * @param data.renderedMapIndexPrefixPattern Prefix match — returns items whose 
value starts with the given string (case-sensitive, index-friendly). Use the 
pipe `|` operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. 
Wildcard characters (`%`, `_`) are treated as literal characters. Trailing 
non-alphanumeric characters in the prefix are stripped before matching so the 
range scan stays index-compatible under locale-aware collations — e.g. `test_` 
effectively matches items start [...]
 * @param data.limit
 * @param data.offset
-* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, 
operator, logical_date, run_after, data_interval_start, data_interval_end`
+* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, operator`
 * @returns TaskInstanceCollectionResponse Successful Response
 * @throws ApiError
 */
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index d30badca166..5f5e947c6b7 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -871,7 +871,7 @@ export const prefetchUseTaskInstanceServiceGetTaskInstance 
= (queryClient: Query
 * @param data.renderedMapIndexPrefixPattern Prefix match — returns items whose 
value starts with the given string (case-sensitive, index-friendly). Use the 
pipe `|` operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. 
Wildcard characters (`%`, `_`) are treated as literal characters. Trailing 
non-alphanumeric characters in the prefix are stripped before matching so the 
range scan stays index-compatible under locale-aware collations — e.g. `test_` 
effectively matches items start [...]
 * @param data.limit
 * @param data.offset
-* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, 
operator, run_after, logical_date, data_interval_start, data_interval_end`
+* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, operator`
 * @returns TaskInstanceCollectionResponse Successful Response
 * @throws ApiError
 */
@@ -1090,7 +1090,7 @@ export const 
prefetchUseTaskInstanceServiceGetMappedTaskInstance = (queryClient:
 * @param data.renderedMapIndexPrefixPattern Prefix match — returns items whose 
value starts with the given string (case-sensitive, index-friendly). Use the 
pipe `|` operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. 
Wildcard characters (`%`, `_`) are treated as literal characters. Trailing 
non-alphanumeric characters in the prefix are stripped before matching so the 
range scan stays index-compatible under locale-aware collations — e.g. `test_` 
effectively matches items start [...]
 * @param data.limit
 * @param data.offset
-* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, 
operator, logical_date, run_after, data_interval_start, data_interval_end`
+* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, operator`
 * @returns TaskInstanceCollectionResponse Successful Response
 * @throws ApiError
 */
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
index c7292be1882..f9f4043c025 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -871,7 +871,7 @@ export const useTaskInstanceServiceGetTaskInstance = <TData 
= Common.TaskInstanc
 * @param data.renderedMapIndexPrefixPattern Prefix match — returns items whose 
value starts with the given string (case-sensitive, index-friendly). Use the 
pipe `|` operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. 
Wildcard characters (`%`, `_`) are treated as literal characters. Trailing 
non-alphanumeric characters in the prefix are stripped before matching so the 
range scan stays index-compatible under locale-aware collations — e.g. `test_` 
effectively matches items start [...]
 * @param data.limit
 * @param data.offset
-* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, 
operator, run_after, logical_date, data_interval_start, data_interval_end`
+* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, operator`
 * @returns TaskInstanceCollectionResponse Successful Response
 * @throws ApiError
 */
@@ -1090,7 +1090,7 @@ export const useTaskInstanceServiceGetMappedTaskInstance 
= <TData = Common.TaskI
 * @param data.renderedMapIndexPrefixPattern Prefix match — returns items whose 
value starts with the given string (case-sensitive, index-friendly). Use the 
pipe `|` operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. 
Wildcard characters (`%`, `_`) are treated as literal characters. Trailing 
non-alphanumeric characters in the prefix are stripped before matching so the 
range scan stays index-compatible under locale-aware collations — e.g. `test_` 
effectively matches items start [...]
 * @param data.limit
 * @param data.offset
-* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, 
operator, logical_date, run_after, data_interval_start, data_interval_end`
+* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, operator`
 * @returns TaskInstanceCollectionResponse Successful Response
 * @throws ApiError
 */
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index 46856ebebf4..7319cc1ca34 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -871,7 +871,7 @@ export const useTaskInstanceServiceGetTaskInstanceSuspense 
= <TData = Common.Tas
 * @param data.renderedMapIndexPrefixPattern Prefix match — returns items whose 
value starts with the given string (case-sensitive, index-friendly). Use the 
pipe `|` operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. 
Wildcard characters (`%`, `_`) are treated as literal characters. Trailing 
non-alphanumeric characters in the prefix are stripped before matching so the 
range scan stays index-compatible under locale-aware collations — e.g. `test_` 
effectively matches items start [...]
 * @param data.limit
 * @param data.offset
-* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, 
operator, run_after, logical_date, data_interval_start, data_interval_end`
+* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, operator`
 * @returns TaskInstanceCollectionResponse Successful Response
 * @throws ApiError
 */
@@ -1090,7 +1090,7 @@ export const 
useTaskInstanceServiceGetMappedTaskInstanceSuspense = <TData = Comm
 * @param data.renderedMapIndexPrefixPattern Prefix match — returns items whose 
value starts with the given string (case-sensitive, index-friendly). Use the 
pipe `|` operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. 
Wildcard characters (`%`, `_`) are treated as literal characters. Trailing 
non-alphanumeric characters in the prefix are stripped before matching so the 
range scan stays index-compatible under locale-aware collations — e.g. `test_` 
effectively matches items start [...]
 * @param data.limit
 * @param data.offset
-* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, 
operator, logical_date, run_after, data_interval_start, data_interval_end`
+* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, operator`
 * @returns TaskInstanceCollectionResponse Successful Response
 * @throws ApiError
 */
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 f372133e777..b47dca11951 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
@@ -2169,7 +2169,7 @@ export class TaskInstanceService {
      * @param data.renderedMapIndexPrefixPattern Prefix match — returns items 
whose value starts with the given string (case-sensitive, index-friendly). Use 
the pipe `|` operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. 
Wildcard characters (`%`, `_`) are treated as literal characters. Trailing 
non-alphanumeric characters in the prefix are stripped before matching so the 
range scan stays index-compatible under locale-aware collations — e.g. `test_` 
effectively matches items  [...]
      * @param data.limit
      * @param data.offset
-     * @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, 
operator, run_after, logical_date, data_interval_start, data_interval_end`
+     * @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, operator`
      * @returns TaskInstanceCollectionResponse Successful Response
      * @throws ApiError
      */
@@ -2511,7 +2511,7 @@ export class TaskInstanceService {
      * @param data.renderedMapIndexPrefixPattern Prefix match — returns items 
whose value starts with the given string (case-sensitive, index-friendly). Use 
the pipe `|` operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. 
Wildcard characters (`%`, `_`) are treated as literal characters. Trailing 
non-alphanumeric characters in the prefix are stripped before matching so the 
range scan stays index-compatible under locale-aware collations — e.g. `test_` 
effectively matches items  [...]
      * @param data.limit
      * @param data.offset
-     * @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, 
operator, logical_date, run_after, data_interval_start, data_interval_end`
+     * @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
state, duration, start_date, end_date, map_index, try_number, logical_date, 
run_after, data_interval_start, data_interval_end, rendered_map_index, operator`
      * @returns TaskInstanceCollectionResponse Successful Response
      * @throws ApiError
      */
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 1c43301db52..51ec2c0676c 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
@@ -3163,7 +3163,7 @@ export type GetMappedTaskInstancesData = {
      */
     operatorNamePrefixPattern?: string | null;
     /**
-     * Attributes to order by, multi criteria sort is supported. Prefix with 
`-` for descending order. Supported attributes: `id, state, duration, 
start_date, end_date, map_index, try_number, logical_date, run_after, 
data_interval_start, data_interval_end, rendered_map_index, operator, 
run_after, logical_date, data_interval_start, data_interval_end`
+     * Attributes to order by, multi criteria sort is supported. Prefix with 
`-` for descending order. Supported attributes: `id, state, duration, 
start_date, end_date, map_index, try_number, logical_date, run_after, 
data_interval_start, data_interval_end, rendered_map_index, operator`
      */
     orderBy?: Array<(string)>;
     pool?: Array<(string)>;
@@ -3319,7 +3319,7 @@ export type GetTaskInstancesData = {
      */
     operatorNamePrefixPattern?: string | null;
     /**
-     * Attributes to order by, multi criteria sort is supported. Prefix with 
`-` for descending order. Supported attributes: `id, state, duration, 
start_date, end_date, map_index, try_number, logical_date, run_after, 
data_interval_start, data_interval_end, rendered_map_index, operator, 
logical_date, run_after, data_interval_start, data_interval_end`
+     * Attributes to order by, multi criteria sort is supported. Prefix with 
`-` for descending order. Supported attributes: `id, state, duration, 
start_date, end_date, map_index, try_number, logical_date, run_after, 
data_interval_start, data_interval_end, rendered_map_index, operator`
      */
     orderBy?: Array<(string)>;
     pool?: Array<(string)>;
diff --git a/airflow-core/tests/unit/api_fastapi/common/test_cursors.py 
b/airflow-core/tests/unit/api_fastapi/common/test_cursors.py
index b863de9eb6f..11db6ca5ba8 100644
--- a/airflow-core/tests/unit/api_fastapi/common/test_cursors.py
+++ b/airflow-core/tests/unit/api_fastapi/common/test_cursors.py
@@ -142,3 +142,23 @@ class TestCursorPagination:
 
         assert len(resolved) == 1
         assert resolved[0][0] == "id"
+
+    def test_apply_cursor_filter_null_value_does_not_raise(self):
+        """Cursor tokens with None values (nullable sort columns) must not 
crash.
+
+        When order_by=rendered_map_index and no map_index_template is set,
+        _rendered_map_index is NULL for all rows.  The cursor encodes None and
+        the next-page filter must use IS NULL / IS NOT NULL instead of >= None.
+        """
+        sp = SortParam(
+            ["_rendered_map_index", "map_index", "id"],
+            TaskInstance,
+        )
+        sp.set_value(["_rendered_map_index", "map_index"])
+        token = _msgpack_cursor_token([None, 49, 
"019462ab-1234-5678-9abc-def012345678"])
+
+        # Should not raise ArgumentError from SQLAlchemy.
+        stmt = apply_cursor_filter(select(TaskInstance), token, sp)
+        sql = str(stmt)
+        assert "IS NULL" in sql
+        assert "IS NOT NULL" in sql
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
index a48f780b8b1..0439cb9263c 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
@@ -851,10 +851,13 @@ class TestGetMappedTaskInstances:
             ({"order_by": "-logical_date", "limit": 100}, list(range(109, 9, 
-1))),
             ({"order_by": "data_interval_start", "limit": 100}, 
list(range(100))),
             ({"order_by": "-data_interval_start", "limit": 100}, 
list(range(109, 9, -1))),
-            ({"order_by": "rendered_map_index", "limit": 100}, 
sorted(range(110), key=str)[:100]),
+            # Compound sort (_rendered_map_index ASC, map_index ASC): all TIs 
have NULL
+            # _rendered_map_index in this fixture so they all tie on the first 
key and
+            # are ordered by map_index integer — 0, 1, 2, ..., 99 (not 
lexicographic).
+            ({"order_by": "rendered_map_index", "limit": 100}, 
list(range(100))),
             (
                 {"order_by": "-rendered_map_index", "limit": 100},
-                sorted(range(110), key=str, reverse=True)[:100],
+                list(range(109, 9, -1)),
             ),
         ],
     )
@@ -925,6 +928,101 @@ class TestGetMappedTaskInstances:
         assert body["total_entries"] == len(expected_map_indexes)
         assert [ti["map_index"] for ti in body["task_instances"]] == 
expected_map_indexes
 
+    def test_rendered_map_index_order_without_template_numeric(self, 
test_client, session, dag_maker):
+        """map_index values beyond 9 must sort numerically, not 
lexicographically.
+
+        Without the compound sort the SQL expression falls back to
+        CAST(map_index AS String), producing "0","1","10","11","2"... instead
+        of 0, 1, 2, ..., 10, 11.
+        """
+        self.create_dag_runs_with_mapped_tasks(
+            dag_maker,
+            session,
+            dags={"numeric_order_dag": {"success": 12, "failed": 0, "running": 
0}},
+        )
+
+        response = test_client.get(
+            
"/dags/numeric_order_dag/dagRuns/run_numeric_order_dag/taskInstances/task_2/listMapped",
+            params={"order_by": "rendered_map_index", "limit": 20},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        # Numeric order: 0, 1, 2, ..., 11.
+        # Lexicographic order would be: 0, 1, 10, 11, 2, 3, 4, 5, 6, 7, 8, 9.
+        assert [ti["map_index"] for ti in body["task_instances"]] == 
list(range(12))
+
+    def test_rendered_map_index_order_with_template(self, test_client, 
session, one_task_with_mapped_tis):
+        """Custom map_index_template labels must be sorted alphabetically."""
+        # one_task_with_mapped_tis creates 3 TIs: map_index 0, 1, 2.
+        labels = {0: "zebra", 1: "apple", 2: "mango"}
+        for map_index, label in labels.items():
+            ti = session.scalar(
+                select(TaskInstance).where(
+                    TaskInstance.task_id == "task_2",
+                    TaskInstance.map_index == map_index,
+                )
+            )
+            ti._rendered_map_index = label
+        session.commit()
+
+        response = test_client.get(
+            
"/dags/mapped_tis/dagRuns/run_mapped_tis/taskInstances/task_2/listMapped",
+            params={"order_by": "rendered_map_index"},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        # Alphabetical order: "apple" (1), "mango" (2), "zebra" (0).
+        assert [ti["map_index"] for ti in body["task_instances"]] == [1, 2, 0]
+        assert [ti["rendered_map_index"] for ti in body["task_instances"]] == [
+            "apple",
+            "mango",
+            "zebra",
+        ]
+
+    def test_rendered_map_index_order_stable_regardless_of_uuid_order(self, 
test_client, session, dag_maker):
+        """
+        Results must be ordered by integer map_index regardless of UUID 
insertion order.
+
+        The compound sort ([_rendered_map_index, map_index]) makes the integer
+        map_index the effective tiebreaker when no map_index_template is set.
+        This verifies that even when UUIDs are assigned out of map_index order
+        (as happens during retries), the response is still sorted 0, 1, 2, ...
+        """
+        from sqlalchemy import update as sa_update
+
+        from airflow.models.taskinstance import uuid7
+
+        self.create_dag_runs_with_mapped_tasks(
+            dag_maker,
+            session,
+            dags={"retry_dag": {"success": 5, "failed": 0, "running": 0}},
+        )
+
+        # Assign newer (larger) UUIDs to map_index 1 and 3, simulating retry
+        # ordering where some TIs received their UUIDs after others.  The sort
+        # result must still follow integer map_index order, not UUID order.
+        for map_index in [1, 3]:
+            session.execute(
+                sa_update(TaskInstance)
+                .where(
+                    TaskInstance.dag_id == "retry_dag",
+                    TaskInstance.task_id == "task_2",
+                    TaskInstance.map_index == map_index,
+                )
+                .values(id=uuid7())
+            )
+        session.commit()
+
+        response = test_client.get(
+            
"/dags/retry_dag/dagRuns/run_retry_dag/taskInstances/task_2/listMapped",
+            params={"order_by": "rendered_map_index", "limit": 50},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        # All 5 TIs must be on the first page in map_index order.
+        assert body["total_entries"] == 5
+        assert [ti["map_index"] for ti in body["task_instances"]] == [0, 1, 2, 
3, 4]
+
     def test_with_date(self, test_client, one_task_with_mapped_tis):
         response = test_client.get(
             
"/dags/mapped_tis/dagRuns/run_mapped_tis/taskInstances/task_2/listMapped",
@@ -1838,8 +1936,11 @@ class TestGetTaskInstances(TestTaskInstanceEndpoint):
     @pytest.mark.parametrize(
         ("order_by", "expected_map_indexes"),
         [
-            ("rendered_map_index", [2, 3, 0, 1]),
-            ("-rendered_map_index", [1, 0, 3, 2]),
+            # All four TIs have explicit labels so alphabetical order is
+            # consistent across databases (no NULL ordering differences).
+            # Labels: "analytics"(3), "events"(2), "table_orders"(0), 
"table_users"(1)
+            ("rendered_map_index", [3, 2, 0, 1]),
+            ("-rendered_map_index", [1, 0, 2, 3]),
         ],
     )
     def test_should_respond_200_for_rendered_map_index_order(
@@ -1851,8 +1952,8 @@ class TestGetTaskInstances(TestTaskInstanceEndpoint):
             task_instances=[
                 {"map_index": 0, "_rendered_map_index": "table_orders"},
                 {"map_index": 1, "_rendered_map_index": "table_users"},
-                {"map_index": 2, "_rendered_map_index": None},
-                {"map_index": 3, "_rendered_map_index": None},
+                {"map_index": 2, "_rendered_map_index": "events"},
+                {"map_index": 3, "_rendered_map_index": "analytics"},
             ],
         )
         response = test_client.get("/dags/~/dagRuns/~/taskInstances", 
params={"order_by": order_by})


Reply via email to