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})