This is an automated email from the ASF dual-hosted git repository.
hussein-awala 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 1b792598b6d Support native asset partitions in watcher events (#74852)
1b792598b6d is described below
commit 1b792598b6d0baaf70985a062bddf3f8e3b058f5
Author: antonlin1 <[email protected]>
AuthorDate: Wed Nov 4 15:46:59 2026 -0500
Support native asset partitions in watcher events (#74852)
* Support native asset partitions in watcher events
Partition-aware Dags cannot consume native partition scopes from watcher
events when the key is retained only as payload metadata.
* Fix the partitioned asset event documentation heading
* Trim watcher partition publication documentation
* Scope watcher partition metadata to asset trigger events
Watchers need to identify native partitions without adding asset-specific
fields to task resumption and task-end events.
* Clarify why watcher events explicitly set their type
* Keep watcher partition guidance alongside trigger authoring
* Verify watcher persistence before shared-stream acknowledgement
* Keep partitioned asset event guidance concise
* Strengthen partitioned watcher event regression tests
* Test shared task and asset watcher events explicitly
* Fix partitioned watcher event documentation heading
---
.../docs/authoring-and-scheduling/assets.rst | 2 +
.../authoring-and-scheduling/event-scheduling.rst | 13 ++
airflow-core/src/airflow/models/trigger.py | 3 +-
airflow-core/src/airflow/triggers/base.py | 31 +++-
.../versions/head/test_asset_events.py | 49 +++++-
airflow-core/tests/unit/jobs/test_scheduler_job.py | 107 ++++++++++++
airflow-core/tests/unit/jobs/test_triggerer_job.py | 182 +++++++++++++++------
airflow-core/tests/unit/models/test_trigger.py | 53 ++++++
.../tests/unit/triggers/test_base_trigger.py | 15 +-
9 files changed, 398 insertions(+), 57 deletions(-)
diff --git a/airflow-core/docs/authoring-and-scheduling/assets.rst
b/airflow-core/docs/authoring-and-scheduling/assets.rst
index 4082e41ad70..f2a5d147bdf 100644
--- a/airflow-core/docs/authoring-and-scheduling/assets.rst
+++ b/airflow-core/docs/authoring-and-scheduling/assets.rst
@@ -538,6 +538,8 @@ depend on whether the producer and consumer have a team
association:
When Multi-Team mode is disabled, ``access_control`` is ignored and all asset
events are delivered to all
consuming Dags, preserving backward compatibility.
+.. _asset-partitions:
+
Asset partitions
----------------
diff --git a/airflow-core/docs/authoring-and-scheduling/event-scheduling.rst
b/airflow-core/docs/authoring-and-scheduling/event-scheduling.rst
index ba9967ad1e4..4e3672c0e8d 100644
--- a/airflow-core/docs/authoring-and-scheduling/event-scheduling.rst
+++ b/airflow-core/docs/authoring-and-scheduling/event-scheduling.rst
@@ -64,6 +64,19 @@ event-driven scheduling, then a new trigger must be created.
This new trigger must inherit ``BaseEventTrigger`` and ensure it properly
works with event-driven scheduling.
It might inherit from the existing trigger as well if both triggers share some
common code.
+Publishing partitioned asset events
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To publish an update for a specific asset partition, yield
``AssetTriggerEvent`` with a ``partition_key``::
+
+ from airflow.triggers.base import AssetTriggerEvent
+
+ yield AssetTriggerEvent({"changed": True}, partition_key="2026-10-16")
+
+Airflow uses this key for partition-aware scheduling.
+
+See :ref:`asset-partitions` for configuring downstream consumers.
+
Sharing one poll across sibling triggers
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/airflow-core/src/airflow/models/trigger.py
b/airflow-core/src/airflow/models/trigger.py
index 43ad7b815b3..de6467a5851 100644
--- a/airflow-core/src/airflow/models/trigger.py
+++ b/airflow-core/src/airflow/models/trigger.py
@@ -50,7 +50,7 @@ from airflow.models.asset import AssetWatcherModel
from airflow.models.base import Base, StringID
from airflow.models.taskinstance import TaskInstance
from airflow.serialization.enums import stringify_encoding_keys
-from airflow.triggers.base import BaseTaskEndEvent
+from airflow.triggers.base import AssetTriggerEvent, BaseTaskEndEvent
from airflow.utils.session import NEW_SESSION, provide_session
from airflow.utils.sqlalchemy import UtcDateTime, with_row_locks
from airflow.utils.state import TaskInstanceState
@@ -399,6 +399,7 @@ class Trigger(Base):
AssetManager.register_asset_change(
asset=asset.to_serialized(),
extra={"from_trigger": True, "payload": event.payload},
+ partition_key=event.partition_key if isinstance(event,
AssetTriggerEvent) else None,
session=session,
)
if trigger.callback:
diff --git a/airflow-core/src/airflow/triggers/base.py
b/airflow-core/src/airflow/triggers/base.py
index 01b0e04522f..9e7615c2b98 100644
--- a/airflow-core/src/airflow/triggers/base.py
+++ b/airflow-core/src/airflow/triggers/base.py
@@ -21,13 +21,14 @@ import json
from collections.abc import AsyncIterator, Hashable
from dataclasses import dataclass
from datetime import timedelta
-from typing import TYPE_CHECKING, Annotated, Any
+from typing import TYPE_CHECKING, Annotated, Any, Literal
import structlog
from pydantic import (
BaseModel,
Discriminator,
JsonValue,
+ StringConstraints,
Tag,
model_serializer,
)
@@ -469,6 +470,29 @@ class TriggerEvent(BaseModel):
return f"TriggerEvent<{self.payload!r}>"
+class AssetTriggerEvent(TriggerEvent):
+ """
+ An asset update emitted by a watcher, optionally identifying a native
partition.
+
+ The partition key applies to every asset associated with the trigger. The
payload
+ remains available in each asset event's extras and to any deferred task
resumed
+ by the same trigger.
+ """
+
+ type: Literal["AssetTriggerEvent"] = "AssetTriggerEvent"
+ partition_key: Annotated[str, StringConstraints(pattern=r"\S",
max_length=250)] | None = None
+
+ def __init__(self, payload, *, partition_key: str | None = None, **kwargs):
+ # Explicitly set type so serialization includes it. The triggerer
supervisor
+ # needs it to reconstruct AssetTriggerEvent and preserve the partition
key.
+ super().__init__(
+ payload=payload,
+ type=kwargs.pop("type", "AssetTriggerEvent"),
+ partition_key=partition_key,
+ **kwargs,
+ )
+
+
class BaseTaskEndEvent(TriggerEvent):
"""
Base event class to end the task without resuming on worker.
@@ -521,13 +545,18 @@ class TaskSkippedEvent(BaseTaskEndEvent):
def trigger_event_discriminator(v):
if isinstance(v, dict):
+ if v.get("type") == "AssetTriggerEvent":
+ return "_asset_"
return v.get("task_instance_state", "_event_")
+ if isinstance(v, AssetTriggerEvent):
+ return "_asset_"
if isinstance(v, TriggerEvent):
return getattr(v, "task_instance_state", "_event_")
DiscrimatedTriggerEvent = Annotated[
Annotated[TriggerEvent, Tag("_event_")]
+ | Annotated[AssetTriggerEvent, Tag("_asset_")]
| Annotated[TaskSuccessEvent, Tag(TaskInstanceState.SUCCESS)]
| Annotated[TaskFailedEvent, Tag(TaskInstanceState.FAILED)]
| Annotated[TaskSkippedEvent, Tag(TaskInstanceState.SKIPPED)],
diff --git
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_events.py
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_events.py
index 8cf59a440c5..76dd42b8866 100644
---
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_events.py
+++
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_events.py
@@ -19,11 +19,16 @@ from __future__ import annotations
from datetime import datetime
+import httpx2
import pytest
+from sqlalchemy import delete
from airflow._shared.timezones import timezone
-from airflow.models.asset import AssetActive, AssetAliasModel, AssetEvent,
AssetModel
+from airflow.models.asset import AssetActive, AssetAliasModel, AssetEvent,
AssetModel, AssetWatcherModel
from airflow.models.dagrun import DagRun
+from airflow.models.trigger import Trigger
+from airflow.sdk.api.client import Client as ExecutionAPIClient
+from airflow.triggers.base import AssetTriggerEvent
from airflow.utils.state import DagRunState
from airflow.utils.types import DagRunType
@@ -96,6 +101,48 @@ def test_asset_alias(session, test_asset_events,
test_asset):
class TestGetAssetEventByAsset:
+ @pytest.mark.parametrize("partition_key", [None, "2026-10-16"])
+ def test_get_watcher_event_through_task_sdk(self, client, session,
test_asset, partition_key, request):
+ trigger = Trigger(classpath="airflow.triggers.testing.SuccessTrigger",
kwargs={})
+ session.add(trigger)
+ session.flush()
+
+ def cleanup():
+ session.execute(delete(AssetEvent).where(AssetEvent.asset_id ==
test_asset.id))
+
session.execute(delete(AssetWatcherModel).where(AssetWatcherModel.trigger_id ==
trigger.id))
+ session.execute(delete(Trigger).where(Trigger.id == trigger.id))
+ session.commit()
+
+ request.addfinalizer(cleanup)
+ test_asset.add_trigger(trigger, "partition_watcher")
+ session.flush()
+ payload = {"partition_key": "payload-only", "changed": True}
+ event = AssetTriggerEvent(payload, partition_key=partition_key)
+ Trigger.submit_event(trigger.id, event, session=session)
+ session.commit()
+ session.expire_all()
+
+ def read_event(request: httpx2.Request) -> httpx2.Response:
+ response = client.get(
+ request.url.path,
+ params=list(request.url.params.multi_items()),
+ headers=dict(request.headers),
+ )
+ return httpx2.Response(response.status_code,
headers=response.headers, content=response.content)
+
+ with ExecutionAPIClient(
+ base_url="http://testserver/execution/",
+ token="fake",
+ transport=httpx2.MockTransport(read_event),
+ ) as sdk_client:
+ result = sdk_client.asset_events.get(name=test_asset.name)
+
+ assert len(result.asset_events) == 1
+ returned_event = result.asset_events[0]
+ assert returned_event.partition_key == partition_key
+ assert returned_event.extra == {"from_trigger": True, "payload":
payload}
+ assert isinstance(returned_event.timestamp, datetime)
+
@pytest.mark.usefixtures("test_asset")
def test_get_by_asset_with_created_dagrun_without_start_date(self, client,
session, test_asset_events):
created_dagrun = DagRun(
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 0b6bd0fee54..c35b62287d6 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -162,6 +162,7 @@ from airflow.timetables.simple import (
PartitionedAssetTimetable as CorePartitionedAssetTimetable,
PartitionedAtRuntime,
)
+from airflow.triggers.base import AssetTriggerEvent, TriggerEvent
from airflow.utils.session import NEW_SESSION, create_session, provide_session
from airflow.utils.sqlalchemy import CommitProhibitorGuard, with_row_locks
from airflow.utils.state import CallbackState, DagRunState,
DagSchedulingState, State, TaskInstanceState
@@ -14345,6 +14346,112 @@ def _produce_and_register_asset_event(
return apdr
[email protected]_serialized_dag
[email protected]("clear_asset_partition_rows")
[email protected](
+ ("mapper", "expected_partition_key", "expected_partition_date"),
+ [
+ pytest.param(IdentityMapper(), "2026-10-16T12:00:00", None,
id="identity"),
+ pytest.param(StartOfDayMapper(), "2026-10-16", pendulum.datetime(2026,
10, 16, tz="UTC"), id="daily"),
+ ],
+)
+# Payload fields resemble native event fields to prove metadata cannot select
the event type or partition.
[email protected](
+ (
+ "event",
+ "expected_native_key",
+ "expected_ordinary_candidate",
+ "expected_created_dag_ids",
+ "expected_run_count",
+ ),
+ [
+ pytest.param(
+ TriggerEvent({"type": "AssetTriggerEvent", "partition_key":
"payload-only"}),
+ None,
+ True,
+ set(),
+ 0,
+ id="ordinary",
+ ),
+ pytest.param(
+ AssetTriggerEvent({"type": "AssetTriggerEvent", "partition_key":
"payload-only"}),
+ None,
+ True,
+ set(),
+ 0,
+ id="asset-without-partition",
+ ),
+ pytest.param(
+ AssetTriggerEvent(
+ {"type": "AssetTriggerEvent", "partition_key": "payload-only"},
+ partition_key="2026-10-16T12:00:00",
+ ),
+ "2026-10-16T12:00:00",
+ False,
+ {"watcher-partitioned-consumer"},
+ 1,
+ id="partitioned",
+ ),
+ ],
+)
+def test_watcher_event_creates_partitioned_consumer_run(
+ dag_maker,
+ session,
+ mapper,
+ expected_partition_key,
+ expected_partition_date,
+ event,
+ expected_native_key,
+ expected_ordinary_candidate,
+ expected_created_dag_ids,
+ expected_run_count,
+):
+ asset = Asset(name="watcher-partitioned-asset")
+ dag_id = "watcher-partitioned-consumer"
+ with dag_maker(
+ dag_id=dag_id,
+ schedule=PartitionedAssetTimetable(
+ assets=asset,
+ default_partition_mapper=mapper,
+ ),
+ session=session,
+ ):
+ EmptyOperator(task_id="consume")
+ ordinary_dag_id = "watcher-ordinary-consumer"
+ with dag_maker(dag_id=ordinary_dag_id, schedule=asset, session=session):
+ EmptyOperator(task_id="consume")
+ session.commit()
+
+ trigger = Trigger(classpath="airflow.triggers.testing.SuccessTrigger",
kwargs={})
+ session.add(trigger)
+ session.flush()
+ asset_model = session.scalar(select(AssetModel).where(AssetModel.name ==
asset.name))
+ asset_model.add_trigger(trigger, "partition-watcher")
+ session.flush()
+ Trigger.submit_event(trigger.id, event, session=session)
+ session.flush()
+
+ asset_event = session.scalar(select(AssetEvent).where(AssetEvent.asset_id
== asset_model.id))
+ assert asset_event.partition_key == expected_native_key
+ assert asset_event.extra == {"from_trigger": True, "payload":
event.payload}
+ ordinary_candidate = session.scalar(
+ select(AssetDagRunQueue).where(AssetDagRunQueue.target_dag_id ==
ordinary_dag_id)
+ )
+ assert (ordinary_candidate is not None) is expected_ordinary_candidate
+ runner = SchedulerJobRunner(
+ job=Job(job_type=SchedulerJobRunner.job_type),
executors=[MockExecutor(do_update=False)]
+ )
+ assert runner._create_dagruns_for_partitioned_asset_dags(session=session)
== expected_created_dag_ids
+ runs = session.scalars(select(DagRun).where(DagRun.dag_id == dag_id)).all()
+ assert len(runs) == expected_run_count
+ for run in runs:
+ assert run.partition_key == expected_partition_key
+ assert run.logical_date is None
+ assert run.state == DagRunState.QUEUED
+ assert [event.id for event in run.consumed_asset_events] ==
[asset_event.id]
+ assert run.partition_date == expected_partition_date
+
+
@pytest.mark.need_serialized_dag
@pytest.mark.usefixtures("clear_asset_partition_rows")
@pytest.mark.parametrize("batch_asset_events", [True, False])
diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py
b/airflow-core/tests/unit/jobs/test_triggerer_job.py
index a614b30cc78..e38fbe0343b 100644
--- a/airflow-core/tests/unit/jobs/test_triggerer_job.py
+++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py
@@ -76,7 +76,7 @@ from airflow.jobs.triggerer_job_runner import (
messages,
)
from airflow.models import Connection, DagModel, DagRun, Trigger, Variable
-from airflow.models.asset import AssetModel
+from airflow.models.asset import AssetEvent, AssetModel
from airflow.models.dag_version import DagVersion
from airflow.models.dagbag import DBDagBag
from airflow.models.dagbundle import DagBundleModel
@@ -112,12 +112,21 @@ from airflow.sdk.execution_time.comms import (
SetAssetStateStoreByUri,
ToSupervisor,
ToTask,
+ _new_encoder,
_RequestFrame,
_ResponseFrame,
)
from airflow.sdk.execution_time.context import AssetStateStoreAccessors
from airflow.serialization.serialized_objects import LazyDeserializedDAG
-from airflow.triggers.base import BaseEventTrigger, BaseTrigger, TriggerEvent
+from airflow.triggers.base import (
+ AssetTriggerEvent,
+ BaseEventTrigger,
+ BaseTrigger,
+ TaskFailedEvent,
+ TaskSkippedEvent,
+ TaskSuccessEvent,
+ TriggerEvent,
+)
from airflow.triggers.shared_stream import SharedStreamProducer
from airflow.triggers.testing import FailureTrigger, SuccessTrigger
from airflow.utils.state import State, TaskInstanceState
@@ -125,6 +134,7 @@ from airflow.utils.types import DagRunType
from tests_common.test_utils.config import conf_vars
from tests_common.test_utils.db import (
+ clear_db_assets,
clear_db_connections,
clear_db_dag_bundles,
clear_db_dags,
@@ -1837,11 +1847,25 @@ class TestTriggerRunner:
# Group is torn down on unsubscribe.
assert trigger_runner._shared_streams._groups == {}
- def test_shared_stream_ack_mode_integration(self) -> None:
- """A BaseEventTrigger whose producer factory is overridden advances
only after the event's derived trigger event is confirmed persisted."""
- advanced: list[Any] = []
- # Container lets _drive() write trigger_runner back for post-run
assertions.
- state: dict[str, Any] = {}
+ @pytest.mark.parametrize("partition_key", [None, "2026-10-16"])
+ @pytest.mark.parametrize("commit_fails", [False, True])
+ def test_shared_stream_ack_mode_integration(
+ self, session, request, jobless_supervisor, partition_key: str | None,
commit_fails: bool
+ ) -> None:
+ """Broker advancement requires successful commit of the event's native
partition key."""
+ trigger_model =
Trigger(classpath="airflow.triggers.testing.SuccessTrigger", kwargs={})
+ session.add(trigger_model)
+ session.flush()
+ asset = AssetModel("shared-stream-event")
+ asset.add_trigger(trigger_model, "shared-stream-watcher")
+ session.add(asset)
+ session.commit()
+ trigger_id, asset_id = trigger_model.id, asset.id
+ request.addfinalizer(clear_db_assets)
+
+ advanced: list[str] = []
+ advance_called = asyncio.Event()
+ event_queued = asyncio.Event()
class _AckSharedProducer(SharedStreamProducer):
async def open_stream(self):
@@ -1851,7 +1875,7 @@ class TestTriggerRunner:
async def advance(self, batch):
advanced.extend(item.broker_payload for item in batch)
- state["advance_called"].set()
+ advance_called.set()
class _AckSharedTrigger(BaseEventTrigger):
def __init__(self, queue_url: str, region: str | None = None):
@@ -1875,62 +1899,83 @@ class TestTriggerRunner:
async def filter_shared_stream(self, shared_stream):
async for raw in shared_stream:
if self.region is None or raw["region"] == self.region:
- yield TriggerEvent(raw)
+ if partition_key is None:
+ yield TriggerEvent(raw)
+ else:
+ yield AssetTriggerEvent(raw,
partition_key=partition_key)
+ event_queued.set()
async def run(self): # pragma: no cover - replaced by
filter_shared_stream
yield TriggerEvent({})
async def _drive():
- advance_called = asyncio.Event()
- state["advance_called"] = advance_called
-
trigger_runner = TriggerRunner()
trigger_runner.triggers = {
- 1: {"task": MagicMock(spec=asyncio.Task), "is_watcher": True,
"name": "us", "events": 0}
+ trigger_id: {
+ "task": MagicMock(spec=asyncio.Task),
+ "is_watcher": True,
+ "name": "us",
+ "events": 0,
+ }
}
trigger = _AckSharedTrigger(queue_url="https://ack-q", region="us")
trigger.task_instance = MagicMock()
trigger.task_instance.map_index = -1
- state["trigger_runner"] = trigger_runner
-
- run_task = asyncio.create_task(trigger_runner.run_trigger(1,
trigger))
- # Wait until the event lands on the outbound queue with its
- # persist-confirmation seq.
- for _ in range(100):
- if trigger_runner.events:
- break
- await asyncio.sleep(0.01)
- assert trigger_runner.events, "the trigger event must reach the
outbound queue"
- _trigger_id, _event, seq = trigger_runner.events[0]
- assert seq is not None, "a shared ack trigger's event must carry a
seq"
-
- # The subscriber moved past the event, but the advance is gated
- # on persistence: nothing may advance before the confirmation
arrives.
- assert advanced == [], "advance must not run before the persist
confirmation"
- # Simulate the supervisor confirming the persist on the next sync.
- trigger_runner._shared_streams.confirm_persisted([seq])
- await asyncio.wait_for(advance_called.wait(), timeout=2.0)
-
- run_task.cancel()
- with contextlib.suppress(asyncio.CancelledError):
- await run_task
+ async def exchange(msg: messages.TriggerStateChanges) ->
messages.TriggerStateSync:
+ wire = _new_encoder().encode(msg.model_dump())
+ decoded =
TypeAdapter(ToTriggerSupervisor).validate_python(msgspec.msgpack.decode(wire))
+ with mock.patch.object(TriggerRunnerSupervisor, "send_msg",
autospec=True) as send_msg:
+ jobless_supervisor._handle_request(decoded,
log=jobless_supervisor.process_log, req_id=0)
+ response = send_msg.call_args.args[1]
+ wire = _new_encoder().encode(response.model_dump())
+ decoded_response =
TypeAdapter(ToTriggerRunner).validate_python(msgspec.msgpack.decode(wire))
+ assert isinstance(decoded_response, messages.TriggerStateSync)
+ return decoded_response
+
+ run_task =
asyncio.create_task(trigger_runner.run_trigger(trigger_id, trigger))
+ try:
+ await asyncio.wait_for(event_queued.wait(), timeout=2.0)
+ assert trigger_runner.events[0].persist_seq is not None
+ with mock.patch.object(trigger_runner, "asend", autospec=True,
side_effect=exchange):
+ await
trigger_runner.sync_state_to_supervisor(finished_ids=[])
+ assert advanced == []
+ if commit_fails:
+ with mock.patch(
+ "sqlalchemy.orm.Session.commit",
+ autospec=True,
+ side_effect=RuntimeError("commit failed"),
+ ):
+ with pytest.raises(RuntimeError, match="commit
failed"):
+ jobless_supervisor.handle_events()
+ else:
+ jobless_supervisor.handle_events()
+
+ asset_event =
session.scalar(select(AssetEvent).where(AssetEvent.asset_id == asset_id))
+ if commit_fails:
+ assert asset_event is None
+ assert list(jobless_supervisor.persisted_event_seqs)
== []
+ else:
+ assert asset_event is not None
+ assert asset_event.partition_key == partition_key
+ assert asset_event.extra == {"from_trigger": True,
"payload": {"region": "us"}}
+
+ assert advanced == []
+ await
trigger_runner.sync_state_to_supervisor(finished_ids=[])
+ if commit_fails:
+ assert advanced == []
+ assert not advance_called.is_set()
+ else:
+ await asyncio.wait_for(advance_called.wait(),
timeout=2.0)
+ finally:
+ run_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await run_task
+
+ assert trigger_runner._shared_streams._groups == {}
asyncio.run(_drive())
- trigger_runner = state["trigger_runner"]
-
- events = list(trigger_runner.events)
- assert len(events) == 1
- trigger_id, event, _seq = events[0]
- assert trigger_id == 1
- assert event.payload == {"region": "us"}
-
- # The producer's advance must have been called with the broker_payload.
- assert advanced == ["receipt-handle-1"], (
- "producer.advance must be called once the event's persistence is
confirmed"
- )
- # Group is torn down on unsubscribe.
- assert trigger_runner._shared_streams._groups == {}
+ assert advanced == ([] if commit_fails else ["receipt-handle-1"])
def test_run_trigger_on_kill_timeout_does_not_block_cleanup(self, session)
-> None:
"""A hanging on_kill() is interrupted after the timeout and cleanup
still runs."""
@@ -3719,10 +3764,41 @@ def test_trigger_state_messages_round_trip_with_seqs():
assert decoded_sync.to_cancel == {5}
-def test_handle_events_records_persist_confirmations(jobless_supervisor):
[email protected]("persist_seq", [None, 7])
[email protected]("wire_format", ["json", "msgpack"])
[email protected](
+ "event",
+ [
+ AssetTriggerEvent({"changed": True}, partition_key="2026-10-16"),
+ AssetTriggerEvent(True),
+ TriggerEvent({"type": "AssetTriggerEvent", "partition_key":
"payload-only"}),
+ TaskSuccessEvent(),
+ TaskFailedEvent(),
+ TaskSkippedEvent(),
+ ],
+)
+def test_asset_trigger_event_state_message_round_trip(persist_seq,
wire_format, event):
+ adapter = TypeAdapter(ToTriggerSupervisor)
+ changes = messages.TriggerStateChanges(type="TriggerStateChanges",
events=[(1, event, persist_seq)])
+
+ if wire_format == "json":
+ decoded = adapter.validate_json(adapter.dump_json(changes))
+ else:
+ decoded =
adapter.validate_python(msgspec.msgpack.decode(_new_encoder().encode(changes)))
+
+ assert isinstance(decoded, messages.TriggerStateChanges)
+ assert decoded.events is not None
+ assert decoded.events[0].event == event
+ assert type(decoded.events[0].event) is type(event)
+ assert decoded.events[0].event.payload == event.payload
+ assert decoded.events[0].persist_seq == persist_seq
+
+
[email protected]("event_class", [TriggerEvent, AssetTriggerEvent])
+def test_handle_events_records_persist_confirmations(jobless_supervisor,
event_class):
"""handle_events confirms the seq of a persisted event; events without a
seq are not confirmed."""
- event_with_seq = TriggerEvent(True)
- event_without_seq = TriggerEvent("x")
+ event_with_seq = event_class(True)
+ event_without_seq = event_class("x")
jobless_supervisor.events.append(TriggerEventEntry(1, event_with_seq, 7))
jobless_supervisor.events.append(TriggerEventEntry(2, event_without_seq,
None))
diff --git a/airflow-core/tests/unit/models/test_trigger.py
b/airflow-core/tests/unit/models/test_trigger.py
index de89888b9d8..5963f18fa62 100644
--- a/airflow-core/tests/unit/models/test_trigger.py
+++ b/airflow-core/tests/unit/models/test_trigger.py
@@ -45,6 +45,7 @@ from airflow.sdk.definitions.callback import AsyncCallback
from airflow.serialization.encoders import encode_trigger
from airflow.serialization.serialized_objects import BaseSerialization
from airflow.triggers.base import (
+ AssetTriggerEvent,
BaseEventTrigger,
BaseTrigger,
TaskFailedEvent,
@@ -455,6 +456,32 @@ def test_submit_event(mock_callback_handle_event, session,
create_task_instance)
mock_callback_handle_event.assert_called_once_with(event, session)
+def test_submit_asset_event_to_shared_task_and_asset_watcher(session,
create_task_instance):
+ trigger = Trigger(classpath="airflow.triggers.testing.SuccessTrigger",
kwargs={})
+ session.add(trigger)
+ session.flush()
+ task_instance = create_task_instance(
+ session=session, logical_date=timezone.datetime(2026, 10, 16),
state=State.DEFERRED
+ )
+ task_instance.trigger_id = trigger.id
+ task_instance.next_kwargs = {"resume_arg": "kept"}
+ asset = AssetModel("shared-trigger-asset")
+ asset.add_trigger(trigger, "shared-trigger-watcher")
+ session.add(asset)
+ session.commit()
+ payload = {"partition_key": "payload-only", "changed": True}
+
+ Trigger.submit_event(trigger.id, AssetTriggerEvent(payload,
partition_key="2026-10-16"), session=session)
+ session.flush()
+ session.refresh(task_instance)
+
+ assert task_instance.state == State.SCHEDULED
+ assert task_instance.next_kwargs == {"event": payload, "resume_arg":
"kept"}
+ asset_event = session.scalar(select(AssetEvent).where(AssetEvent.asset_id
== asset.id))
+ assert asset_event.partition_key == "2026-10-16"
+ assert asset_event.extra == {"from_trigger": True, "payload": payload}
+
+
@pytest.mark.parametrize("owner", ["task", "asset_watcher", "callback",
"none"])
def test_submit_event_records_the_event_time_for_every_kind(session,
create_task_instance, owner):
t0 = timezone.datetime(2026, 10, 21, 12, 0, 0)
@@ -482,6 +509,32 @@ def
test_submit_event_records_the_event_time_for_every_kind(session, create_task
assert session.get(Trigger, trigger.id).last_event_at == t0
[email protected]("partition_key", [None, "2026-10-16", " region-west
", "x" * 250])
+def test_submit_event_preserves_native_asset_partition(session, partition_key):
+ trigger = Trigger(classpath="airflow.triggers.testing.SuccessTrigger",
kwargs={})
+ session.add(trigger)
+ session.flush()
+ assets = [AssetModel("partitioned-first"),
AssetModel("partitioned-second")]
+ for asset in assets:
+ asset.add_trigger(trigger, f"partition_watcher_{asset.name}")
+ session.add_all(assets)
+ session.flush()
+ payload = {"partition_key": "payload-only", "changed": True}
+
+ Trigger.submit_event(trigger.id, AssetTriggerEvent(payload,
partition_key=partition_key), session=session)
+ session.flush()
+ session.expire_all()
+
+ asset_events = session.scalars(
+ select(AssetEvent).where(AssetEvent.asset_id.in_([a.id for a in
assets]))
+ ).all()
+ assert len(asset_events) == 2
+ assert {event.asset_id for event in asset_events} == {asset.id for asset
in assets}
+ for asset_event in asset_events:
+ assert asset_event.partition_key == partition_key
+ assert asset_event.extra == {"from_trigger": True, "payload": payload}
+
+
@pytest.mark.parametrize(("asset_count", "expected_query_count"), [(1, 6), (5,
6)])
@patch("airflow.models.trigger.AssetManager.register_asset_change")
def test_submit_event_no_n_plus_one_for_assets(_, session, asset_count,
expected_query_count):
diff --git a/airflow-core/tests/unit/triggers/test_base_trigger.py
b/airflow-core/tests/unit/triggers/test_base_trigger.py
index f65d56517b9..c30a21408d9 100644
--- a/airflow-core/tests/unit/triggers/test_base_trigger.py
+++ b/airflow-core/tests/unit/triggers/test_base_trigger.py
@@ -20,10 +20,23 @@ from __future__ import annotations
from unittest.mock import create_autospec
import pytest
+from pydantic import ValidationError
from airflow.sdk.bases.operator import BaseOperator
from airflow.sdk.execution_time.context import AssetStateStoreAccessors
-from airflow.triggers.base import BaseEventTrigger, BaseTrigger,
StartTriggerArgs, TriggerEvent
+from airflow.triggers.base import (
+ AssetTriggerEvent,
+ BaseEventTrigger,
+ BaseTrigger,
+ StartTriggerArgs,
+ TriggerEvent,
+)
+
+
[email protected]("partition_key", ["", " \t\n", "x" * 251, 42])
+def test_asset_trigger_event_rejects_invalid_partition_key(partition_key):
+ with pytest.raises(ValidationError, match="partition_key"):
+ AssetTriggerEvent({"changed": True}, partition_key=partition_key)
class DummyOperator(BaseOperator):