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 3f66d0f2da5 Mark only a run's most recent asset event as triggering it
(#71441)
3f66d0f2da5 is described below
commit 3f66d0f2da5c81e010ea280b24f47175cfef02f4
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Thu Aug 13 12:46:18 2026 +0200
Mark only a run's most recent asset event as triggering it (#71441)
* Mark only a run's most recent asset event as triggering it
An asset event is linked to every dag run that consumed it, so a run that
consumed several events showed "Triggered Dag Run" on each of them — in both
the Asset Events view and a dag run's upstream asset events view — making it
look as though more runs had been created than actually were.
Only a run's most recent consumed event triggers it; the earlier ones are
merely included in the run. Surfacing that distinction makes the attribution
match what actually happened.
closes: #56749
* Match asset event triggering by id and add an N+1 query-count test
Address review feedback: match each created-dagrun reference to its source
event by id instead of relying on the positional order of two parallel
lists.
The same asset event can trigger one dag run while being merely included in
another (an asset can fan out to several consumers), so the flag is resolved
per (event, run) pair. The endpoints always populate it, so it is a required
field with no default. Also add a query-count assertion so both asset-event
endpoints are guarded against an N+1 as the number of consumed events grows.
* Small adjustments
---
.../src/airflow/api_fastapi/common/db/assets.py | 44 ++++++++++-
.../api_fastapi/core_api/datamodels/assets.py | 7 ++
.../core_api/openapi/v2-rest-api-generated.yaml | 7 ++
.../api_fastapi/core_api/routes/public/assets.py | 3 +-
.../api_fastapi/core_api/routes/public/dag_run.py | 8 +-
.../api_fastapi/core_api/services/public/assets.py | 45 +++++++++++
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 7 +-
.../airflow/ui/openapi-gen/requests/types.gen.ts | 4 +
.../airflow/ui/public/i18n/locales/en/common.json | 1 +
.../src/components/Assets/TriggeredRuns.test.tsx | 86 ++++++++++++++++++++++
.../ui/src/components/Assets/TriggeredRuns.tsx | 42 +++++++++--
.../core_api/routes/public/test_assets.py | 34 ++++++++-
.../core_api/routes/public/test_dag_run.py | 55 +++++++++++++-
.../src/airflowctl/api/datamodels/generated.py | 7 ++
.../tests/airflow_ctl/api/test_operations.py | 1 +
15 files changed, 336 insertions(+), 15 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 b0ec961d864..3e54c8dcabb 100644
--- a/airflow-core/src/airflow/api_fastapi/common/db/assets.py
+++ b/airflow-core/src/airflow/api_fastapi/common/db/assets.py
@@ -17,14 +17,17 @@
from __future__ import annotations
+from collections.abc import Iterable
from typing import TYPE_CHECKING
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
-from airflow.models.asset import AssetEvent, AssetModel, AssetWatcherModel
+from airflow.models.asset import AssetEvent, AssetModel, AssetWatcherModel,
association_table
+from airflow.models.dagrun import DagRun
if TYPE_CHECKING:
+ from sqlalchemy.orm import Session
from sqlalchemy.sql import Select
@@ -52,3 +55,42 @@ def generate_assets_with_last_event_query() -> Select:
selectinload(AssetModel.watchers).joinedload(AssetWatcherModel.trigger),
)
)
+
+
+def resolve_triggering_event_refs(
+ events: Iterable[AssetEvent], *, session: Session
+) -> set[tuple[int, str, str]]:
+ """
+ Return ``(event_id, dag_id, run_id)`` tuples where the asset event
actually triggered the run.
+
+ A dag run consumes every asset event queued when it is created, but only
the run's most recent
+ consumed event triggers it; earlier consumed events are merely included.
+ """
+ run_ids = {run.id for event in events for run in event.created_dagruns}
+ if not run_ids:
+ return set()
+ ranked_events = (
+ select(
+ association_table.c.event_id,
+ DagRun.dag_id,
+ DagRun.run_id,
+ func.row_number()
+ .over(
+ partition_by=association_table.c.dag_run_id,
+ order_by=(AssetEvent.timestamp.desc(), AssetEvent.id.desc()),
+ )
+ .label("rank"),
+ )
+ .join(AssetEvent, AssetEvent.id == association_table.c.event_id)
+ .join(DagRun, DagRun.id == association_table.c.dag_run_id)
+ .where(association_table.c.dag_run_id.in_(run_ids))
+ .subquery()
+ )
+ return {
+ (event_id, dag_id, run_id)
+ for event_id, dag_id, run_id in session.execute(
+ select(ranked_events.c.event_id, ranked_events.c.dag_id,
ranked_events.c.run_id).where(
+ ranked_events.c.rank == 1
+ )
+ )
+ }
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/assets.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/assets.py
index 8966891cf8b..fea14b36755 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/assets.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/assets.py
@@ -170,6 +170,13 @@ class DagRunAssetReference(StrictBaseModel):
data_interval_start: datetime | None
data_interval_end: datetime | None
partition_key: str | None
+ triggering: bool = Field(
+ description=(
+ "Whether this asset event triggered the referenced dag run. Only a
run's most recent "
+ "consumed asset event triggers it; earlier consumed events are
included in the run but "
+ "did not trigger it."
+ ),
+ )
class AssetEventResponse(BaseModel):
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 5200635ec9e..b3edb5986f4 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
@@ -13933,6 +13933,12 @@ components:
- type: string
- type: 'null'
title: Partition Key
+ triggering:
+ type: boolean
+ title: Triggering
+ description: Whether this asset event triggered the referenced dag
run.
+ Only a run's most recent consumed asset event triggers it; earlier
consumed
+ events are included in the run but did not trigger it.
additionalProperties: false
type: object
required:
@@ -13945,6 +13951,7 @@ components:
- data_interval_start
- data_interval_end
- partition_key
+ - triggering
title: DagRunAssetReference
description: DagRun serializer for asset responses.
DagRunMutableStates:
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py
index 830d4edaeed..d4363b37bc3 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py
@@ -74,6 +74,7 @@ from airflow.api_fastapi.core_api.security import (
requires_access_asset_alias,
requires_access_dag,
)
+from airflow.api_fastapi.core_api.services.public.assets import
serialize_asset_events
from airflow.api_fastapi.logging.decorators import action_logging
from airflow.assets.manager import asset_manager
from airflow.configuration import conf
@@ -378,7 +379,7 @@ def get_asset_events(
assets_events = session.scalars(assets_event_select).all()
return AssetEventCollectionResponse(
- asset_events=assets_events,
+ asset_events=serialize_asset_events(assets_events, session=session),
total_entries=total_entries,
)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
index d43e3b56fd8..b0b2b922c16 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
@@ -106,6 +106,7 @@ from airflow.api_fastapi.core_api.security import (
requires_access_dag_run_bulk,
requires_access_dag_run_clear_bulk,
)
+from airflow.api_fastapi.core_api.services.public.assets import
serialize_asset_events
from airflow.api_fastapi.core_api.services.public.dag_run import (
BulkDagRunService,
DagRunWaiter,
@@ -287,7 +288,10 @@ def get_upstream_asset_events(
DagRun.dag_id == dag_id,
DagRun.run_id == dag_run_id,
)
-
.options(joinedload(DagRun.consumed_asset_events).joinedload(AssetEvent.asset))
+ .options(
+
joinedload(DagRun.consumed_asset_events).joinedload(AssetEvent.asset),
+
joinedload(DagRun.consumed_asset_events).subqueryload(AssetEvent.created_dagruns),
+ )
)
if dag_run is None:
raise HTTPException(
@@ -296,7 +300,7 @@ def get_upstream_asset_events(
)
events = dag_run.consumed_asset_events
return AssetEventCollectionResponse(
- asset_events=events,
+ asset_events=serialize_asset_events(events, session=session),
total_entries=len(events),
)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/assets.py
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/assets.py
new file mode 100644
index 00000000000..f110f15e1fd
--- /dev/null
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/public/assets.py
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import TYPE_CHECKING
+
+from airflow.api_fastapi.common.db.assets import resolve_triggering_event_refs
+from airflow.api_fastapi.core_api.datamodels.assets import AssetEventResponse
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import Session
+
+ from airflow.models.asset import AssetEvent
+
+
+def serialize_asset_events(events: Sequence[AssetEvent], *, session: Session)
-> list[AssetEventResponse]:
+ """
+ Serialize asset events, flagging on each created dag run whether this
event triggered it.
+
+ Only a run's most recent consumed event triggers it; the rest were merely
included. The flag is
+ resolved per ``(event_id, dag_id, run_id)`` tuple and set on the run
before validation so the response
+ carries it.
+ """
+ triggering_refs = resolve_triggering_event_refs(events, session=session)
+ asset_events = []
+ for event in events:
+ for run in event.created_dagruns:
+ run.triggering = (event.id, run.dag_id, run.run_id) in
triggering_refs
+ asset_events.append(AssetEventResponse.model_validate(event))
+ return asset_events
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index d9afa239aa2..1388b6f37f4 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -4496,11 +4496,16 @@ export const $DagRunAssetReference = {
}
],
title: 'Partition Key'
+ },
+ triggering: {
+ type: 'boolean',
+ title: 'Triggering',
+ description: "Whether this asset event triggered the referenced
dag run. Only a run's most recent consumed asset event triggers it; earlier
consumed events are included in the run but did not trigger it."
}
},
additionalProperties: false,
type: 'object',
- required: ['run_id', 'dag_id', 'logical_date', 'start_date', 'end_date',
'state', 'data_interval_start', 'data_interval_end', 'partition_key'],
+ required: ['run_id', 'dag_id', 'logical_date', 'start_date', 'end_date',
'state', 'data_interval_start', 'data_interval_end', 'partition_key',
'triggering'],
title: 'DagRunAssetReference',
description: 'DagRun serializer for asset responses.'
} as const;
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 cf8c86e997c..8136b7c0d56 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
@@ -1156,6 +1156,10 @@ export type DagRunAssetReference = {
data_interval_start: string | null;
data_interval_end: string | null;
partition_key: string | null;
+ /**
+ * Whether this asset event triggered the referenced dag run. Only a run's
most recent consumed asset event triggers it; earlier consumed events are
included in the run but did not trigger it.
+ */
+ triggering: boolean;
};
/**
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index dc3ab657d49..684a0a674de 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -151,6 +151,7 @@
"tooltip": "Press {{hotkey}} for fullscreen"
},
"generateToken": "Generate Token",
+ "includedIn": "Included in",
"key": "Key",
"logicalDate": "Logical Date",
"logout": "Logout",
diff --git
a/airflow-core/src/airflow/ui/src/components/Assets/TriggeredRuns.test.tsx
b/airflow-core/src/airflow/ui/src/components/Assets/TriggeredRuns.test.tsx
new file mode 100644
index 00000000000..b6e79146011
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Assets/TriggeredRuns.test.tsx
@@ -0,0 +1,86 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import type { DagRunAssetReference } from "openapi/requests/types.gen";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { TriggeredRuns } from "./TriggeredRuns";
+
+const makeRun = (overrides: Partial<DagRunAssetReference>):
DagRunAssetReference =>
+ ({
+ dag_id: "dag_1",
+ data_interval_end: null,
+ data_interval_start: null,
+ end_date: null,
+ logical_date: null,
+ partition_key: null,
+ run_id: "run_1",
+ start_date: "2025-01-01T00:00:00Z",
+ state: "success",
+ triggering: true,
+ ...overrides,
+ }) satisfies DagRunAssetReference;
+
+describe("TriggeredRuns", () => {
+ it("labels a triggering run as triggered", () => {
+ render(<TriggeredRuns dagRuns={[makeRun({ triggering: true })]} />, {
wrapper: Wrapper });
+
+ expect(screen.getByText(/triggered dagRun_one/u)).toBeInTheDocument();
+ expect(screen.queryByText(/includedIn/u)).not.toBeInTheDocument();
+ });
+
+ it("labels a non-triggering run as included", () => {
+ render(<TriggeredRuns dagRuns={[makeRun({ triggering: false })]} />, {
wrapper: Wrapper });
+
+ expect(screen.getByText(/includedIn dagRun_one/u)).toBeInTheDocument();
+ expect(screen.queryByText(/triggered dagRun/u)).not.toBeInTheDocument();
+ });
+
+ it("splits a mix of triggering and included runs into separate labels", ()
=> {
+ render(
+ <TriggeredRuns
+ dagRuns={[
+ makeRun({ dag_id: "dag_triggered", run_id: "r1", triggering: true }),
+ makeRun({ dag_id: "dag_included", run_id: "r2", triggering: false }),
+ ]}
+ />,
+ { wrapper: Wrapper },
+ );
+
+ expect(screen.getByText(/triggered dagRun_one/u)).toBeInTheDocument();
+ expect(screen.getByText(/includedIn dagRun_one/u)).toBeInTheDocument();
+ });
+
+ it("groups multiple included runs behind a single count label", () => {
+ render(
+ <TriggeredRuns
+ dagRuns={[
+ makeRun({ dag_id: "dag_a", run_id: "r1", triggering: false }),
+ makeRun({ dag_id: "dag_b", run_id: "r2", triggering: false }),
+ ]}
+ />,
+ { wrapper: Wrapper },
+ );
+
+ expect(screen.getByText(/2 includedIn dagRun_other/u)).toBeInTheDocument();
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/Assets/TriggeredRuns.tsx
b/airflow-core/src/airflow/ui/src/components/Assets/TriggeredRuns.tsx
index c5aab95ad57..6f7499791f2 100644
--- a/airflow-core/src/airflow/ui/src/components/Assets/TriggeredRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Assets/TriggeredRuns.tsx
@@ -28,16 +28,18 @@ type Props = {
readonly dagRuns?: Array<DagRunAssetReference>;
};
-export const TriggeredRuns = ({ dagRuns }: Props) => {
+const DagRunGroup = ({
+ dagRuns,
+ prefix,
+}: {
+ readonly dagRuns: Array<DagRunAssetReference>;
+ readonly prefix: string;
+}) => {
const { t: translate } = useTranslation("common");
- if (dagRuns === undefined || dagRuns.length === 0) {
- return undefined;
- }
-
return dagRuns.length === 1 ? (
<Flex flexWrap="wrap" gap={1}>
- <Text flexShrink={0}>{`${translate("triggered")}
${translate("dagRun_one")}`}: </Text>
+ <Text flexShrink={0}>{`${prefix} ${translate("dagRun_one")}`}: </Text>
<StateBadge state={dagRuns[0]?.state as DagRunState} />
<RouterLink overflowWrap="anywhere"
to={`/dags/${dagRuns[0]?.dag_id}/runs/${dagRuns[0]?.run_id}`}>
{dagRuns[0]?.dag_id}
@@ -48,14 +50,14 @@ export const TriggeredRuns = ({ dagRuns }: Props) => {
<Popover.Root autoFocus={false} lazyMount unmountOnExit>
<Popover.Trigger asChild>
<Button variant="outline">
- {`${dagRuns.length} ${translate("triggered")}
${translate("dagRun_other", { count: dagRuns.length })}`}
+ {`${dagRuns.length} ${prefix} ${translate("dagRun_other", { count:
dagRuns.length })}`}
</Button>
</Popover.Trigger>
<Popover.Content css={{ "--popover-bg": "colors.bg.emphasized" }}
width="fit-content">
<Popover.Arrow />
<Popover.Body>
{dagRuns.map((dagRun) => (
- <Flex gap={1} key={dagRun.dag_id} my={2}>
+ <Flex gap={1} key={`${dagRun.dag_id}-${dagRun.run_id}`} my={2}>
<StateBadge state={dagRun.state as DagRunState} />
<RouterLink
to={`/dags/${dagRun.dag_id}/runs/${dagRun.run_id}`}>{dagRun.dag_id}</RouterLink>
</Flex>
@@ -65,3 +67,27 @@ export const TriggeredRuns = ({ dagRuns }: Props) => {
</Popover.Root>
);
};
+
+export const TriggeredRuns = ({ dagRuns }: Props) => {
+ const { t: translate } = useTranslation("common");
+
+ if (dagRuns === undefined || dagRuns.length === 0) {
+ return undefined;
+ }
+
+ // An asset event is linked to every run that consumed it, but only a run's
most recent consumed
+ // event triggered it (backend flag). The rest were merely included, so
label them accordingly.
+ const triggeredRuns = dagRuns.filter((dagRun) => dagRun.triggering);
+ const includedRuns = dagRuns.filter((dagRun) => !dagRun.triggering);
+
+ return (
+ <Flex direction="column" gap={1}>
+ {triggeredRuns.length > 0 ? (
+ <DagRunGroup dagRuns={triggeredRuns} prefix={translate("triggered")} />
+ ) : undefined}
+ {includedRuns.length > 0 ? (
+ <DagRunGroup dagRuns={includedRuns} prefix={translate("includedIn")} />
+ ) : undefined}
+ </Flex>
+ );
+};
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
index 462d1605af0..80c482ca245 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
@@ -832,7 +832,7 @@ class TestGetAssetEvents(TestAssets):
session.commit()
assert len(assets) == 2
- with assert_queries_count(3):
+ with assert_queries_count(4):
response = test_client.get("/assets/events")
assert response.status_code == 200
@@ -861,6 +861,7 @@ class TestGetAssetEvents(TestAssets):
"data_interval_start":
from_datetime_to_zulu_without_ms(DEFAULT_DATE),
"data_interval_end":
from_datetime_to_zulu_without_ms(DEFAULT_DATE),
"partition_key": None,
+ "triggering": True,
}
],
"timestamp":
from_datetime_to_zulu_without_ms(DEFAULT_DATE),
@@ -890,6 +891,7 @@ class TestGetAssetEvents(TestAssets):
"data_interval_start":
from_datetime_to_zulu_without_ms(DEFAULT_DATE),
"data_interval_end":
from_datetime_to_zulu_without_ms(DEFAULT_DATE),
"partition_key": None,
+ "triggering": True,
}
],
"timestamp":
from_datetime_to_zulu_without_ms(DEFAULT_DATE),
@@ -899,6 +901,34 @@ class TestGetAssetEvents(TestAssets):
"total_entries": 2,
}
+ def test_only_most_recent_consumed_event_is_flagged_as_triggering(self,
test_client, session):
+ """A run consuming several events marks only its most recent consumed
event as triggering it."""
+ self.create_assets(num=1)
+ older_event = AssetEvent(id=1, asset_id=1, extra={},
timestamp=DEFAULT_DATE)
+ newer_event = AssetEvent(id=2, asset_id=1, extra={},
timestamp=DEFAULT_DATE + timedelta(days=1))
+ session.add_all([older_event, newer_event])
+ dag_run = DagRun(
+ dag_id="source_dag_id",
+ run_id="run_1",
+ run_type=DagRunType.MANUAL,
+ logical_date=DEFAULT_DATE + timedelta(days=1),
+ start_date=DEFAULT_DATE,
+ data_interval=(DEFAULT_DATE, DEFAULT_DATE),
+ state=DagRunState.SUCCESS,
+ )
+ dag_run.end_date = DEFAULT_DATE
+ session.add(dag_run)
+ session.flush()
+ dag_run.consumed_asset_events.extend([older_event, newer_event])
+ session.commit()
+
+ response = test_client.get("/assets/events")
+
+ assert response.status_code == 200
+ events = {event["id"]: event for event in
response.json()["asset_events"]}
+ assert events[1]["created_dagruns"][0]["triggering"] is False
+ assert events[2]["created_dagruns"][0]["triggering"] is True
+
def test_should_respond_401(self, unauthenticated_test_client):
response = unauthenticated_test_client.get("/assets/events")
assert response.status_code == 401
@@ -1049,6 +1079,7 @@ class TestGetAssetEvents(TestAssets):
"data_interval_start":
from_datetime_to_zulu_without_ms(DEFAULT_DATE),
"data_interval_end":
from_datetime_to_zulu_without_ms(DEFAULT_DATE),
"partition_key": None,
+ "triggering": True,
}
],
"timestamp":
from_datetime_to_zulu_without_ms(DEFAULT_DATE),
@@ -1078,6 +1109,7 @@ class TestGetAssetEvents(TestAssets):
"data_interval_start":
from_datetime_to_zulu_without_ms(DEFAULT_DATE),
"data_interval_end":
from_datetime_to_zulu_without_ms(DEFAULT_DATE),
"partition_key": None,
+ "triggering": True,
}
],
"timestamp":
from_datetime_to_zulu_without_ms(DEFAULT_DATE),
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
index d574fc58637..9d27ddf2817 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
@@ -2013,7 +2013,7 @@ class TestGetDagRunAssetTriggerEvents:
session.commit()
assert event.timestamp
- with assert_queries_count(3):
+ with assert_queries_count(4):
response = test_client.get(
"/dags/TEST_DAG_ID/dagRuns/TEST_DAG_RUN_ID/upstreamAssetEvents",
)
@@ -2043,6 +2043,7 @@ class TestGetDagRunAssetTriggerEvents:
"start_date":
from_datetime_to_zulu_without_ms(dr.start_date),
"state": "running",
"partition_key": partition_key,
+ "triggering": True,
}
],
"partition_key": partition_key,
@@ -2052,6 +2053,58 @@ class TestGetDagRunAssetTriggerEvents:
}
assert response.json() == expected_response
+ @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+ @pytest.mark.parametrize("num_events", [1, 5])
+ def test_query_count_does_not_scale_with_consumed_events(
+ self, num_events, test_client, dag_maker, session
+ ):
+ """A run consuming N asset events must not issue a query per event
(guard against N+1)."""
+ asset1 = Asset(name="ds1", uri="file:///da1")
+ with dag_maker(
+ dag_id="source_dag", start_date=START_DATE1,
schedule=timedelta(days=1), session=session
+ ):
+ EmptyOperator(task_id="task", outlets=[asset1])
+ source_run = dag_maker.create_dagrun()
+ ti = source_run.task_instances[0]
+ asset1_id = session.scalar(select(AssetModel.id).where(AssetModel.uri
== asset1.uri))
+
+ events = [
+ AssetEvent(
+ asset_id=asset1_id,
+ source_task_id=ti.task_id,
+ source_dag_id=ti.dag_id,
+ source_run_id=ti.run_id,
+ source_map_index=ti.map_index,
+ timestamp=START_DATE1 + timedelta(minutes=index),
+ )
+ for index in range(num_events)
+ ]
+ session.add_all(events)
+
+ with dag_maker(
+ dag_id="TEST_DAG_ID", start_date=START_DATE1,
schedule=timedelta(days=1), session=session
+ ):
+ pass
+ consuming_run = dag_maker.create_dagrun(run_id="TEST_DAG_RUN_ID",
run_type=DagRunType.ASSET_TRIGGERED)
+ for event in events:
+ consuming_run.consumed_asset_events.append(event)
+ session.commit()
+
+ # Constant regardless of the number of consumed events (parametrized 1
vs 5).
+ with assert_queries_count(4):
+ response =
test_client.get("/dags/TEST_DAG_ID/dagRuns/TEST_DAG_RUN_ID/upstreamAssetEvents")
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["total_entries"] == num_events
+ # Only the run's most recent consumed event triggered it; the rest
were merely included.
+ triggering_by_event = {
+ event["id"]: event["created_dagruns"][0]["triggering"] for event
in payload["asset_events"]
+ }
+ newest_event_id = max(event.id for event in events)
+ assert triggering_by_event[newest_event_id] is True
+ assert all(value is False for eid, value in
triggering_by_event.items() if eid != newest_event_id)
+
def test_should_respond_401(self, unauthenticated_test_client):
response = unauthenticated_test_client.get(
"/dags/TEST_DAG_ID/dagRuns/TEST_DAG_RUN_ID/upstreamAssetEvents",
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 3e9681b4c18..9f464e8bda3 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -533,6 +533,13 @@ class DagRunAssetReference(BaseModel):
data_interval_start: Annotated[datetime | None, Field(title="Data Interval
Start")]
data_interval_end: Annotated[datetime | None, Field(title="Data Interval
End")]
partition_key: Annotated[str | None, Field(title="Partition Key")]
+ triggering: Annotated[
+ bool,
+ Field(
+ description="Whether this asset event triggered the referenced dag
run. Only a run's most recent consumed asset event triggers it; earlier
consumed events are included in the run but did not trigger it.",
+ title="Triggering",
+ ),
+ ]
class DagRunMutableStates(str, Enum):
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index 52fa594afd5..aaad996ec99 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -370,6 +370,7 @@ class TestAssetsOperations:
data_interval_start=datetime.datetime(2025, 1, 1, 0, 0, 0),
data_interval_end=datetime.datetime(2025, 1, 1, 0, 0, 0),
partition_key=None,
+ triggering=True,
)
asset_event_response = AssetEventResponse(