From 10cb8c290e5102b127dffa59c6799cc14b7e6995 Mon Sep 17 00:00:00 2001 From: manipatnam <91188953+manipatnam@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:42:30 +0530 Subject: [PATCH] [v3-2-test] fixed sort order for mapped task instances (#67551) * 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 <91188953+manipatnam@users.noreply.github.com> Co-authored-by: AI Assistant --- .../src/airflow/api_fastapi/common/cursors.py | 23 +++- .../airflow/api_fastapi/common/parameters.py | 26 +++- .../openapi/v2-rest-api-generated.yaml | 12 +- .../core_api/routes/public/task_instances.py | 9 ++ .../ui/openapi-gen/queries/ensureQueryData.ts | 4 +- .../ui/openapi-gen/queries/prefetch.ts | 4 +- .../airflow/ui/openapi-gen/queries/queries.ts | 4 +- .../ui/openapi-gen/queries/suspense.ts | 4 +- .../ui/openapi-gen/requests/services.gen.ts | 4 +- .../ui/openapi-gen/requests/types.gen.ts | 4 +- .../unit/api_fastapi/common/test_cursors.py | 20 ++++ .../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 21bfa23d4a4d6..2eb5569f890fa 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 2688c0090a516..37bdd03a0760b 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 @@ def _resolve(self) -> list[tuple[str, ColumnElement, bool]]: 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 @@ def row_value(self, row: Any, name: str) -> Any: 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 @@ def row_value(self, row: Any, name: str) -> Any: 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 @@ def depends(cls, *args: Any, **kwargs: Any) -> Self: 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 92f5dcade8ac9..268b0807a795d 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 2432d8f283a55..1664fe434755e 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 63adabe5d2e89..eb386538ccdb0 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 starting with `test`, and `s3://` matches items starting with `s3`. * @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 starting with `test`, and `s3://` matches items starting with `s3`. * @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 d30badca166e6..5f5e947c6b774 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 starting with `test`, and `s3://` matches items starting with `s3`. * @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 starting with `test`, and `s3://` matches items starting with `s3`. * @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 c7292be18826a..f9f4043c025f0 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 = ; 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 b863de9eb6f3e..11db6ca5ba8e9 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 @@ def test_sort_param_pk_not_duplicated_when_sorting_by_id(self): 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 a48f780b8b11a..0439cb9263c58 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 @@ def test_offset_limit(self, test_client, one_task_with_many_mapped_tis): ({"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 @@ def test_rendered_map_index_filter( 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 @@ def test_should_respond_200_for_order_by(self, order_by_field, base_date, test_c @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 @@ def test_should_respond_200_for_rendered_map_index_order( 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})