Repository navigation
Add AssetEventSensor to standard provider - #70225
hussein-awala wants to merge 22 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new AssetEventSensor and AssetEventTrigger to the Standard provider to wait for asset events matching filter criteria until an expected count is reached (including a deferrable mode that runs optional process_result processing in the triggerer). This integrates with the Task SDK’s InletEventsAccessor-based asset-event querying and is version-gated to Airflow 3.4+.
Changes:
- Introduce
AssetEventSensor+AssetEventTriggerwith support for filters (after/before, ordering/limit,partition_key,partition_key_regexp_pattern,extra) andexpected_countsemantics. - Add provider/version compatibility flags (
AIRFLOW_V_3_4_PLUS) and wire new modules into provider metadata. - Add unit tests (including DB-backed harness via a
SUPERVISOR_COMMSstand-in) for sensor/trigger behavior and deferrable flow.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| providers/standard/src/airflow/providers/standard/sensors/asset.py | New AssetEventSensor implementation (sync + deferrable), filter forwarding, process_result handling, and execute_complete. |
| providers/standard/src/airflow/providers/standard/triggers/asset.py | New AssetEventTrigger implementation + shared helpers for fetch/count/serialization. |
| providers/standard/src/airflow/providers/standard/exceptions.py | Add sensor-specific exception types for trigger event validation. |
| providers/standard/src/airflow/providers/standard/version_compat.py | Add AIRFLOW_V_3_4_PLUS feature gate for the provider. |
| providers/standard/src/airflow/providers/standard/get_provider_info.py | Register new sensor/trigger modules in provider info. |
| providers/standard/provider.yaml | Register new sensor/trigger modules in provider YAML metadata. |
| providers/standard/tests/unit/standard/conftest.py | Add DB-backed fixtures and SUPERVISOR_COMMS stand-in to exercise real SQL/filtering paths in unit tests. |
| providers/standard/tests/unit/standard/sensors/test_asset.py | Add sensor tests (poke semantics, process_result, deferrable short-circuit/defer behavior, filter forwarding). |
| providers/standard/tests/unit/standard/triggers/test_asset.py | Add trigger tests (serialization, polling loop, process_result import/behavior, DB-backed run). |
| devel-common/src/tests_common/test_utils/version_compat.py | Add AIRFLOW_V_3_4_PLUS for tests to skip appropriately. |
Lee-W
left a comment
There was a problem hiding this comment.
Thanks! I'll take a deeper look, but I'd first like to understand why we need a sensor like this.
I thought we could always use a custom PartitionMapper to achieve something similar. Are there any use cases I'm missing? Thanks!
@Lee-W Good point, but they're at different layers.
We can say it's a wrapper for the So complementary, not redundant. Can add this to the docs if useful. |
|
Thanks for answering in details. But I'm still not convinced this should be a task-level thing.
But my main concern is that this dependency is not known by Airflow. We can't schedule on it, and it doesn't show up in the lineage. The whole point of assets is that declaring a data dependency makes it visible to the platform. That works against what the feature is for. |
|
@Lee-W We hit this exact tension migrating some pipelines to partitioned assets, a concrete data point. Take a simple linear DAG: task A runs, then task B needs an asset produced by a different DAG before it can proceed, then task C runs. Today that's A -> ExternalTaskSensor -> B -> C, and A runs immediately regardless of whether the other DAG's data is ready. Forcing that dependency onto the DAG's own schedule would delay A too, a real regression, since A never needed it. Splitting A into its own DAG doesn't help either: A, B, and C are one logical unit of work that should be run, observed, and retried together, and splitting them apart only serves the scheduler's DAG-level-only asset constraint, not any real independence between them. A task-level sensor between A and B is the right fit, and this shape isn't rare. On the visibility concern: could the sensor also set inlets for the asset(s) it waits on? inlets/outlets already drive the lineage graph independent of scheduling, so the dependency stays visible to the platform without tying it to what triggers the DAG run. |
|
I opened the original discussion behind this (#67375), so let me add the concrete case, since the "why task-level?" question is the crux. The workload is a time-scheduled batch consumer ( Why the existing mechanisms don't quite cover it:
On the visibility concern (the strongest objection, I think): I don't think satisfying it requires scheduling on the asset. Airflow already separates lineage from scheduling — inlets/outlets draw the dependency graph independently of what triggers a run. A task-level sensor can declare the awaited asset as an inlet, so the dependency stays visible to the platform (asset graph, lineage, OpenLineage) without coupling the run to asset-triggering. That keeps both: the consumer keeps its time schedule and data interval, and the data dependency stays first-class. So I'd argue this is a genuine gap for time-partitioned batch consumers — the alternatives either change the run's contract or act at the wrong layer — and a task-level sensor is complementary to asset scheduling, not a replacement for it. |
uranusjr
left a comment
There was a problem hiding this comment.
There are multiple issues with deferrable support. I’d suggest dropping this for now and only implement a synchronous sensor in this PR. After that’s merged, we can
- Introduce triggerer changes needed to support asset event fetching.
- Refactor InletEventsAccessor internals to ready it for an async interface.
- Add the trigger to depend on the above changes.
- Add
deferrable=Trueto the sensor that only works on Airflow 3.4+.
|
This sequencing makes sense. FWIW step 1 — the triggerer changes for asset-event fetching — is already implemented in #67941, which independently hit the exact issue you flagged: Its trigger also takes the shape you're describing: natively async ( Happy for #67941 to serve as that step-1 prerequisite, or for the relevant commits to be split/reviewed however fits the plan — whatever keeps this moving. |
3cc26e0 to
68333aa
Compare
@uranusjr Thanks, that's a fair catch on the triggerer, I've dropped deferrable support from this PR: |
|
Also, the lineage point raised above makes sense to me; we should resolve that before moving forward. Let’s have the sensor declare the awaited asset as an inlet and read |
|
I raised this use case in #67375, and I've got the deferrable path working in #67941 already — happy to pick up the follow-up (steps 1/3/4) once the sync sensor lands. It's in the shape you described: triggerer support for |
|
Hey @hussein-awala, @uranusjr & @Leondon9! I'm keen to see this move forward asap and wondered if any more progress has been made? I'm more than happy to assist if I can in any way. |
|
Thanks for keeping this alive, @nathadfield. The path is clear from @uranusjr's review — a synchronous AssetEventSensor first (drop deferrable, use the inlet / context["inlet_events"][asset] approach for lineage), with the triggerer/deferrable work as a follow-up. |
|
I have limited availability to test my changes, but I’ll have everything ready by the end of next week. I’ll convert the PR to draft since I’ll be pushing some untested changes, and I’ll mark it as ready once the changes have been tested and reviewed. |
The execution API calls backing asset event fetching (GetAssetEventByAsset and GetAssetEventByAssetAlias) are not available in the triggerer, so the deferrable path could not work in practice. Ship the synchronous sensor only; deferrable support can follow once the triggerer can fetch asset events and InletEventsAccessor exposes a native async interface. Removes AssetEventTrigger and its tests, folds the shared fetch/count/ serialize helpers into the sensor module, and drops the now-unused asset sensor exceptions.
Task-level asset waits need visible lineage without changing the Dag schedule. Explicit count policies let consumers wait for a minimum number of events without missing the condition when several events arrive between pokes.
Conflicting selectors can silently wait for a different asset, while fractional counts and invalid limits can produce misleading waits. Result processing also needs clear limits so consumers know which events their callback can inspect.
Asset waits need time bounds tied to a run without requiring Jinja expressions or limiting Dag authors to a fixed set of anchors. A runtime resolver contract lets authors derive custom bounds from task context.
89cced8 to
6efe6f1
Compare
|
@uranusjr @cmarteepants, could you review the run-relative bounds in this update? after and before now accept any object with resolve(context). The built-in helpers cover run start, logical date, and data interval start and end. Each helper accepts an optional lookback. The implementation uses Airflow’s existing template rendering. Custom resolvers need only resolve(context). Inheritance is optional. Please check the resolver interface, anchor semantics, and behavior after rescheduling. |
| after: datetime | str | AssetEventTimeBound | Callable[..., datetime | str] | None = None, | ||
| before: datetime | str | AssetEventTimeBound | Callable[..., datetime | str] | None = None, |
There was a problem hiding this comment.
Template callables are only evaluated from 3.3.0 so we’ll need some additional function gating below and notes in documentation.
There was a problem hiding this comment.
I checked the 3.2.0 source. It already evaluates template callables with context and jinja_env.
I also tested this with released Airflow 3.2.2 and Task SDK 1.2.2. The callable tests pass for both after and before, so I kept support on 3.2+.
| return [e.model_dump(mode="json") if isinstance(e, BaseModel) else e for e in events] | ||
|
|
||
|
|
||
| class AssetEventTimeBound(Protocol): |
There was a problem hiding this comment.
This relies on resolve() duck-typing, but it’s never documented as a public interface. It’s fine in the standard provider, but we’ll need some tests around it in the provider so a future Airflow changes get compat coverage.
There was a problem hiding this comment.
I extended the provider tests to cover plain objects and protocol subclasses for both after and before. They exercise template rendering and polling with datetime and ISO string results. The tests also cover mapped custom resolvers. These cases pass with released Airflow 3.2.2.
|
|
||
|
|
||
| class _ContextTimeBound(AssetEventTimeBound): | ||
| _context_key: ClassVar[str] |
There was a problem hiding this comment.
This is both the lookup key and the error label. RunStart fakes it and overrides _get_timestamp anyway. It would be simpler IMO to make _get_timestamp the single abstract hook with a separate label, which also removes the cast at L81 too.
The built-ins serialize as their repr. This format is specific to Python so other languages would have trouble reproducing it. It would be better to either add and explicit serialize().
There was a problem hiding this comment.
I made _get_timestamp() the single abstract hook and added a separate error label. This removes the lookup cast.
The built-in helpers now provide serialize() with anchor and offset_seconds fields. Mapped arguments still use the core serializer’s representation fallback, so the helpers retain a deterministic __repr__.
|
|
||
| def _get_accessor(self, context: Context) -> InletEventsAccessor: | ||
| # The mapped operator's inlets don't include the asset added in init. | ||
| # TODO: Remove this once the min Airflow refreshes after unmapping. |
There was a problem hiding this comment.
Do we have a reference (issue/link) to be used here?
There was a problem hiding this comment.
I added a link to #75751 at the workaround. The comment states when we can remove it.
| @pytest.mark.parametrize("mapped_bound", [False, True], ids=["partial", "expand"]) | ||
| def test_mapped_custom_resolver( | ||
| self, asset, events_response, create_runtime_ti, mock_supervisor_comms, mapped_bound | ||
| ): |
There was a problem hiding this comment.
This misses _ParameterBound without __repr__ serializes ugly in partial_kwargs and literal expand values, and every parse writes a new Dag version because the repr format contains an object id.
Document that mapped custom resolvers need a deterministic repr, and add a test that serializes the mapped operator twice and compares. The real fix (a qualname fallback in BaseSerialization) is a separate core PR.
There was a problem hiding this comment.
I documented the need for a deterministic __repr__ that includes the resolver’s configuration.
The new tests compare serialization of two independently created, equivalent Dags. They cover partial and expand for every built-in helper and a custom resolver. They also check that different configurations produce different serialized values.
The general fallback change remains separate core work, as suggested.
| wait_for_asset_event = AssetEventSensor( | ||
| task_id="wait_for_asset_event", | ||
| asset=my_asset, | ||
| after=RunStart(lookback=timedelta(hours=1)), |
There was a problem hiding this comment.
I think DataIntervalEnd() is better for a time-scheduled dag. Either change this, or switch to an asset-triggered dag. (Or maybe we can have two dags here?)
There was a problem hiding this comment.
I changed the daily example to after=DataIntervalEnd().
| wait_for_latest_asset_event = AssetEventSensor( | ||
| task_id="wait_for_latest_asset_event", | ||
| asset=my_asset, | ||
| process_result=latest_event_only, | ||
| ) |
There was a problem hiding this comment.
This contradicts the warning about having no bounds would fetch the entire history. (I wonder if we should reject this by default and require the user to pass at least one bound, or set e.g. limit=None explicitly.)
There was a problem hiding this comment.
I added after=DataIntervalEnd() to this example.
The sensor now requires at least one query filter: after, before, partition_key, partition_key_regexp_pattern, or non-empty extra. Time bounds remain optional when another query filter is present.
The sensor checks this at construction and before polling, after template rendering. An empty extra dictionary does not qualify. Neither limit nor process_result alone satisfies the requirement.
| .. note:: | ||
|
|
||
| For asset-triggered runs, ``dag_run.run_after`` can be the timestamp of the | ||
| newest triggering event, and ``after`` is inclusive. If the sensor waits on | ||
| the same asset that triggered the run, that event satisfies it immediately. | ||
| Use ``RunStart()`` to wait for events at or after the actual run start. |
There was a problem hiding this comment.
This was written for a templated example. The example now uses RunStart, so the run_after sentence no longer applies. Rewrite to cover what users will hit.
Something like for asset-triggered runs, the logical date and data interval are None, so LogicalDate() and DataIntervalStart/End() raise, so RunStart() should be used instead.
There was a problem hiding this comment.
I replaced the obsolete note. The guide now explains that asset-triggered runs have no logical date or data interval. Bounds that require those timestamps raise ValueError.
It also compares RunStart() with the new RunAfter() helper. RunAfter() keeps a fixed boundary after clearing, but its inclusive lower bound can match a triggering event.
| Airflow resolves the objects during template rendering, before polling. Their | ||
| run-relative values stay fixed while the sensor polls and after rescheduling. |
There was a problem hiding this comment.
True for reschedules, not for clears. Clearing a finished run resets start_date, so the window moves forward, and a cleared sensor can wait forever for events the original run already saw.
There was a problem hiding this comment.
I corrected the guide to distinguish clearing from rescheduling. Clearing a finished run gives it a new actual start time when it starts again. RunStart() therefore moves forward.
The table contrasts this with the fixed RunAfter(), logical date, and interval bounds. The tests check each helper’s response to changed run metadata. They also check that reconstructed sensors keep their bounds after rescheduling.
| Each built-in object accepts a non-negative ``lookback`` duration, with zero as the | ||
| default. For example, ``RunStart(lookback=timedelta(hours=1))`` includes events | ||
| from one hour before the Dag run started. The basic example above uses this | ||
| bound, so an event from that hour can satisfy the sensor immediately. |
There was a problem hiding this comment.
On before, this moves the upper bound earlier, which reads oddly. A signed offset reads naturally for both bounds. (It’s awkward to change it though)
There was a problem hiding this comment.
I replaced lookback with a signed offset. Each helper adds the offset to its timestamp. Negative values move the bound earlier, and positive values move it later.
The docs show examples for both bounds. The tests cover zero, positive, and negative offsets.
| The same example works with ``class ParameterBound:``. Custom resolvers need | ||
| only ``resolve(context)``. The ``lookback`` argument is a convenience provided | ||
| by the built-in helpers. |
There was a problem hiding this comment.
Probably not needed since we already said inheritance is optional above?
There was a problem hiding this comment.
I removed the repeated sentence. The interface description states once that inheritance is optional.
There was a problem hiding this comment.
@hussein-awala nice, thanks for adding the bounds! RunStart(lookback=...) reads well for the common case. Building on TP's review this morning:
-
No bounds by default: +1 to requiring an explicit choice, either at least one bound or an explicit opt-in to the full history.
-
Clearing an asset-triggered run: I didn't realize this, but yes as TP pointed out, clearing a finished run resets
start_date, so aRunStart()window moves forward. I don't think that's wrong, just a tradeoff. Users clear a run either to pick up new data (after an upstream fix, for example) or to redo the same work (after fixing a downstream bug, for example).RunStart()already handles the former. For the latter, scheduled runs can useLogicalDate(), which doesn't change on clear, but asset-triggered runs have no logical date. ARunAfter()bound would fill that gap, as long as it avoids the triggering-event trap from my first review (run_afteris the newest triggering event's timestamp, andafteris inclusive).Authors have to pick up front, but at least a wrong guess with
RunStart()times out loudly while a fixed bound will pass silently. -
Docs: we should spell out these tradeoffs. Something like:
Bound Available on On clear Watch out for RunStart()All runs Moves to the clear time (finished runs only), so the rerun waits for new events With lookback, events from before the run can satisfy it immediatelyLogicalDate()Scheduled runs only; raises on asset-triggered runs Fixed Meaning depends on the timetable: the trigger time for CronTriggerTimetable(the default for cron strings), the interval start forCronDataIntervalTimetableDataIntervalStart()/DataIntervalEnd()Scheduled runs only; raises on asset-triggered runs Fixed Zero-length on CronTriggerTimetable, so both equal the logical dateRunAfter()(proposed)All runs Fixed On asset-triggered runs, equals the triggering event's timestamp My earlier pass had a docs section covering this. Once we settle on the approach, I'm happy to take a pass and add it.
|
Thank you both for the review! I believe it's better to keep time bounds optional. Users can filter by partition, for example to get the latest event for a partition regardless of its creation date. I do not think we should require start or end bounds for this use case. I will document each bound’s use cases and tradeoffs, as Constance suggested, including its behavior when tasks are cleared. I will go through TP’s comments and ping him once I have addressed them. |
|
@cmarteepants Thank you for the suggestions. I added the comparison table with availability, behavior after clearing, and cautions for each bound. The sensor now requires at least one query filter. Time bounds remain optional when another query filter is present. I also added To exclude events exactly at that timestamp, users can set |
Adds
AssetEventSensorto the standard provider for Dags that need to wait for asset events between tasks. Basic use requires Airflow 3.2+. Thepartition_key,partition_key_regexp_pattern, andextrafilters require Airflow 3.4+.Pass an
Asset,Asset.ref(...), orAssetAliasthroughasset=. The sensor declares the target as an inlet and reads events through the SDK inlet accessor.Supply at least one query filter:
after,before,partition_key,partition_key_regexp_pattern, or non-emptyextra. Time bounds are optional when another filter is supplied. The sensor checks this requirement at construction and after template rendering, before it queries events.The sensor supports ordering and query limits.
expected_countandcount_policycontrol the required count. An optionalprocess_resultcallable transforms the events before counting. Successful sensors return the processed events through XCom.afterandbeforeaccept datetime values, ISO strings, Jinja templates, template callables, and any object withresolve(context). The built-inRunStart,RunAfter,LogicalDate,DataIntervalStart, andDataIntervalEndhelpers accept a signedoffset. They use Airflow's existing template rendering. Custom resolvers need no inheritance, dataclass, or serialization methods.The sensor guide compares the bounds in a table, including their behavior when tasks are cleared. It explains inclusive boundaries, timetable differences, and stable custom representations for Dag serialization. The PR also includes an example Dag and tests for custom resolvers, mapped sensors, and rescheduling.
Testing:
mainat2c560aad52.breeze verifypassed: core database and non-database tests; provider database tests with Postgres; provider non-database tests; and documentation builds. The provider checks coveredcommon.ai,common.compat,google,openlineage, andstandard.Limitations:
[api] regexp_query_timeout. The default value,0, disables regexp queries.partial(inlets=[...])declaration for the asset to appear in lineage. The runtime inlet workaround links to the core fix in Lazy-resolve inlet refs and refresh asset context #75751.__repr__for mapped arguments and older core serialization paths.Was generative AI tooling used to co-author this PR?
Generated-by: Cursor (Claude Opus 4.8) and Codex (GPT-6) following the guidelines