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 fdb8594e6bf Fix /ui/assets picking a stale "last event" under
concurrent asset writes (#71047)
fdb8594e6bf is described below
commit fdb8594e6bfa89c675ff44e86fb700cc5c77b067
Author: Takayoshi Makabe <[email protected]>
AuthorDate: Fri Aug 28 23:30:57 2026 +0900
Fix /ui/assets picking a stale "last event" under concurrent asset writes
(#71047)
* Fix /ui/assets picking a stale "last event" under concurrent asset writes
* Add Unit Test
---
.../src/airflow/api_fastapi/common/db/assets.py | 22 ++++++++---
.../api_fastapi/core_api/routes/ui/test_assets.py | 44 ++++++++++++++++++++++
2 files changed, 61 insertions(+), 5 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 8ee707fc9cd..4e7bddbd213 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 collections.abc import Iterable
from typing import TYPE_CHECKING
-from sqlalchemy import func, select
+from sqlalchemy import and_, func, select
from sqlalchemy.orm import selectinload
from airflow.api_fastapi.common.db.dags import eager_load_teams
@@ -42,8 +42,20 @@ if TYPE_CHECKING:
def generate_assets_with_last_event_query() -> Select:
"""Fetch Assets outer-joined to their latest AssetEvent id/timestamp."""
- max_asset_event_id_query = (
- select(AssetEvent.asset_id,
func.max(AssetEvent.id).label("max_asset_event_id"))
+ latest_asset_event_timestamps = (
+ select(AssetEvent.asset_id,
func.max(AssetEvent.timestamp).label("last_timestamp"))
+ .group_by(AssetEvent.asset_id)
+ .subquery()
+ )
+ latest_asset_event_ids = (
+ select(AssetEvent.asset_id,
func.max(AssetEvent.id).label("last_asset_event_id"))
+ .join(
+ latest_asset_event_timestamps,
+ and_(
+ AssetEvent.asset_id ==
latest_asset_event_timestamps.c.asset_id,
+ AssetEvent.timestamp ==
latest_asset_event_timestamps.c.last_timestamp,
+ ),
+ )
.group_by(AssetEvent.asset_id)
.subquery()
)
@@ -54,8 +66,8 @@ def generate_assets_with_last_event_query() -> Select:
AssetEvent.id.label("last_asset_event_id"),
AssetEvent.timestamp.label("last_asset_event_timestamp"),
)
- .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)
+ .outerjoin(latest_asset_event_ids, AssetModel.id ==
latest_asset_event_ids.c.asset_id)
+ .outerjoin(AssetEvent, AssetEvent.id ==
latest_asset_event_ids.c.last_asset_event_id)
.options(
*eager_load_asset_reference_teams(),
selectinload(AssetModel.consuming_tasks),
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 f91f475a18b..36b200405cc 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
@@ -569,6 +569,50 @@ class TestGetAssetsUi:
assert response.status_code == 200
assert [a["name"] for a in response.json()["assets"]] == ["newer",
"older"]
+ def test_last_asset_event_uses_latest_timestamp_not_highest_id(self,
test_client, session):
+ asset = AssetModel(name="out_of_order",
uri="s3://bucket/out_of_order", group="asset")
+ session.add(asset)
+ session.add(AssetActive.for_asset(asset))
+ session.flush()
+
+ base = pendulum.datetime(2024, 1, 1)
+ latest_event = AssetEvent(asset_id=asset.id,
timestamp=base.add(days=1))
+ highest_id_event = AssetEvent(asset_id=asset.id, timestamp=base)
+ session.add_all([latest_event, highest_id_event])
+ session.flush()
+ latest_event_id = latest_event.id
+ highest_id_event_id = highest_id_event.id
+ session.commit()
+
+ assert highest_id_event_id > latest_event_id
+
+ response = test_client.get("/assets")
+ assert response.status_code == 200
+ last_asset_event = response.json()["assets"][0]["last_asset_event"]
+ assert last_asset_event["id"] == latest_event_id
+ assert last_asset_event["timestamp"] == "2024-01-02T00:00:00Z"
+
+ def test_last_asset_event_breaks_timestamp_tie_by_highest_id(self,
test_client, session):
+ asset = AssetModel(name="tied_timestamp",
uri="s3://bucket/tied_timestamp", group="asset")
+ session.add(asset)
+ session.add(AssetActive.for_asset(asset))
+ session.flush()
+
+ tied_timestamp = pendulum.datetime(2024, 1, 1)
+ lower_id_event = AssetEvent(asset_id=asset.id,
timestamp=tied_timestamp)
+ higher_id_event = AssetEvent(asset_id=asset.id,
timestamp=tied_timestamp)
+ session.add_all([lower_id_event, higher_id_event])
+ session.flush()
+ higher_id_event_id = higher_id_event.id
+ session.commit()
+
+ assert higher_id_event_id > lower_id_event.id
+
+ response = test_client.get("/assets")
+ assert response.status_code == 200
+ last_asset_event = response.json()["assets"][0]["last_asset_event"]
+ assert last_asset_event["id"] == higher_id_event_id
+
def test_sort_by_group(self, test_client, session):
billing = AssetModel(name="billing_asset",
uri="s3://bucket/billing_sort", group="billing")
marketing = AssetModel(name="marketing_asset",
uri="s3://bucket/marketing_sort", group="marketing")