AssetEventSensor¶
Use the AssetEventSensor to wait for
asset events matching a set of filters to reach an expected count.
Use this sensor when an already-running Dag needs an asset between tasks. For example, a daily consumer can run its preparation tasks immediately, then wait for another Dag to produce the partition for its own data interval. Moving that dependency to an asset schedule would change when the entire Dag starts and the data interval used by the consumer.
The sensor declares its target as an inlet, so the dependency is visible in asset lineage
without changing the Dag’s schedule. It reads events through context["inlet_events"].
Note
This sensor requires Apache Airflow 3.2+. The partition_key,
partition_key_regexp_pattern and extra filters require
Apache Airflow 3.4+.
When to use a schedule instead¶
If the dependency does not need to sit in the middle of a run, a schedule is usually a better fit. See Asset-Aware Scheduling for details.
Schedule the Dag on the asset when the whole run can wait for it.
Use
AssetAndTimeSchedule(Airflow 3.4+) when a time-scheduled run needs the asset before it starts.Use a partitioned asset timetable with
RollupMapperto wait for every partition in a window.
Use this sensor when some tasks should run immediately and only later tasks
depend on the asset. Here, prepare starts as soon as the run does, and only
process waits for the partition (wait_for_partition_events is defined
below):
prepare = EmptyOperator(task_id="prepare")
process = EmptyOperator(task_id="process")
prepare >> wait_for_partition_events >> process
Basic usage¶
Pass an Asset, ref(), or
AssetAlias as asset. A reference must resolve to an
existing asset when the task starts. The target is static so Airflow can record
the inlet; use templated event filters to select a run’s events. By default the
sensor succeeds once at least one matching event exists.
Supply at least one query filter: after, before, partition_key,
partition_key_regexp_pattern, or a non-empty extra dictionary. The sensor
raises ValueError at construction if no filter is supplied. It checks again
after template rendering, before querying events. An empty extra={} does
not count as a filter.
Time bounds are optional when another query filter is supplied. Ordering,
limit, and process_result do not replace query filters.
This daily example waits for events at or after the data interval ends:
wait_for_asset_event = AssetEventSensor(
task_id="wait_for_asset_event",
asset=my_asset,
after=DataIntervalEnd(),
)
A mapped sensor works, but its asset is only recorded as an inlet in lineage if
it is also passed through partial(inlets=[...]).
Run-relative time bounds¶
The after and before arguments accept datetime values, ISO strings,
Jinja templates, and these bound objects from
airflow.providers.standard.sensors.asset:
Bound |
Available on |
When tasks are cleared |
Watch out for |
|---|---|---|---|
|
All runs, including asset-triggered runs and backfills. |
A finished run gets a new start time when it starts again. Clearing tasks in a queued or running run preserves the existing start time. |
Uses |
|
All runs, including asset-triggered runs and backfills. |
Stays fixed for the same run. |
Uses |
|
Runs with a logical date, including manual runs that supply one. Asset-triggered runs have none. |
Stays fixed for the same run. |
Its meaning depends on the timetable or the value supplied for a manual run. It does not necessarily represent when execution started. |
|
Runs with a data interval. Availability depends on the timetable and run type. Asset-triggered runs have none. |
Stay fixed for the same run. |
Use the interval represented by the run for historical work.
|
Use after=RunStart() when clearing a finished run should wait for new data.
Use after=RunAfter() to keep the same time boundary when an asset-triggered
run is cleared. Runs with logical dates or data intervals can also use those
fixed bounds. An open-ended query can still include new events that arrive
later, so fixed bounds do not freeze the result set.
Each built-in object accepts a signed offset duration, with zero as the
default. A negative offset moves the bound earlier. A positive offset moves it
later. For example, after=DataIntervalEnd(offset=-timedelta(hours=1)) includes
events from one hour before the interval ends onward.
before=DataIntervalEnd(offset=timedelta(hours=1)) includes events up to one
hour after the interval ends. Import timedelta from datetime to use these
examples.
To exclude events exactly at dag_run.run_after, use
after=RunAfter(offset=timedelta(microseconds=1)). Later events can still match,
including events seen during an earlier execution of the run.
For example, use both interval bounds to select a historical window:
from airflow.providers.standard.sensors.asset import (
AssetEventSensor,
DataIntervalEnd,
DataIntervalStart,
)
wait_for_interval_events = AssetEventSensor(
task_id="wait_for_interval_events",
asset=my_asset,
after=DataIntervalStart(),
before=DataIntervalEnd(),
)
Both bounds are inclusive and filter the asset event’s timestamp. An event at an interval boundary can therefore match two adjacent intervals. These bounds do not filter the event’s partition key.
To create a nonzero window with CronTriggerTimetable, use an offset or
configure the timetable’s interval.
A bound raises ValueError if its selected timestamp is missing; it does not
substitute another timestamp. Manual runs can have a data interval when a logical
date is supplied. For asset-triggered runs, use RunStart() or RunAfter()
instead. See the table for how they differ when a run is cleared.
Airflow resolves the objects during template rendering, before polling. Their run-relative values stay fixed while the sensor polls and after rescheduling.
Custom resolvers¶
Pass any object with a resolve(context) method that returns a datetime or
ISO string. AssetEventTimeBound
defines this interface. Inheritance is optional.
For stable Dag serialization, give custom objects a deterministic __repr__
that includes their configuration. Airflow currently uses this representation
for partial and expand arguments. Older versions, including Airflow 3.2.0,
also use it for unmapped template fields. The default object representation
contains a memory address and can create a new Dag version on each parse.
This example includes the parameter name in its representation:
from airflow.providers.standard.sensors.asset import AssetEventSensor, AssetEventTimeBound
class ParameterBound(AssetEventTimeBound):
def __init__(self, name):
self.name = name
def __repr__(self):
return f"ParameterBound(name={self.name!r})"
def resolve(self, context):
return context["params"][self.name]
wait_for_custom_events = AssetEventSensor.partial(
task_id="wait_for_custom_events",
asset=my_asset,
inlets=[my_asset],
after=ParameterBound("events_after"),
params={"events_after": "2024-01-01T00:00:00+00:00"},
).expand(limit=[1, 2])
Custom resolvers do not need serialize or deserialize methods. The
offset argument is a convenience provided by the built-in helpers.
Alternatively, Airflow 3.2+ supports a template callable that accepts the context and
jinja_env keyword arguments and returns a datetime or ISO string. Airflow
evaluates the callable during template rendering. Custom resolvers and callables
must return the same bound after rescheduling. A bound based on the current clock
can move the query window and exclude events that arrived between pokes.
Filtering events and expected count¶
Filter events with after, before (time range), partition_key,
partition_key_regexp_pattern, or extra (key/value pairs contained in
the event extra field). Prefer a partition key for the event’s partition
identity; extra can further filter metadata, such as a validation status.
Use ascending to set the order and limit to cap the number of matching
events returned.
partition_key_regexp_pattern requires a positive
[api] regexp_query_timeout.
With the default 0, regexp filtering is disabled, the query is rejected,
and the task fails.
Use expected_count and count_policy to control how many processed
events are required:
count_policy="minimum"(the default) succeeds at or aboveexpected_count, which defaults to one.count_policy="exact"succeeds only atexpected_count. Use this policy with zero to check that no matching events exist. An exact count can be missed if several events arrive between pokes.
Counts must be non-negative integers, and limit must be a positive integer
when supplied. With the minimum policy, zero always satisfies the count
condition.
If a process_result callback is not provided, the sensor raises ValueError
at construction if limit is less than expected_count with the minimum
policy, or not greater than expected_count with the exact policy.
wait_for_partition_events = AssetEventSensor(
task_id="wait_for_partition_events",
asset=my_asset,
partition_key="{{ data_interval_start | ds }}",
expected_count=3,
count_policy="minimum",
)
Post-processing results¶
Pass a process_result callable to transform, deduplicate or filter the
fetched events before the count check. It receives the list of asset events and
must return a list; the processed events are pushed to XCom. It runs on every
poke, so it should be idempotent and free of side effects.
The query applies limit before processing. A callback may expand the fetched list, so
limit < expected_count is allowed when a callback is supplied; the count is checked on its
output. Choose the limit with any filtering or deduplication in mind.
With ascending order and a limit, each poke returns the same oldest matching events while those
events remain in the query range. If the callback rejects them, newer acceptable events beyond
the limit are never inspected. Prefer query filters such as partition_key, extra and time
bounds where possible. For a check of recent events, use ascending=False with a suitable limit.
The example below keeps the latest event at or after the data interval ends.
If no post-processing is needed, use ascending=False, limit=1 with the query
filter to fetch only its latest matching event.
def latest_event_only(events: list[Any]) -> list[Any]:
"""Keep only the most recent event."""
return events[-1:]
wait_for_latest_asset_event = AssetEventSensor(
task_id="wait_for_latest_asset_event",
asset=my_asset,
after=DataIntervalEnd(),
process_result=latest_event_only,
)