This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new a3b1e13827f Eager-load Assets list relationships with selectinload and
refresh it after new events (#70997)
a3b1e13827f is described below
commit a3b1e13827f18e3012568818e33149d45b1a4057
Author: Brent Bovenzi <[email protected]>
AuthorDate: Tue Aug 11 05:04:05 2026 -0400
Eager-load Assets list relationships with selectinload and refresh it after
new events (#70997)
The Assets list eager-loads five relationships. subqueryload re-runs the
whole
last-event query — including its max(asset_event.id) GROUP BY aggregation —
as an
inner subquery once per relationship, so a single list page issued seven
aggregations of asset_event. selectinload emits a plain WHERE asset_id IN
(page
ids) instead, matching the Dags list helper.
The query-count guard is also strengthened to give assets a watcher (with a
trigger) and an alias, so it actually covers the relationships and the
nested
watcher -> trigger join it exists to protect.
Creating an asset event now invalidates the Assets list cache, so the row's
last
event timestamp — and its position under the default last-event sort —
update
instead of going stale.
---
.../src/airflow/api_fastapi/common/db/assets.py | 12 +++++------
.../src/pages/Asset/CreateAssetEventModal.test.tsx | 17 ++++++++++++++-
.../ui/src/pages/Asset/CreateAssetEventModal.tsx | 8 ++++++-
.../api_fastapi/core_api/routes/ui/test_assets.py | 25 ++++++++++++++++------
4 files changed, 48 insertions(+), 14 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/common/db/assets.py
b/airflow-core/src/airflow/api_fastapi/common/db/assets.py
index e393ea19060..b0ec961d864 100644
--- a/airflow-core/src/airflow/api_fastapi/common/db/assets.py
+++ b/airflow-core/src/airflow/api_fastapi/common/db/assets.py
@@ -20,7 +20,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import func, select
-from sqlalchemy.orm import subqueryload
+from sqlalchemy.orm import selectinload
from airflow.models.asset import AssetEvent, AssetModel, AssetWatcherModel
@@ -45,10 +45,10 @@ def generate_assets_with_last_event_query() -> Select:
.outerjoin(max_asset_event_id_query, AssetModel.id ==
max_asset_event_id_query.c.asset_id)
.outerjoin(AssetEvent, AssetEvent.id ==
max_asset_event_id_query.c.max_asset_event_id)
.options(
- subqueryload(AssetModel.scheduled_dags),
- subqueryload(AssetModel.producing_tasks),
- subqueryload(AssetModel.consuming_tasks),
- subqueryload(AssetModel.aliases),
-
subqueryload(AssetModel.watchers).joinedload(AssetWatcherModel.trigger),
+ selectinload(AssetModel.scheduled_dags),
+ selectinload(AssetModel.producing_tasks),
+ selectinload(AssetModel.consuming_tasks),
+ selectinload(AssetModel.aliases),
+
selectinload(AssetModel.watchers).joinedload(AssetWatcherModel.trigger),
)
)
diff --git
a/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.test.tsx
index a7e91e64c05..9784f59ed88 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.test.tsx
@@ -16,13 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { QueryClient } from "@tanstack/react-query";
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type * as OpenapiQueries from "openapi/queries";
-import type { AssetResponse, DAGDetailsResponse } from
"openapi/requests/types.gen";
+import type { AssetEventResponse, AssetResponse, DAGDetailsResponse } from
"openapi/requests/types.gen";
import type { DagRunTriggerParams } from "src/components/TriggerDag/types";
import type * as Ui from "src/components/ui";
import { Wrapper } from "src/utils/Wrapper";
@@ -90,6 +91,7 @@ vi.mock("openapi/queries", async (importOriginal) => {
const {
useAssetServiceCreateAssetEvent,
+ useAssetServiceGetAssetsUiKey,
useAssetServiceMaterializeAsset,
useDagServiceGetDagDetails,
useDependenciesServiceGetDependencies,
@@ -169,6 +171,19 @@ describe("CreateAssetEventModal", () => {
});
});
+ it("invalidates the assets list cache after creating an event", async () => {
+ const invalidateSpy = vi.spyOn(QueryClient.prototype,
"invalidateQueries").mockResolvedValue();
+
+ render(<CreateAssetEventModal asset={asset} onClose={vi.fn()} open />, {
wrapper: Wrapper });
+
+ const onSuccess =
vi.mocked(useAssetServiceCreateAssetEvent).mock.calls.at(-1)?.[0]?.onSuccess as
+ ((data: AssetEventResponse) => Promise<void>) | undefined;
+
+ await onSuccess?.({ id: 1 } as unknown as AssetEventResponse);
+
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey:
[useAssetServiceGetAssetsUiKey] });
+ });
+
it("sends the entered manual partition key as-is", () => {
render(<CreateAssetEventModal asset={asset} onClose={vi.fn()} open />, {
wrapper: Wrapper });
diff --git
a/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.tsx
b/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.tsx
index b4e64b0f1d8..76a291d4667 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.tsx
@@ -25,6 +25,7 @@ import { FiPlay } from "react-icons/fi";
import {
useAssetServiceCreateAssetEvent,
UseAssetServiceGetAssetEventsKeyFn,
+ useAssetServiceGetAssetsUiKey,
useAssetServiceMaterializeAsset,
UseDagRunServiceGetDagRunsKeyFn,
useDagServiceGetDagDetails,
@@ -78,7 +79,12 @@ export const CreateAssetEventModal = ({ asset, onClose, open
}: Props) => {
setPartitionKey(undefined);
onClose();
- let queryKeys = [UseAssetServiceGetAssetEventsKeyFn({ assetId: asset.id },
[{ assetId: asset.id }])];
+ let queryKeys = [
+ UseAssetServiceGetAssetEventsKeyFn({ assetId: asset.id }, [{ assetId:
asset.id }]),
+ // The Assets list defaults to sorting by last asset event, so a new
event changes both the
+ // displayed timestamp and the row's position; refresh it for manual
events and materializes.
+ [useAssetServiceGetAssetsUiKey],
+ ];
if ("dag_run_id" in response) {
const dagId = response.dag_id;
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
index 2427729035f..9cee5f1ac8c 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
@@ -31,9 +31,11 @@ from airflow.models.asset import (
AssetEvent,
AssetModel,
AssetPartitionDagRun,
+ AssetWatcherModel,
DagScheduleAssetReference,
PartitionedAssetKeyLog,
)
+from airflow.models.trigger import Trigger
from airflow.partition_mappers.base import RollupMapper
from airflow.partition_mappers.temporal import StartOfHourMapper
from airflow.partition_mappers.window import HourWindow
@@ -756,12 +758,23 @@ class TestGetAssetsUi:
def test_query_count(self, test_client, session):
"""The asset relationships are eager-loaded, so the query count stays
fixed regardless of
- how many assets are returned (a lazy-loading regression would issue
queries per asset)."""
- for i in range(5):
- asset = AssetModel(name=f"asset{i}", uri=f"s3://bucket/asset{i}",
group="asset")
- session.add(asset)
- session.add(AssetActive.for_asset(asset))
+ how many assets are returned (a lazy-loading regression would issue
queries per asset).
+
+ At least two assets carry a watcher (with a trigger) and an alias so
the count guards the
+ ``selectinload`` of every relationship, including the nested
``watchers -> trigger`` join.
+ """
+ assets = [AssetModel(name=f"asset{i}", uri=f"s3://bucket/asset{i}",
group="asset") for i in range(5)]
+ session.add_all(assets)
+ session.add_all(AssetActive.for_asset(asset) for asset in assets)
+ session.flush()
+
+ for i in range(2):
+ trigger =
Trigger(classpath=f"airflow.triggers.testing.TestTrigger{i}", kwargs={})
+ session.add(trigger)
+ session.flush()
+ assets[i].watchers.append(AssetWatcherModel(name=f"watcher{i}",
trigger_id=trigger.id))
+ assets[i].aliases.append(AssetAliasModel(name=f"alias{i}",
group=""))
session.commit()
- with assert_queries_count(7):
+ with assert_queries_count(8):
assert test_client.get("/assets").status_code == 200