This is an automated email from the ASF dual-hosted git repository.

vatsrahul1001 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 3ac1e4f7f2e Make asset event batching opt-in (#68517)
3ac1e4f7f2e is described below

commit 3ac1e4f7f2e72deaf152b524908ad16a9d320813
Author: renat-sagut <[email protected]>
AuthorDate: Mon Oct 12 05:49:29 2026 +0200

    Make asset event batching opt-in (#68517)
    
    * batch asset events
    
    * allow apdr reuse
    
    * tests
    
    * tests
    
    * cleanup
    
    * tests
    
    * formatting
    
    * tests
    
    * tests
    
    * tests
    
    * mypy
    
    * docu
    
    * ruff
    
    * added version guard in openlineage tests
    
    * Update airflow-core/docs/authoring-and-scheduling/asset-scheduling.rst
    
    Co-authored-by: Jens Scheffler <[email protected]>
    
    * cleanup
    
    * version guard
    
    * tests
    
    * tests
    
    * tests
    
    * docu
    
    * trigger ci
    
    * version guard
    
    * cleanup
    
    * tests
    
    * Preserve asset event batching choices across timetables
    
    Dag authors need batching choices to survive serialization without changing 
existing constructor calls. Align the feature with current scheduler queue 
handling and the reviewed release target.
    
    * Make asset event batching opt-in and bound queued run creation
    
    * Preserve queued asset events when batching changes
    
    Existing compound asset schedules must remain valid after reparsing. 
Changing batching modes must also preserve queued partition events and let 
users inspect the progress of each pending run.
    
    * Remove asset event batching newsfragment
    
    * Fix docs spelling check failure on asset batching docs
    
    The docs spelling check rejects "rollups", which is not in the wordlist, so 
the PR documentation build failed. Refer to RollupMapper instead of using the 
plural.
    
    * Move merging of pending partitioned runs out of the batching change
    
    Merging pending partitioned runs when batching is enabled is a separate
    concern from making batching opt-in, and its scheduler locking is the
    riskiest part of the change. Keep this PR to the opt-in default and track
    the merge logic separately.
    
    * Improve asset batching and pending run selection
    
    Asset schedules need to express their event requirements consistently 
during parsing and scheduling. The pending-run view should show the latest 
attempt when no specific run is selected.
    
    * Clarify asset catchup and simplify partition mapper defaults
    
    ---------
    
    Co-authored-by: Jens Scheffler <[email protected]>
    Co-authored-by: Ephraim Anierobi <[email protected]>
---
 .../authoring-and-scheduling/asset-scheduling.rst  |  64 ++++++
 airflow-core/newsfragments/68517.significant.rst   |  14 ++
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |   8 +
 .../core_api/routes/ui/partitioned_dag_runs.py     |  11 +-
 airflow-core/src/airflow/assets/manager.py         |  26 ++-
 .../src/airflow/config_templates/config.yml        |  15 ++
 .../src/airflow/jobs/scheduler_job_runner.py       |  84 +++++---
 airflow-core/src/airflow/models/asset.py           |  10 +-
 .../airflow/serialization/definitions/assets.py    |  13 ++
 airflow-core/src/airflow/serialization/encoders.py |   7 +-
 airflow-core/src/airflow/timetables/assets.py      |  35 +--
 airflow-core/src/airflow/timetables/base.py        |  16 ++
 airflow-core/src/airflow/timetables/simple.py      |  91 +++++---
 .../src/airflow/ui/openapi-gen/queries/common.ts   |   5 +-
 .../ui/openapi-gen/queries/ensureQueryData.ts      |   6 +-
 .../src/airflow/ui/openapi-gen/queries/prefetch.ts |   6 +-
 .../src/airflow/ui/openapi-gen/queries/queries.ts  |   6 +-
 .../src/airflow/ui/openapi-gen/queries/suspense.ts |   6 +-
 .../ui/openapi-gen/requests/services.gen.ts        |   4 +-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |   1 +
 .../ui/src/components/AssetProgressCell.test.tsx   |  70 ++++++
 .../ui/src/components/AssetProgressCell.tsx        |  15 +-
 .../src/pages/DagsList/PartitionScheduleModal.tsx  |   1 +
 .../routes/ui/test_partitioned_dag_runs.py         |  48 +++--
 airflow-core/tests/unit/assets/test_manager.py     |  37 ++++
 .../tests/unit/dag_processing/test_dagbag.py       |  28 +++
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 239 ++++++++++++++++++++-
 .../unit/serialization/definitions/test_assets.py  |  19 ++
 .../tests/unit/timetables/test_assets_timetable.py | 144 ++++++++++++-
 .../unit/timetables/test_partitioned_timetable.py  |  54 ++++-
 .../tests/unit/openlineage/plugins/test_utils.py   |  39 +++-
 .../tests/unit/openlineage/utils/test_utils.py     |  33 ++-
 task-sdk/docs/api.rst                              |   2 +
 task-sdk/src/airflow/sdk/__init__.py               |   3 +
 task-sdk/src/airflow/sdk/__init__.pyi              |   2 +
 .../src/airflow/sdk/definitions/asset/__init__.py  |  13 ++
 .../airflow/sdk/definitions/timetables/assets.py   |  51 ++++-
 task-sdk/tests/task_sdk/definitions/test_asset.py  |  16 ++
 .../task_sdk/definitions/timetables/test_assets.py | 166 ++++++++++++++
 39 files changed, 1263 insertions(+), 145 deletions(-)

diff --git a/airflow-core/docs/authoring-and-scheduling/asset-scheduling.rst 
b/airflow-core/docs/authoring-and-scheduling/asset-scheduling.rst
index 1bf2507237b..03d9122cc9d 100644
--- a/airflow-core/docs/authoring-and-scheduling/asset-scheduling.rst
+++ b/airflow-core/docs/authoring-and-scheduling/asset-scheduling.rst
@@ -446,3 +446,67 @@ Asset-aware timetables combine asset expressions with a 
time-based schedule:
 * Use ``AssetAndTimeSchedule`` to keep a Dag on a timetable but only create 
scheduled runs once the referenced assets have been updated.
 
 For more detailed information on asset-aware timetables, refer to 
:ref:`AssetOrTimeSchedule <asset-timetable-section>`.
+
+
+Controlling DagRun creation per asset event
+---------------------------------------------
+
+.. versionadded:: 3.4.0
+
+Asset-triggered Dags create one Dag run per event by default. Set
+``batch_asset_events=True`` on the timetable to consume queued events together
+in one run. The :ref:`config:scheduler__batch_asset_events` setting changes the
+default for the deployment; an explicit timetable argument takes precedence.
+Reparse Dags after changing the setting. Previously serialized timetables 
without
+this option retain batching until they are reparsed.
+
+The option is also available on ``AssetOrTimeSchedule`` and
+``PartitionedAssetTimetable``. Asset conditions using ``&`` and partition 
mappers based on
+``RollupMapper`` need several events to satisfy their condition and cannot 
consume each event
+independently, so they default to batching when ``batch_asset_events`` is not 
set.
+Passing ``batch_asset_events=False`` explicitly for them makes timetable 
validation
+raise an error. Single-asset schedules and ``|`` expressions support either 
mode.
+
+Pending partitioned runs created while batching was disabled are not merged 
when you
+enable batching, so let them finish before changing the setting.
+
+For non-partitioned Dags, per-event run creation respects ``max_active_runs`` 
and the scheduler's
+:ref:`config:scheduler__max_dagruns_to_create_per_loop` budget. Events left 
over
+remain queued for subsequent scheduler passes.
+
+For non-partitioned asset-triggered Dags, ``catchup=False`` consumes only 
events
+queued for that Dag. It does not discard a queued backlog: with batching 
disabled,
+each queued event still produces its own run.
+
+With ``catchup=True``, once queued events satisfy the asset condition, the 
scheduler
+also includes older, unconsumed events for the Dag's scheduled assets and 
aliases,
+including events emitted before the Dag started consuming them. Events already
+consumed by a run of that Dag are excluded in both modes. 
``batch_asset_events``
+controls whether the eligible events produce separate runs or are consumed 
together.
+
+.. code-block:: python
+
+    from airflow.sdk import DAG, Asset, AssetTriggeredTimetable
+
+    # Each update to "data-file" produces its own DagRun
+    with DAG(
+        dag_id="per-event-consumer",
+        schedule=AssetTriggeredTimetable(
+            assets=Asset("s3://bucket/data-file"),
+            batch_asset_events=False,
+        ),
+    ):
+        ...
+
+To consume several queued updates to an asset in one run, enable batching:
+
+.. code-block:: python
+
+    with DAG(
+        dag_id="combined-consumer",
+        schedule=AssetTriggeredTimetable(
+            assets=Asset("s3://bucket/orders"),
+            batch_asset_events=True,
+        ),
+    ):
+        ...
diff --git a/airflow-core/newsfragments/68517.significant.rst 
b/airflow-core/newsfragments/68517.significant.rst
new file mode 100644
index 00000000000..f07598c3625
--- /dev/null
+++ b/airflow-core/newsfragments/68517.significant.rst
@@ -0,0 +1,14 @@
+Asset-triggered Dags create one run per event by default
+
+Single-asset schedules and asset OR expressions now create one Dag run per 
event
+instead of consuming all queued events in one run. This also applies to
+``AssetOrTimeSchedule`` and ``PartitionedAssetTimetable``.
+
+To retain batching, set ``batch_asset_events=True`` on the timetable or enable
+``[scheduler] batch_asset_events`` and reparse the affected Dags. Already 
serialized
+timetables without this option retain their previous batching behavior until 
reparsed.
+
+Schedules requiring several events, including ``schedule=[a, b]``, asset AND
+expressions, and partitioned timetables using ``RollupMapper``, continue to 
batch
+by default. Explicitly setting ``batch_asset_events=False`` for these schedules
+raises a timetable validation error.
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 2feaf0a7ea2..3b988ffb102 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -560,6 +560,14 @@ paths:
         schema:
           type: string
           title: Partition Key
+      - name: partitioned_dag_run_id
+        in: query
+        required: false
+        schema:
+          anyOf:
+          - type: integer
+          - type: 'null'
+          title: Partitioned Dag Run Id
       responses:
         '200':
           description: Successful Response
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
index 77ac9454b6a..f87e4cd12b2 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
@@ -370,12 +370,13 @@ def get_pending_partitioned_dag_run(
     dag_id: str,
     partition_key: str,
     session: SessionDep,
+    partitioned_dag_run_id: int | None = None,
 ) -> PartitionedDagRunDetailResponse:
     """Return full details for pending PartitionedDagRun."""
     # partition_key is a query param, not a path segment: it is a free-form key
     # (up to 250 chars) that may itself contain "/", which would otherwise be
     # ambiguous (or mis-routed) as a path segment.
-    partitioned_dag_run = session.execute(
+    query = (
         select(
             AssetPartitionDagRun.id,
             AssetPartitionDagRun.target_dag_id,
@@ -390,11 +391,11 @@ def get_pending_partitioned_dag_run(
             AssetPartitionDagRun.partition_key == partition_key,
             AssetPartitionDagRun.created_dag_run_id.is_(None),
         )
-        # Duplicate pending rows for the same (dag_id, partition_key) can exist
-        # after a crash; mirror _get_or_create_apdr and work on the latest one.
         .order_by(AssetPartitionDagRun.id.desc())
-        .limit(1)
-    ).first()
+    )
+    if partitioned_dag_run_id is not None:
+        query = query.where(AssetPartitionDagRun.id == partitioned_dag_run_id)
+    partitioned_dag_run = session.execute(query.limit(1)).first()
 
     if partitioned_dag_run is None:
         raise HTTPException(
diff --git a/airflow-core/src/airflow/assets/manager.py 
b/airflow-core/src/airflow/assets/manager.py
index 8aa834d4a4a..ad133881db7 100644
--- a/airflow-core/src/airflow/assets/manager.py
+++ b/airflow-core/src/airflow/assets/manager.py
@@ -694,6 +694,7 @@ class AssetManager(LoggingMixin):
                     target_dag=target_dag,
                     rollup_fingerprint=fingerprint,
                     asset_id=asset_id,
+                    allow_reuse=timetable.batch_asset_events,
                     session=session,
                 )
                 log_record = PartitionedAssetKeyLog(
@@ -715,6 +716,7 @@ class AssetManager(LoggingMixin):
         target_dag: DagModel,
         rollup_fingerprint: dict,
         asset_id: int,
+        allow_reuse: bool,
         session: Session,
     ) -> AssetPartitionDagRun:
         """
@@ -743,17 +745,25 @@ class AssetManager(LoggingMixin):
           one, the producing assets disagree; picking one would be 
order-dependent, so the
           carried date is suppressed to ``None`` (and re-adoptable by a later 
event).
         - Otherwise (the dates agree, or this event carries none) the existing 
value is kept.
+
+        When ``allow_reuse=True``, an existing pending APDR for the same
+        ``(target_dag, partition_key)`` is reused — multiple events accumulate 
on one
+        APDR. When ``allow_reuse=False`` (set when the timetable's 
``batch_asset_events``
+        is ``False``), a new APDR is always created so each event gets its own 
APDR
+        and the scheduler produces one DagRun per event.
         """
         with _lock_asset_model(session=session, asset_id=asset_id):
-            latest_apdr: AssetPartitionDagRun | None = session.scalar(
-                select(AssetPartitionDagRun)
-                .where(
-                    AssetPartitionDagRun.partition_key == target_key,
-                    AssetPartitionDagRun.target_dag_id == target_dag.dag_id,
+            latest_apdr: AssetPartitionDagRun | None = None
+            if allow_reuse:
+                latest_apdr = session.scalar(
+                    select(AssetPartitionDagRun)
+                    .where(
+                        AssetPartitionDagRun.partition_key == target_key,
+                        AssetPartitionDagRun.target_dag_id == 
target_dag.dag_id,
+                    )
+                    .order_by(AssetPartitionDagRun.id.desc())
+                    .limit(1)
                 )
-                .order_by(AssetPartitionDagRun.id.desc())
-                .limit(1)
-            )
             if latest_apdr and latest_apdr.created_dag_run_id is None:
                 existing_partition_date = latest_apdr.partition_date
                 if existing_partition_date is None:
diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index 0de787a4c59..951b85194aa 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -2688,6 +2688,19 @@ sentry:
 scheduler:
   description: ~
   options:
+    batch_asset_events:
+      description: |
+        Default for the ``batch_asset_events`` argument of asset-triggered 
timetables.
+        When false, each asset event creates a separate Dag run. Set this to 
true
+        to restore batching of queued events into one run. An explicit 
timetable
+        argument overrides this setting. Asset AND conditions and 
``RollupMapper`` partition
+        mappers always default to batching, and passing 
``batch_asset_events=False`` for them
+        is rejected. The setting is read when a Dag's timetable is constructed;
+        reparse Dags after changing it.
+      version_added: 3.4.0
+      type: boolean
+      example: ~
+      default: "False"
     job_heartbeat_sec:
       description: |
         Task instances listen for external kill signal (when you clear tasks
@@ -2886,6 +2899,8 @@ scheduler:
     max_dagruns_to_create_per_loop:
       description: |
         Max number of DAGs to create DagRuns for per scheduler loop.
+        Also limits non-partitioned asset-triggered Dag run creation across 
all selected
+        Dags, including those with event batching enabled.
       example: ~
       version_added: 2.0.0
       type: integer
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 052d7d12b1a..c78bbf1d4bb 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -2901,6 +2901,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
         *,
         dag: SerializedDAG,
         records: Sequence[AssetDagRunQueue],
+        limit: int | None = None,
         session: Session,
     ) -> list[AssetEvent]:
         """Select unconsumed events referenced by a Dag's locked ADRQ rows."""
@@ -2934,6 +2935,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                     ),
                 )
                 .order_by(AssetEvent.timestamp.asc(), AssetEvent.id.asc())
+                .limit(limit)
             )
         )
 
@@ -2966,7 +2968,12 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
         session: Session,
     ) -> None:
         """For Dags that are triggered by assets, create Dag runs."""
+        remaining_runs = DagModel.NUM_DAGS_PER_DAGRUN_QUERY
+        per_dag_limit = max(1, remaining_runs // max(1, len(dag_models)))
+        active_runs_by_dag: dict[str, int] | None = None
         for dag_model in dag_models:
+            if remaining_runs <= 0:
+                break
             dag = self._get_current_dag(dag_id=dag_model.dag_id, 
session=session)
             if not dag:
                 self.log.error("Dag '%s' not found in serialized_dag table", 
dag_model.dag_id)
@@ -2990,11 +2997,31 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                 )
                 continue
 
+            run_limit = per_dag_limit
+            if not dag.timetable.batch_asset_events:
+                if active_runs_by_dag is None:
+                    active_runs_by_dag = DagRun.active_runs_of_dags(
+                        dag_ids=(model.dag_id for model in dag_models),
+                        exclude_backfill=True,
+                        session=session,
+                    )
+                run_limit = min(
+                    run_limit,
+                    remaining_runs,
+                    (dag_model.max_active_runs or 0) - 
active_runs_by_dag.get(dag.dag_id, 0),
+                )
+                if run_limit <= 0:
+                    continue
+
             asset_events = self._select_consumed_asset_events(
                 dag=dag,
                 records=queued_adrqs,
+                limit=None if dag.timetable.batch_asset_events else run_limit 
+ 1,
                 session=session,
             )
+            has_more_events = not dag.timetable.batch_asset_events and 
len(asset_events) > run_limit
+            if has_more_events:
+                asset_events = asset_events[:run_limit]
             if asset_events:
                 triggered_date = timezone.coerce_datetime(max(event.timestamp 
for event in asset_events))
                 self.log.debug(
@@ -3003,41 +3030,50 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                     len(queued_adrqs),
                     triggered_date,
                 )
-                dag_run = dag.create_dagrun(
-                    run_id=DagRun.generate_run_id(
-                        run_type=DagRunType.ASSET_TRIGGERED, 
logical_date=None, run_after=triggered_date
-                    ),
-                    logical_date=None,
-                    data_interval=None,
-                    run_after=triggered_date,
-                    run_type=DagRunType.ASSET_TRIGGERED,
-                    triggered_by=DagRunTriggeredByType.ASSET,
-                    state=DagRunState.QUEUED,
-                    creating_job_id=self.job.id,
-                    session=session,
-                )
                 team_name = (
                     self._get_team_names_for_dag_ids([dag.dag_id], 
session).get(dag.dag_id)
                     if self._multi_team
                     else None
                 )
-                stats.incr("asset.triggered_dagruns", 
tags=prune_dict({"team_name": team_name}))
-                dag_run.consumed_asset_events.extend(asset_events)
-                self.log.info(
-                    "Created asset-triggered DagRun for '%s': run_id=%s, 
consumed %d asset events",
-                    dag.dag_id,
-                    dag_run.run_id,
-                    len(asset_events),
-                )
+                for run_after, events in 
dag.timetable.group_asset_events(asset_events, triggered_date):
+                    dag_run = dag.create_dagrun(
+                        run_id=DagRun.generate_run_id(
+                            run_type=DagRunType.ASSET_TRIGGERED, 
logical_date=None, run_after=run_after
+                        ),
+                        logical_date=None,
+                        data_interval=None,
+                        run_after=run_after,
+                        run_type=DagRunType.ASSET_TRIGGERED,
+                        triggered_by=DagRunTriggeredByType.ASSET,
+                        state=DagRunState.QUEUED,
+                        creating_job_id=self.job.id,
+                        session=session,
+                    )
+                    remaining_runs -= 1
+                    stats.incr("asset.triggered_dagruns", 
tags=prune_dict({"team_name": team_name}))
+                    dag_run.consumed_asset_events.extend(events)
+                    self.log.info(
+                        "Created asset-triggered DagRun for '%s': run_id=%s, 
consumed %d asset events",
+                        dag.dag_id,
+                        dag_run.run_id,
+                        len(events),
+                    )
             else:
                 self.log.info(
                     "No DagRun created for '%s' - asset events already 
consumed or none found",
                     dag.dag_id,
                 )
-            # Always delete ADRQ rows for this batch to prevent stale entries 
accumulating,
-            # including when all events were already consumed by a concurrent 
DagRun.
+            records_to_delete = queued_adrqs
+            if has_more_events:
+                consumed_ids = {event.id for event in asset_events}
+                if dag.catchup:
+                    # Historical events may have no ADRQ. Keep a wakeup row 
until that backlog drains.
+                    consumed_ids.discard(max(record.asset_event_id for record 
in queued_adrqs))
+                records_to_delete = [
+                    record for record in queued_adrqs if record.asset_event_id 
in consumed_ids
+                ]
             self._delete_consumed_asset_records(
-                records=queued_adrqs,
+                records=records_to_delete,
                 dag_id=dag.dag_id,
                 session=session,
             )
diff --git a/airflow-core/src/airflow/models/asset.py 
b/airflow-core/src/airflow/models/asset.py
index afc71a59ad6..ab780be2e77 100644
--- a/airflow-core/src/airflow/models/asset.py
+++ b/airflow-core/src/airflow/models/asset.py
@@ -949,11 +949,11 @@ class AssetPartitionDagRun(Base):
     target_dag_id / partition_key are expected in general; each dag run that
     gets created leaves its APDR record behind.
 
-    Where created_dag_run_id is null, the dag run has not yet been created.
-    We should not allow more than one row with the same target_dag_id /
-    partition_key where created_dag_run_id is null, and this is what the
-    `_lock_asset_model` mutex control is for. In case a duplicate somehow
-    gets created, we always work on the latest matching APDR record.
+    Where created_dag_run_id is null, the Dag run has not yet been created.
+    With event batching enabled, `_lock_asset_model` protects reuse of the
+    latest pending row for a target_dag_id / partition_key pair. With batching
+    disabled, each event creates its own row, so multiple pending rows for
+    the same pair are expected.
     """
 
     id: Mapped[int] = mapped_column(Integer, primary_key=True, 
autoincrement=True)
diff --git a/airflow-core/src/airflow/serialization/definitions/assets.py 
b/airflow-core/src/airflow/serialization/definitions/assets.py
index b1f28909373..0ba7137e998 100644
--- a/airflow-core/src/airflow/serialization/definitions/assets.py
+++ b/airflow-core/src/airflow/serialization/definitions/assets.py
@@ -70,6 +70,11 @@ class SerializedAssetBase:
     :meta private:
     """
 
+    @property
+    def requires_batching(self) -> bool:
+        """Whether this condition needs multiple asset events to be 
satisfied."""
+        return False
+
     def __bool__(self) -> bool:
         return True
 
@@ -260,6 +265,10 @@ class SerializedAssetBooleanCondition(SerializedAssetBase):
 
     agg_func: ClassVar[Callable[[Iterable], bool]]
 
+    @property
+    def requires_batching(self) -> bool:
+        return any(obj.requires_batching for obj in self.objects)
+
     def iter_assets(self) -> Iterator[tuple[SerializedAssetUniqueKey, 
SerializedAsset]]:
         for o in self.objects:
             yield from o.iter_assets()
@@ -301,6 +310,10 @@ class SerializedAssetAll(SerializedAssetBooleanCondition):
 
     agg_func = all
 
+    @property
+    def requires_batching(self) -> bool:
+        return len(self.objects) > 1 or super().requires_batching
+
     def __repr__(self) -> str:
         return f"AssetAny({', '.join(map(str, self.objects))})"
 
diff --git a/airflow-core/src/airflow/serialization/encoders.py 
b/airflow-core/src/airflow/serialization/encoders.py
index e4b417de213..d1f801d0b53 100644
--- a/airflow-core/src/airflow/serialization/encoders.py
+++ b/airflow-core/src/airflow/serialization/encoders.py
@@ -371,7 +371,10 @@ class _Serializer:
 
     @serialize_timetable.register
     def _(self, timetable: AssetTriggeredTimetable) -> dict[str, Any]:
-        return {"asset_condition": 
encode_asset_like(timetable.asset_condition)}
+        return {
+            "asset_condition": encode_asset_like(timetable.asset_condition),
+            "batch_asset_events": timetable.batch_asset_events,
+        }
 
     @serialize_timetable.register
     def _(self, timetable: EventsTimetable) -> dict[str, Any]:
@@ -439,6 +442,7 @@ class _Serializer:
         return {
             "asset_condition": encode_asset_like(timetable.asset_condition),
             "timetable": encode_timetable(timetable.timetable),
+            "batch_asset_events": timetable.batch_asset_events,
         }
 
     @serialize_timetable.register
@@ -449,6 +453,7 @@ class _Serializer:
     def _(self, timetable: PartitionedAssetTimetable) -> dict[str, Any]:
         return {
             "asset_condition": encode_asset_like(timetable.asset_condition),
+            "batch_asset_events": timetable.batch_asset_events,
             "default_partition_mapper": 
encode_partition_mapper(timetable.default_partition_mapper),
             "partition_mapper_config": [
                 (encode_asset_like(asset), 
encode_partition_mapper(partition_mapper))
diff --git a/airflow-core/src/airflow/timetables/assets.py 
b/airflow-core/src/airflow/timetables/assets.py
index 7359ba23d0e..8ec26434f7c 100644
--- a/airflow-core/src/airflow/timetables/assets.py
+++ b/airflow-core/src/airflow/timetables/assets.py
@@ -20,10 +20,16 @@ from __future__ import annotations
 import typing
 from collections.abc import Collection
 
+import attrs
+
 from airflow.exceptions import AirflowTimetableInvalid
 from airflow.serialization.definitions.assets import SerializedAsset, 
SerializedAssetAll, SerializedAssetBase
 from airflow.timetables.base import Timetable
-from airflow.timetables.simple import AssetTriggeredTimetable
+from airflow.timetables.simple import (
+    AssetTriggeredTimetable,
+    _coerce_asset_condition,
+    _get_default_batch_asset_events,
+)
 from airflow.utils.types import DagRunType
 
 if typing.TYPE_CHECKING:
@@ -39,21 +45,21 @@ def _validate_asset_time_schedule(*, timetable: Timetable, 
asset_condition: Seri
         raise AirflowTimetableInvalid("All elements in 'assets' must be 
assets")
 
 
[email protected](eq=False, repr=False, kw_only=True, slots=False)
 class AssetOrTimeSchedule(AssetTriggeredTimetable):
     """Combine time-based scheduling with event-based scheduling."""
 
-    def __init__(
-        self,
-        *,
-        timetable: Timetable,
-        assets: Collection[SerializedAsset] | SerializedAssetBase,
-    ) -> None:
-        super().__init__(assets)
-        self.timetable = timetable
-        self.description = f"Triggered by assets or {timetable.description}"
-        self.periodic = timetable.periodic
-        self.can_be_scheduled = timetable.can_be_scheduled
-        self.active_runs_limit = timetable.active_runs_limit
+    asset_condition: SerializedAssetBase = attrs.field(alias="assets", 
converter=_coerce_asset_condition)
+    timetable: Timetable
+    batch_asset_events: bool = attrs.field(
+        default=attrs.Factory(_get_default_batch_asset_events, takes_self=True)
+    )
+
+    def __attrs_post_init__(self) -> None:
+        self.description = f"Triggered by assets or 
{self.timetable.description}"
+        self.periodic = self.timetable.periodic
+        self.can_be_scheduled = self.timetable.can_be_scheduled
+        self.active_runs_limit = self.timetable.active_runs_limit
 
     @classmethod
     def deserialize(cls, data: dict[str, typing.Any]) -> Timetable:
@@ -62,9 +68,11 @@ class AssetOrTimeSchedule(AssetTriggeredTimetable):
         return cls(
             assets=decode_asset_like(data["asset_condition"]),
             timetable=decode_timetable(data["timetable"]),
+            batch_asset_events=data.get("batch_asset_events", True),
         )
 
     def validate(self) -> None:
+        super().validate()
         _validate_asset_time_schedule(
             timetable=self.timetable,
             asset_condition=self.asset_condition,
@@ -76,6 +84,7 @@ class AssetOrTimeSchedule(AssetTriggeredTimetable):
         return {
             "asset_condition": encode_asset_like(self.asset_condition),
             "timetable": encode_timetable(self.timetable),
+            "batch_asset_events": self.batch_asset_events,
         }
 
     @property
diff --git a/airflow-core/src/airflow/timetables/base.py 
b/airflow-core/src/airflow/timetables/base.py
index b2ad57cae76..a2325395a08 100644
--- a/airflow-core/src/airflow/timetables/base.py
+++ b/airflow-core/src/airflow/timetables/base.py
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
 
     from pendulum import DateTime
 
+    from airflow.models.asset import AssetEvent
     from airflow.models.dag import DagModel
     from airflow.models.dagrun import DagRun
     from airflow.partition_mappers import PartitionMapper
@@ -228,6 +229,21 @@ class Timetable(Protocol):
     asset_condition: SerializedAssetBase = _NullAsset()
     """The asset condition that triggers a DAG using this timetable."""
 
+    batch_asset_events: bool = False
+    """Whether queued asset events are consumed together in one Dag run."""
+
+    def group_asset_events(
+        self, events: list[AssetEvent], triggered_date: DateTime
+    ) -> Iterable[tuple[DateTime, list[AssetEvent]]]:
+        """Yield a run time and its consumed events for each asset-triggered 
run."""
+        if not events:
+            return
+        if self.batch_asset_events:
+            yield triggered_date, events
+        else:
+            for event in events:
+                yield timezone.coerce_datetime(event.timestamp), [event]
+
     partitioned: bool = False
     """Whether this timetable considers asset partitions.
 
diff --git a/airflow-core/src/airflow/timetables/simple.py 
b/airflow-core/src/airflow/timetables/simple.py
index a1c64a213b0..d16627a56c8 100644
--- a/airflow-core/src/airflow/timetables/simple.py
+++ b/airflow-core/src/airflow/timetables/simple.py
@@ -20,10 +20,12 @@ from contextlib import suppress
 from datetime import datetime
 from typing import TYPE_CHECKING, Any, TypeAlias
 
+import attrs
 import structlog
 
 from airflow._shared.timezones import timezone
-from airflow.exceptions import InvalidPartitionKeyError
+from airflow.configuration import conf
+from airflow.exceptions import AirflowTimetableInvalid, 
InvalidPartitionKeyError
 from airflow.partition_mappers.identity import IdentityMapper
 from airflow.serialization.definitions.assets import (
     SerializedAsset,
@@ -207,6 +209,22 @@ class PartitionedAtRuntime(NullTimetable):
         return "PartitionedAtRuntime"
 
 
+def _coerce_asset_condition(
+    assets: Collection[SerializedAsset | BaseAsset] | SerializedAssetBase | 
BaseAsset,
+) -> SerializedAssetBase:
+    if isinstance(assets, SerializedAssetBase | BaseAsset):
+        return ensure_serialized_asset(assets)
+    return SerializedAssetAll([ensure_serialized_asset(asset) for asset in 
assets])
+
+
+def _get_default_batch_asset_events(timetable: AssetTriggeredTimetable) -> 
bool:
+    return (
+        conf.getboolean("scheduler", "batch_asset_events", fallback=False)
+        or timetable.get_batching_requirement() is not None
+    )
+
+
[email protected](eq=False, repr=False, slots=False)
 class AssetTriggeredTimetable(_TrivialTimetable):
     """
     Timetable that never schedules anything.
@@ -216,29 +234,42 @@ class AssetTriggeredTimetable(_TrivialTimetable):
     :meta private:
     """
 
-    description: str = "Triggered by assets"
+    description = "Triggered by assets"
     asset_triggered = True
 
-    def __init__(self, assets: Collection[SerializedAsset] | 
SerializedAssetBase) -> None:
-        super().__init__()
-        # Compatibility: Handle SDK assets if needed so this class works in 
dag files.
-        if isinstance(assets, SerializedAssetBase | BaseAsset):
-            self.asset_condition = ensure_serialized_asset(assets)
-        else:
-            self.asset_condition = 
SerializedAssetAll([ensure_serialized_asset(a) for a in assets])
+    asset_condition: SerializedAssetBase = attrs.field(alias="assets", 
converter=_coerce_asset_condition)
+    batch_asset_events: bool = attrs.field(
+        default=attrs.Factory(_get_default_batch_asset_events, 
takes_self=True), kw_only=True
+    )
 
     @classmethod
     def deserialize(cls, data: dict[str, Any]) -> Timetable:
         from airflow.serialization.decoders import decode_asset_like
 
-        return cls(decode_asset_like(data["asset_condition"]))
+        return cls(
+            decode_asset_like(data["asset_condition"]),
+            batch_asset_events=data.get("batch_asset_events", True),
+        )
 
     @property
     def summary(self) -> str:
         return "Asset"
 
     def serialize(self) -> dict[str, Any]:
-        return {"asset_condition": encode_asset_like(self.asset_condition)}
+        return {
+            "asset_condition": encode_asset_like(self.asset_condition),
+            "batch_asset_events": self.batch_asset_events,
+        }
+
+    def get_batching_requirement(self) -> str | None:
+        """Return why this timetable cannot run without event batching, or 
``None``."""
+        if self.asset_condition.requires_batching:
+            return "Asset AND conditions require batch_asset_events=True"
+        return None
+
+    def validate(self) -> None:
+        if not self.batch_asset_events and (reason := 
self.get_batching_requirement()):
+            raise AirflowTimetableInvalid(reason)
 
     def generate_run_id(
         self,
@@ -273,6 +304,7 @@ class AssetTriggeredTimetable(_TrivialTimetable):
 DEFAULT_PARTITION_MAPPER = IdentityMapper()
 
 
[email protected](eq=False, repr=False, kw_only=True, slots=False)
 class PartitionedAssetTimetable(AssetTriggeredTimetable):
     """Asset-driven timetable that listens for partitioned assets."""
 
@@ -282,21 +314,30 @@ class PartitionedAssetTimetable(AssetTriggeredTimetable):
     def summary(self) -> str:
         return "Partitioned Asset"
 
-    def __init__(
-        self,
-        *,
-        assets: SerializedAssetBase,
-        partition_mapper_config: dict[SerializedAssetBase, PartitionMapper] | 
None = None,
-        default_partition_mapper: PartitionMapper = DEFAULT_PARTITION_MAPPER,
-    ) -> None:
-        super().__init__(assets=assets)
-        self.partition_mapper_config = partition_mapper_config or {}
-        self.default_partition_mapper = default_partition_mapper
-
-        self._name_to_partition_mapper: dict[str, PartitionMapper] = {}
-        self._uri_to_partition_mapper: dict[str, PartitionMapper] = {}
+    asset_condition: SerializedAssetBase = attrs.field(alias="assets", 
converter=_coerce_asset_condition)
+    partition_mapper_config: dict[SerializedAssetBase, PartitionMapper] = 
attrs.field(
+        factory=dict,
+        # Mypy's attrs plugin does not recognize converter factories.
+        converter=attrs.converters.default_if_none(factory=dict),  # type: 
ignore[misc]
+    )
+    default_partition_mapper: PartitionMapper = DEFAULT_PARTITION_MAPPER
+    batch_asset_events: bool = attrs.field(
+        default=attrs.Factory(_get_default_batch_asset_events, takes_self=True)
+    )
+    _name_to_partition_mapper: dict[str, PartitionMapper] = 
attrs.field(factory=dict, init=False)
+    _uri_to_partition_mapper: dict[str, PartitionMapper] = 
attrs.field(factory=dict, init=False)
+
+    def __attrs_post_init__(self) -> None:
         self._build_name_uri_mapping()
 
+    def get_batching_requirement(self) -> str | None:
+        if any(
+            mapper.is_rollup
+            for mapper in (self.default_partition_mapper, 
*self.partition_mapper_config.values())
+        ):
+            return "Partition rollups require batch_asset_events=True"
+        return super().get_batching_requirement()
+
     def _build_name_uri_mapping(self) -> None:
         for base_asset, partition_mapper in 
self.partition_mapper_config.items():
             for unique_key, _ in base_asset.iter_assets():
@@ -403,6 +444,7 @@ class PartitionedAssetTimetable(AssetTriggeredTimetable):
 
         return {
             "asset_condition": encode_asset_like(self.asset_condition),
+            "batch_asset_events": self.batch_asset_events,
             "partition_mapper_config": [
                 (encode_asset_like(asset), 
encode_partition_mapper(partition_mapper))
                 for asset, partition_mapper in 
self.partition_mapper_config.items()
@@ -420,6 +462,7 @@ class PartitionedAssetTimetable(AssetTriggeredTimetable):
 
         timetable = cls(
             assets=decode_asset_like(data["asset_condition"]),
+            batch_asset_events=data.get("batch_asset_events", True),
             
default_partition_mapper=decode_partition_mapper(default_partition_mapper_data),
             partition_mapper_config={
                 decode_asset_like(ser_asest): 
decode_partition_mapper(ser_partition_mapper)
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
index bcebe9e0d7b..b4aed6744dd 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -1106,10 +1106,11 @@ export const 
UsePartitionedDagRunServiceGetPartitionedDagRunsKeyFn = ({ dagId, h
 export type PartitionedDagRunServiceGetPendingPartitionedDagRunDefaultResponse 
= Awaited<ReturnType<typeof 
PartitionedDagRunService.getPendingPartitionedDagRun>>;
 export type 
PartitionedDagRunServiceGetPendingPartitionedDagRunQueryResult<TData = 
PartitionedDagRunServiceGetPendingPartitionedDagRunDefaultResponse, TError = 
unknown> = UseQueryResult<TData, TError>;
 export const usePartitionedDagRunServiceGetPendingPartitionedDagRunKey = 
"PartitionedDagRunServiceGetPendingPartitionedDagRun";
-export const UsePartitionedDagRunServiceGetPendingPartitionedDagRunKeyFn = ({ 
dagId, partitionKey }: {
+export const UsePartitionedDagRunServiceGetPendingPartitionedDagRunKeyFn = ({ 
dagId, partitionedDagRunId, partitionKey }: {
   dagId: string;
+  partitionedDagRunId?: number;
   partitionKey: string;
-}, queryKey?: Array<unknown>) => 
[usePartitionedDagRunServiceGetPendingPartitionedDagRunKey, ...(queryKey ?? [{ 
dagId, partitionKey }])];
+}, queryKey?: Array<unknown>) => 
[usePartitionedDagRunServiceGetPendingPartitionedDagRunKey, ...(queryKey ?? [{ 
dagId, partitionedDagRunId, partitionKey }])];
 export type DependenciesServiceGetDependenciesDefaultResponse = 
Awaited<ReturnType<typeof DependenciesService.getDependencies>>;
 export type DependenciesServiceGetDependenciesQueryResult<TData = 
DependenciesServiceGetDependenciesDefaultResponse, TError = unknown> = 
UseQueryResult<TData, TError>;
 export const useDependenciesServiceGetDependenciesKey = 
"DependenciesServiceGetDependencies";
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index b3d106c6f04..718f1947310 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -2192,13 +2192,15 @@ export const 
ensureUsePartitionedDagRunServiceGetPartitionedDagRunsData = (query
 * @param data The data for the request.
 * @param data.dagId
 * @param data.partitionKey
+* @param data.partitionedDagRunId
 * @returns PartitionedDagRunDetailResponse Successful Response
 * @throws ApiError
 */
-export const ensureUsePartitionedDagRunServiceGetPendingPartitionedDagRunData 
= (queryClient: QueryClient, { dagId, partitionKey }: {
+export const ensureUsePartitionedDagRunServiceGetPendingPartitionedDagRunData 
= (queryClient: QueryClient, { dagId, partitionedDagRunId, partitionKey }: {
   dagId: string;
+  partitionedDagRunId?: number;
   partitionKey: string;
-}) => queryClient.ensureQueryData({ queryKey: 
Common.UsePartitionedDagRunServiceGetPendingPartitionedDagRunKeyFn({ dagId, 
partitionKey }), queryFn: () => 
PartitionedDagRunService.getPendingPartitionedDagRun({ dagId, partitionKey }) 
});
+}) => queryClient.ensureQueryData({ queryKey: 
Common.UsePartitionedDagRunServiceGetPendingPartitionedDagRunKeyFn({ dagId, 
partitionedDagRunId, partitionKey }), queryFn: () => 
PartitionedDagRunService.getPendingPartitionedDagRun({ dagId, 
partitionedDagRunId, partitionKey }) });
 /**
 * Get Dependencies
 * Dependencies graph.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index 485654a7a40..bcd34c714af 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -2192,13 +2192,15 @@ export const 
prefetchUsePartitionedDagRunServiceGetPartitionedDagRuns = (queryCl
 * @param data The data for the request.
 * @param data.dagId
 * @param data.partitionKey
+* @param data.partitionedDagRunId
 * @returns PartitionedDagRunDetailResponse Successful Response
 * @throws ApiError
 */
-export const prefetchUsePartitionedDagRunServiceGetPendingPartitionedDagRun = 
(queryClient: QueryClient, { dagId, partitionKey }: {
+export const prefetchUsePartitionedDagRunServiceGetPendingPartitionedDagRun = 
(queryClient: QueryClient, { dagId, partitionedDagRunId, partitionKey }: {
   dagId: string;
+  partitionedDagRunId?: number;
   partitionKey: string;
-}) => queryClient.prefetchQuery({ queryKey: 
Common.UsePartitionedDagRunServiceGetPendingPartitionedDagRunKeyFn({ dagId, 
partitionKey }), queryFn: () => 
PartitionedDagRunService.getPendingPartitionedDagRun({ dagId, partitionKey }) 
});
+}) => queryClient.prefetchQuery({ queryKey: 
Common.UsePartitionedDagRunServiceGetPendingPartitionedDagRunKeyFn({ dagId, 
partitionedDagRunId, partitionKey }), queryFn: () => 
PartitionedDagRunService.getPendingPartitionedDagRun({ dagId, 
partitionedDagRunId, partitionKey }) });
 /**
 * Get Dependencies
 * Dependencies graph.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
index a225a608ac7..11b7e9f5851 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -2192,13 +2192,15 @@ export const 
usePartitionedDagRunServiceGetPartitionedDagRuns = <TData = Common.
 * @param data The data for the request.
 * @param data.dagId
 * @param data.partitionKey
+* @param data.partitionedDagRunId
 * @returns PartitionedDagRunDetailResponse Successful Response
 * @throws ApiError
 */
-export const usePartitionedDagRunServiceGetPendingPartitionedDagRun = <TData = 
Common.PartitionedDagRunServiceGetPendingPartitionedDagRunDefaultResponse, 
TError = unknown, TQueryKey extends Array<unknown> = unknown[]>({ dagId, 
partitionKey }: {
+export const usePartitionedDagRunServiceGetPendingPartitionedDagRun = <TData = 
Common.PartitionedDagRunServiceGetPendingPartitionedDagRunDefaultResponse, 
TError = unknown, TQueryKey extends Array<unknown> = unknown[]>({ dagId, 
partitionedDagRunId, partitionKey }: {
   dagId: string;
+  partitionedDagRunId?: number;
   partitionKey: string;
-}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey: 
Common.UsePartitionedDagRunServiceGetPendingPartitionedDagRunKeyFn({ dagId, 
partitionKey }, queryKey), queryFn: () => 
PartitionedDagRunService.getPendingPartitionedDagRun({ dagId, partitionKey }) 
as TData, ...options });
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey: 
Common.UsePartitionedDagRunServiceGetPendingPartitionedDagRunKeyFn({ dagId, 
partitionedDagRunId, partitionKey }, queryKey), queryFn: () => 
PartitionedDagRunService.getPendingPartitionedDagRun({ dagId, 
partitionedDagRunId, partitionKey }) as TData, ...options });
 /**
 * Get Dependencies
 * Dependencies graph.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index d77094ceaed..84c37145799 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -2192,13 +2192,15 @@ export const 
usePartitionedDagRunServiceGetPartitionedDagRunsSuspense = <TData =
 * @param data The data for the request.
 * @param data.dagId
 * @param data.partitionKey
+* @param data.partitionedDagRunId
 * @returns PartitionedDagRunDetailResponse Successful Response
 * @throws ApiError
 */
-export const usePartitionedDagRunServiceGetPendingPartitionedDagRunSuspense = 
<TData = 
Common.PartitionedDagRunServiceGetPendingPartitionedDagRunDefaultResponse, 
TError = unknown, TQueryKey extends Array<unknown> = unknown[]>({ dagId, 
partitionKey }: {
+export const usePartitionedDagRunServiceGetPendingPartitionedDagRunSuspense = 
<TData = 
Common.PartitionedDagRunServiceGetPendingPartitionedDagRunDefaultResponse, 
TError = unknown, TQueryKey extends Array<unknown> = unknown[]>({ dagId, 
partitionedDagRunId, partitionKey }: {
   dagId: string;
+  partitionedDagRunId?: number;
   partitionKey: string;
-}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey: 
Common.UsePartitionedDagRunServiceGetPendingPartitionedDagRunKeyFn({ dagId, 
partitionKey }, queryKey), queryFn: () => 
PartitionedDagRunService.getPendingPartitionedDagRun({ dagId, partitionKey }) 
as TData, ...options });
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey: 
Common.UsePartitionedDagRunServiceGetPendingPartitionedDagRunKeyFn({ dagId, 
partitionedDagRunId, partitionKey }, queryKey), queryFn: () => 
PartitionedDagRunService.getPendingPartitionedDagRun({ dagId, 
partitionedDagRunId, partitionKey }) as TData, ...options });
 /**
 * Get Dependencies
 * Dependencies graph.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index c22d64e684d..4b799d58b9d 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -5380,6 +5380,7 @@ export class PartitionedDagRunService {
      * @param data The data for the request.
      * @param data.dagId
      * @param data.partitionKey
+     * @param data.partitionedDagRunId
      * @returns PartitionedDagRunDetailResponse Successful Response
      * @throws ApiError
      */
@@ -5391,7 +5392,8 @@ export class PartitionedDagRunService {
                 dag_id: data.dagId
             },
             query: {
-                partition_key: data.partitionKey
+                partition_key: data.partitionKey,
+                partitioned_dag_run_id: data.partitionedDagRunId
             },
             errors: {
                 404: 'Not Found',
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 bc19ad00938..69a3ad485c6 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
@@ -5404,6 +5404,7 @@ export type GetPartitionedDagRunsResponse = 
PartitionedDagRunCollectionResponse;
 
 export type GetPendingPartitionedDagRunData = {
     dagId: string;
+    partitionedDagRunId?: number | null;
     partitionKey: string;
 };
 
diff --git 
a/airflow-core/src/airflow/ui/src/components/AssetProgressCell.test.tsx 
b/airflow-core/src/airflow/ui/src/components/AssetProgressCell.test.tsx
new file mode 100644
index 00000000000..291f3198a9b
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/AssetProgressCell.test.tsx
@@ -0,0 +1,70 @@
+/*!
+ * 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 { render, waitFor } from "@testing-library/react";
+import { expect, it, vi } from "vitest";
+
+import { PartitionedDagRunService } from "openapi/requests/services.gen";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { AssetProgressCell } from "./AssetProgressCell";
+
+it("fetches separate details for pending runs sharing a partition key", async 
() => {
+  const request = vi.spyOn(PartitionedDagRunService, 
"getPendingPartitionedDagRun").mockResolvedValue({
+    assets: [],
+    dag_id: "consumer",
+    id: 1,
+    partition_key: "same-key",
+    total_received: 1,
+    total_required: 1,
+  });
+
+  render(
+    <>
+      <AssetProgressCell
+        dagId="consumer"
+        partitionedDagRunId={1}
+        partitionKey="same-key"
+        totalReceived={1}
+        totalRequired={1}
+      />
+      <AssetProgressCell
+        dagId="consumer"
+        partitionedDagRunId={2}
+        partitionKey="same-key"
+        totalReceived={1}
+        totalRequired={1}
+      />
+    </>,
+    { wrapper: Wrapper },
+  );
+
+  await waitFor(() => {
+    expect(request).toHaveBeenCalledWith({
+      dagId: "consumer",
+      partitionedDagRunId: 1,
+      partitionKey: "same-key",
+    });
+    expect(request).toHaveBeenCalledWith({
+      dagId: "consumer",
+      partitionedDagRunId: 2,
+      partitionKey: "same-key",
+    });
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/AssetProgressCell.tsx 
b/airflow-core/src/airflow/ui/src/components/AssetProgressCell.tsx
index bf62e40b569..799499b6bcf 100644
--- a/airflow-core/src/airflow/ui/src/components/AssetProgressCell.tsx
+++ b/airflow-core/src/airflow/ui/src/components/AssetProgressCell.tsx
@@ -32,14 +32,25 @@ import { formatNumber } from "src/utils";
 
 type Props = {
   readonly dagId: string;
+  readonly partitionedDagRunId: number;
   readonly partitionKey: string;
   readonly totalReceived: number;
   readonly totalRequired: number;
 };
 
-export const AssetProgressCell = ({ dagId, partitionKey, totalReceived, 
totalRequired }: Props) => {
+export const AssetProgressCell = ({
+  dagId,
+  partitionedDagRunId,
+  partitionKey,
+  totalReceived,
+  totalRequired,
+}: Props) => {
   const { i18n } = useTranslation();
-  const { data, isLoading } = 
usePartitionedDagRunServiceGetPendingPartitionedDagRun({ dagId, partitionKey });
+  const { data, isLoading } = 
usePartitionedDagRunServiceGetPendingPartitionedDagRun({
+    dagId,
+    partitionedDagRunId,
+    partitionKey,
+  });
 
   const assetExpression = data?.asset_expression ?? undefined;
   const assets: Array<PartitionedDagRunAssetResponse> = data?.assets ?? [];
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.tsx
index 6835033ad2c..b53769e10f3 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.tsx
@@ -67,6 +67,7 @@ const getColumns = (
     cell: ({ row }) => (
       <AssetProgressCell
         dagId={dagId}
+        partitionedDagRunId={row.original.id}
         partitionKey={row.original.partition_key}
         totalReceived={row.original.total_received}
         totalRequired={row.original.total_required}
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
index 7016db222dd..1fe5df0b9a3 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
@@ -1015,14 +1015,10 @@ class TestGetPendingPartitionedDagRun:
         assert body["dag_id"] == "slash_key_dag"
         assert body["partition_key"] == "region/us"
 
-    def test_duplicate_pending_apdr_rows_return_latest(self, test_client, 
dag_maker, session):
-        """
-        Duplicate pending APDR rows for the same (dag_id, partition_key) must 
not 500.
-
-        The model docstring for ``AssetPartitionDagRun`` says callers should 
always
-        work on the latest row when duplicates exist; the route must do the 
same
-        instead of raising ``MultipleResultsFound`` from ``.one_or_none()``.
-        """
+    @pytest.mark.parametrize(
+        "selection", [None, "oldest", "newest", "missing", "other-dag", 
"other-partition", "created"]
+    )
+    def test_pending_partition_details_select_run(self, test_client, 
dag_maker, session, selection):
         asset = Asset(uri="s3://bucket/dup_apdr", name="dup_apdr")
         with dag_maker(
             dag_id="dup_apdr_dag",
@@ -1030,40 +1026,52 @@ class TestGetPendingPartitionedDagRun:
             serialized=True,
         ):
             EmptyOperator(task_id="t")
-        dag_maker.create_dagrun()
+        dag_run = dag_maker.create_dagrun()
         dag_maker.sync_dagbag_to_db()
 
         asset = session.scalar(select(AssetModel).where(AssetModel.uri == 
"s3://bucket/dup_apdr"))
 
-        # Older duplicate row: no received events.
-        stale_pdr = AssetPartitionDagRun(target_dag_id="dup_apdr_dag", 
partition_key="2024-08-01")
-        session.add(stale_pdr)
+        next_pdr = AssetPartitionDagRun(target_dag_id="dup_apdr_dag", 
partition_key="2024-08-01")
+        session.add(next_pdr)
         session.flush()
 
-        # Newer duplicate row (higher id): has a received event.
-        latest_pdr = AssetPartitionDagRun(target_dag_id="dup_apdr_dag", 
partition_key="2024-08-01")
-        session.add(latest_pdr)
+        later_pdr = AssetPartitionDagRun(target_dag_id="dup_apdr_dag", 
partition_key="2024-08-01")
+        session.add(later_pdr)
         session.flush()
-        event = AssetEvent(asset_id=asset.id, timestamp=pendulum.now())
+        event = AssetEvent(asset_id=asset.id, 
timestamp=pendulum.datetime(2024, 8, 1))
         session.add(event)
         session.flush()
         session.add(
             PartitionedAssetKeyLog(
                 asset_id=asset.id,
                 asset_event_id=event.id,
-                asset_partition_dag_run_id=latest_pdr.id,
+                asset_partition_dag_run_id=next_pdr.id,
                 source_partition_key="2024-08-01",
                 target_dag_id="dup_apdr_dag",
                 target_partition_key="2024-08-01",
             )
         )
+        params = {"partition_key": "2024-08-01"}
+        if selection is not None:
+            params["partitioned_dag_run_id"] = next_pdr.id if selection == 
"oldest" else later_pdr.id
+        if selection == "missing":
+            params["partitioned_dag_run_id"] = -1
+        elif selection == "other-dag":
+            later_pdr.target_dag_id = "other-dag"
+        elif selection == "other-partition":
+            later_pdr.partition_key = "other-partition"
+        elif selection == "created":
+            later_pdr.created_dag_run_id = dag_run.id
         session.commit()
 
-        resp = 
test_client.get("/pending_partitioned_dag_run/dup_apdr_dag?partition_key=2024-08-01")
+        resp = test_client.get("/pending_partitioned_dag_run/dup_apdr_dag", 
params=params)
+        if selection in {"missing", "other-dag", "other-partition", "created"}:
+            assert resp.status_code == 404
+            return
         assert resp.status_code == 200
         body = resp.json()
-        assert body["id"] == latest_pdr.id
-        assert body["total_received"] == 1
+        assert body["id"] == (next_pdr.id if selection == "oldest" else 
later_pdr.id)
+        assert body["total_received"] == (1 if selection == "oldest" else 0)
 
     def test_non_rollup_many_to_one_received_capped_at_one(self, test_client, 
dag_maker, session):
         """
diff --git a/airflow-core/tests/unit/assets/test_manager.py 
b/airflow-core/tests/unit/assets/test_manager.py
index 8b72d1db57d..049df518569 100644
--- a/airflow-core/tests/unit/assets/test_manager.py
+++ b/airflow-core/tests/unit/assets/test_manager.py
@@ -55,6 +55,7 @@ from airflow.providers.standard.operators.empty import 
EmptyOperator
 from airflow.sdk.definitions.asset import Asset
 from airflow.sdk.definitions.timetables.assets import PartitionedAssetTimetable
 
+from tests_common.test_utils.asserts import capture_orm_selects
 from tests_common.test_utils.config import conf_vars
 from tests_common.test_utils.db import (
     clear_db_apdr,
@@ -439,6 +440,7 @@ class TestAssetManager:
                     target_dag=testing_dag,
                     rollup_fingerprint=rollup_fingerprint,
                     asset_id=asm.id,
+                    allow_reuse=True,
                     session=_session,
                 ).id
             finally:
@@ -477,6 +479,7 @@ class TestAssetManager:
             target_dag=testing_dag,
             rollup_fingerprint=fp,
             asset_id=asm.id,
+            allow_reuse=True,
             session=session,
         )
         assert first.partition_date == timezone.parse("2026-05-20T00:00:00")
@@ -488,6 +491,7 @@ class TestAssetManager:
             target_dag=testing_dag,
             rollup_fingerprint=fp,
             asset_id=asm.id,
+            allow_reuse=True,
             session=session,
         )
         assert second.id == first.id  # same pending APDR
@@ -508,6 +512,7 @@ class TestAssetManager:
             target_dag=testing_dag,
             rollup_fingerprint=fp,
             asset_id=asm.id,
+            allow_reuse=True,
             session=session,
         )
         first = 
AssetManager._get_or_create_apdr(target_partition_date=source_date, **kwargs)
@@ -536,6 +541,7 @@ class TestAssetManager:
             target_dag=testing_dag,
             rollup_fingerprint=fp,
             asset_id=asm.id,
+            allow_reuse=True,
             session=session,
         )
         # First event carries no date (e.g. producer had no partition_date).
@@ -562,6 +568,7 @@ class TestAssetManager:
             target_dag=testing_dag,
             rollup_fingerprint=fp,
             asset_id=asm.id,
+            allow_reuse=True,
             session=session,
         )
         first = AssetManager._get_or_create_apdr(target_partition_date=date_1, 
**kwargs)
@@ -616,6 +623,36 @@ class TestAssetManager:
         assert apdr.partition_date is None
         mock_log.exception.assert_called_once()
 
+    @pytest.mark.usefixtures("testing_dag_bundle")
+    @pytest.mark.parametrize("allow_reuse", [True, False])
+    def test_get_or_create_apdr_allow_reuse(self, session, allow_reuse):
+        clear_db_apdr()
+        clear_db_pakl()
+        asset = AssetModel(uri="test://reuse/", name="reuse_asset", 
group="asset")
+        dag = DagModel(dag_id="reuse_test_dag", is_stale=False, 
bundle_name="testing")
+        session.add_all([asset, dag])
+        session.flush()
+
+        with capture_orm_selects("asset_partition_dag_run") as lookups:
+            apdrs = [
+                AssetManager._get_or_create_apdr(
+                    target_key="key-1",
+                    target_partition_date=None,
+                    target_dag=dag,
+                    rollup_fingerprint={},
+                    asset_id=asset.id,
+                    allow_reuse=allow_reuse,
+                    session=session,
+                )
+                for _ in range(2)
+            ]
+
+        assert len(lookups) == (2 if allow_reuse else 0)
+        expected_count = 1 if allow_reuse else 2
+        assert len({apdr.id for apdr in apdrs}) == expected_count
+        assert 
session.scalar(select(func.count()).select_from(AssetPartitionDagRun)) == 
expected_count
+        assert all(apdr.created_dag_run_id is None for apdr in apdrs)
+
     @pytest.mark.need_serialized_dag
     @pytest.mark.usefixtures("testing_dag_bundle")
     def test_queue_partitioned_dags_stamps_rollup_fingerprint(self, session, 
dag_maker):
diff --git a/airflow-core/tests/unit/dag_processing/test_dagbag.py 
b/airflow-core/tests/unit/dag_processing/test_dagbag.py
index 73a718580f0..d24443f709f 100644
--- a/airflow-core/tests/unit/dag_processing/test_dagbag.py
+++ b/airflow-core/tests/unit/dag_processing/test_dagbag.py
@@ -892,6 +892,34 @@ class TestDagBag:
         assert len(dagbag.import_errors) == 1
         assert len(dagbag.dags) == 0
 
+    @pytest.mark.parametrize("batch_asset_events", [False, True])
+    @pytest.mark.parametrize("explicit", [False, None])
+    def test_process_file_compound_assets_require_batching(self, tmp_path, 
batch_asset_events, explicit):
+        dag_file = tmp_path / "compound_assets.py"
+        batching_argument = "" if explicit is None else f", 
batch_asset_events={explicit!r}"
+        dag_file.write_text(
+            textwrap.dedent(
+                f"""
+                from airflow.sdk import DAG, Asset, PartitionedAssetTimetable
+
+                with DAG("compound_assets", schedule=PartitionedAssetTimetable(
+                    Asset("a") & Asset("b"){batching_argument}
+                )):
+                    pass
+                """
+            )
+        )
+        with conf_vars({("scheduler", "batch_asset_events"): 
str(batch_asset_events)}):
+            dagbag = DagBag(dag_folder=os.fspath(tmp_path))
+        if explicit is None:
+            assert not dagbag.import_errors
+            assert "compound_assets" in dagbag.dags
+        else:
+            assert not dagbag.dags
+            assert list(dagbag.import_errors.values()) == [
+                "AirflowTimetableInvalid: Asset AND conditions require 
batch_asset_events=True"
+            ]
+
     def test_process_file_invalid_param_check(self, tmp_path):
         """
         test if an invalid param in the dags can be identified
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 5204fe776c0..530a5f99033 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -151,12 +151,13 @@ from airflow.sdk import (
 )
 from airflow.sdk.definitions._internal.loop import create_loop
 from airflow.sdk.definitions.callback import AsyncCallback, SyncCallback
-from airflow.sdk.definitions.timetables.assets import PartitionedAssetTimetable
+from airflow.sdk.definitions.timetables.assets import AssetTriggeredTimetable, 
PartitionedAssetTimetable
 from airflow.serialization.definitions.dag import SerializedDAG
 from airflow.serialization.encoders import ensure_serialized_asset
 from airflow.serialization.serialized_objects import LazyDeserializedDAG
 from airflow.timetables.base import DagRunInfo, DataInterval, Timetable, 
compute_rollup_fingerprint
 from airflow.timetables.simple import (
+    AssetTriggeredTimetable as CoreAssetTriggeredTimetable,
     PartitionedAssetTimetable as CorePartitionedAssetTimetable,
     PartitionedAtRuntime,
 )
@@ -6518,6 +6519,7 @@ class TestSchedulerJob:
         assert dr.creating_job_id == scheduler_job.id
 
     @pytest.mark.need_serialized_dag
+    @conf_vars({("scheduler", "batch_asset_events"): "True"})
     def test_create_dag_runs_assets(self, session, dag_maker):
         """
         Test various invariants of _create_dag_runs.
@@ -6652,6 +6654,7 @@ class TestSchedulerJob:
             pytest.param(True, True, id="catchup-on-consumes-backlog"),
         ],
     )
+    @conf_vars({("scheduler", "batch_asset_events"): "True"})
     def test_new_asset_triggered_dag_backlog_gated_by_catchup(
         self, catchup, expects_old_event, session, dag_maker
     ):
@@ -6714,6 +6717,7 @@ class TestSchedulerJob:
         assert {e.id for e in created_run.consumed_asset_events} == expected
 
     @pytest.mark.need_serialized_dag
+    @conf_vars({("scheduler", "batch_asset_events"): "True"})
     def test_asset_events_out_of_order_are_both_consumed(self, session, 
dag_maker):
         """Regression test for GH-54659.
 
@@ -6878,11 +6882,19 @@ class TestSchedulerJob:
 
     @pytest.mark.need_serialized_dag
     @pytest.mark.backend("postgres", "mysql")
-    def test_create_dag_runs_when_concurrent_asset_events_created(self, 
session: Session, dag_maker, caplog):
+    @pytest.mark.parametrize("batch_asset_events", [True, False])
+    def test_create_dag_runs_when_concurrent_asset_events_created(
+        self, session: Session, dag_maker, caplog, batch_asset_events
+    ):
 
         ASSET_EVENT_COUNT = 30
         asset = Asset(name="test_asset")
-        with dag_maker(dag_id="consumer", schedule=asset, session=session):
+        with dag_maker(
+            dag_id="consumer",
+            schedule=AssetTriggeredTimetable(assets=asset, 
batch_asset_events=batch_asset_events),
+            max_active_runs=ASSET_EVENT_COUNT,
+            session=session,
+        ):
             pass
         dag_model = dag_maker.dag_model
         # Capture the dag_id as a plain string in the main thread. The worker 
threads must not
@@ -13230,6 +13242,218 @@ def _produce_and_register_asset_event(
     return apdr
 
 
[email protected]_serialized_dag
[email protected]("clear_asset_partition_rows")
[email protected]("batch_asset_events", [True, False])
+def test_partitioned_batch_asset_events(dag_maker: DagMaker, session: Session, 
batch_asset_events: bool):
+    asset = Asset(name="partitioned-batch-asset")
+    dag_id = "partitioned-batch-consumer"
+    with dag_maker(
+        dag_id=dag_id,
+        schedule=PartitionedAssetTimetable(assets=asset, 
batch_asset_events=batch_asset_events),
+        session=session,
+    ):
+        EmptyOperator(task_id="task")
+    session.commit()
+    apdrs = [
+        _produce_and_register_asset_event(
+            dag_id=f"partitioned-batch-producer-{index}",
+            asset=asset,
+            partition_key="key-1",
+            session=session,
+            dag_maker=dag_maker,
+        )
+        for index in range(2)
+    ]
+    expected_count = 1 if batch_asset_events else 2
+    assert len({apdr.id for apdr in apdrs}) == expected_count
+
+    runner = SchedulerJobRunner(
+        job=Job(job_type=SchedulerJobRunner.job_type), 
executors=[MockExecutor(do_update=False)]
+    )
+    assert runner._create_dagruns_for_partitioned_asset_dags(session=session) 
== {dag_id}
+    dag_runs = session.scalars(select(DagRun).where(DagRun.dag_id == 
dag_id)).all()
+    assert len(dag_runs) == expected_count
+    assert len({run.run_id for run in dag_runs}) == expected_count
+    assert {apdr.created_dag_run_id for apdr in apdrs} == {run.id for run in 
dag_runs}
+    assert [len(run.consumed_asset_events) for run in dag_runs] == [2 // 
expected_count] * expected_count
+    assert len({event.id for run in dag_runs for event in 
run.consumed_asset_events}) == 2
+
+
[email protected]_serialized_dag
[email protected]("custom_grouping", [False, True])
[email protected]("batch_asset_events", [True, False])
[email protected]("event_spacing", [timedelta(0), timedelta(seconds=1)])
+def test_non_partitioned_batch_asset_events(
+    dag_maker: DagMaker,
+    session: Session,
+    batch_asset_events: bool,
+    event_spacing: timedelta,
+    custom_grouping,
+    mocker,
+):
+    asset = Asset(name="batch-consumer-asset")
+    with dag_maker(
+        dag_id="batch-consumer",
+        schedule=AssetTriggeredTimetable(assets=asset, 
batch_asset_events=batch_asset_events),
+        session=session,
+    ):
+        EmptyOperator(task_id="task")
+    session.commit()
+    dag_model = session.get(DagModel, "batch-consumer")
+    asset_model = session.scalar(select(AssetModel).where(AssetModel.uri == 
asset.uri))
+    assert dag_model is not None
+    assert asset_model is not None
+    base = timezone.parse("2026-10-01T00:00:00+00:00")
+    events = [
+        AssetEvent(asset_id=asset_model.id, timestamp=base + index * 
event_spacing) for index in range(2)
+    ]
+    session.add_all(events)
+    session.flush()
+    session.add_all(
+        AssetDagRunQueue(asset_id=asset_model.id, 
target_dag_id=dag_model.dag_id, asset_event_id=event.id)
+        for event in events
+    )
+    session.flush()
+
+    runner = SchedulerJobRunner(
+        job=Job(job_type=SchedulerJobRunner.job_type), 
executors=[MockExecutor(do_update=False)]
+    )
+    if custom_grouping:
+        mocker.patch.object(
+            CoreAssetTriggeredTimetable,
+            "group_asset_events",
+            autospec=True,
+            return_value=[(base.add(hours=1), events)],
+        )
+    runner._create_dag_runs_asset_triggered(dag_models=[dag_model], 
session=session)
+    session.flush()
+
+    dag_runs = session.scalars(
+        select(DagRun).where(DagRun.dag_id == 
dag_model.dag_id).order_by(DagRun.id)
+    ).all()
+    expected_events = [events] if batch_asset_events or custom_grouping else 
[[event] for event in events]
+    assert [{event.id for event in run.consumed_asset_events} for run in 
dag_runs] == [
+        {event.id for event in group} for group in expected_events
+    ]
+    expected_run_dates = (
+        [base.add(hours=1)]
+        if custom_grouping
+        else [max(event.timestamp for event in group) for group in 
expected_events]
+    )
+    assert [run.run_after for run in dag_runs] == expected_run_dates
+    assert len({run.run_id for run in dag_runs}) == len(expected_events)
+    assert all(
+        run.run_type == DagRunType.ASSET_TRIGGERED and run.state == 
DagRunState.QUEUED for run in dag_runs
+    )
+    assert session.scalar(select(func.count()).select_from(AssetDagRunQueue)) 
== 0
+
+    runner._create_dag_runs_asset_triggered(dag_models=[dag_model], 
session=session)
+    assert session.scalar(
+        select(func.count()).select_from(DagRun).where(DagRun.dag_id == 
dag_model.dag_id)
+    ) == len(expected_events)
+
+
[email protected]_serialized_dag
[email protected]("catchup", [False, True])
[email protected](
+    ("max_active_runs", "run_budget", "first_count"), [(1, 10, 1), (10, 1, 1), 
(3, 2, 2)]
+)
+def test_unbatched_asset_runs_drain_backlog_within_limits(
+    dag_maker, session, monkeypatch, catchup, max_active_runs, run_budget, 
first_count
+):
+    monkeypatch.setattr(DagModel, "NUM_DAGS_PER_DAGRUN_QUERY", run_budget)
+    asset = Asset(name="bounded-events")
+    dag_id = "bounded-consumer"
+    with dag_maker(
+        dag_id=dag_id,
+        schedule=AssetTriggeredTimetable(assets=asset, 
batch_asset_events=False),
+        max_active_runs=max_active_runs,
+        catchup=catchup,
+        session=session,
+    ):
+        EmptyOperator(task_id="consume")
+    session.commit()
+    dag_model = session.get(DagModel, dag_id)
+    asset_model = session.scalar(select(AssetModel).where(AssetModel.uri == 
asset.uri))
+    timestamp = timezone.parse("2026-10-01T00:00:00Z")
+    events = [AssetEvent(asset_id=asset_model.id, timestamp=timestamp) for _ 
in range(3)]
+    session.add_all(events)
+    session.flush()
+    queued_events = events[:1] if catchup else events
+    session.add_all(
+        AssetDagRunQueue(asset_id=asset_model.id, target_dag_id=dag_id, 
asset_event_id=event.id)
+        for event in queued_events
+    )
+    session.flush()
+    runner = SchedulerJobRunner(job=Job(), 
executors=[MockExecutor(do_update=False)])
+    runner._create_dag_runs_asset_triggered(dag_models=[dag_model], 
session=session)
+    session.flush()
+    runs = session.scalars(select(DagRun).where(DagRun.dag_id == 
dag_id).order_by(DagRun.id)).all()
+    assert len(runs) == first_count
+    assert session.scalar(select(func.count()).select_from(AssetDagRunQueue)) 
> 0
+    if first_count == max_active_runs:
+        runner._create_dag_runs_asset_triggered(dag_models=[dag_model], 
session=session)
+        assert (
+            
session.scalar(select(func.count()).select_from(DagRun).where(DagRun.dag_id == 
dag_id))
+            == first_count
+        )
+
+    for _ in range(3):
+        for run in runs:
+            run.state = DagRunState.SUCCESS
+        session.flush()
+        query, triggered_dates = DagModel.dags_needing_dagruns(session=session)
+        runner._create_dag_runs_asset_triggered(
+            dag_models=[model for model in query.all() if model.dag_id in 
triggered_dates], session=session
+        )
+        session.flush()
+        runs = session.scalars(select(DagRun).where(DagRun.dag_id == 
dag_id).order_by(DagRun.id)).all()
+
+    assert [run.consumed_asset_events for run in runs] == [[event] for event 
in events]
+    assert session.scalar(select(func.count()).select_from(AssetDagRunQueue)) 
== 0
+
+
[email protected]_serialized_dag
[email protected](DagModel, "NUM_DAGS_PER_DAGRUN_QUERY", 3)
[email protected]("batch_first_dag", [False, True])
+def test_asset_runs_share_creation_budget(dag_maker, session, batch_first_dag):
+    asset = Asset(name="shared-budget")
+    dag_ids = ["budget-consumer-1", "budget-consumer-2"]
+    for dag_id in dag_ids:
+        with dag_maker(
+            dag_id=dag_id,
+            schedule=AssetTriggeredTimetable(
+                assets=asset, batch_asset_events=batch_first_dag and dag_id == 
dag_ids[0]
+            ),
+            session=session,
+        ):
+            EmptyOperator(task_id="consume")
+    session.commit()
+    asset_model = session.scalar(select(AssetModel).where(AssetModel.uri == 
asset.uri))
+    events = [
+        AssetEvent(asset_id=asset_model.id, 
timestamp=timezone.parse("2026-10-01T00:00:00Z"))
+        for _ in range(3)
+    ]
+    session.add_all(events)
+    session.flush()
+    session.add_all(
+        AssetDagRunQueue(asset_id=asset_model.id, target_dag_id=dag_id, 
asset_event_id=event.id)
+        for dag_id in dag_ids
+        for event in events
+    )
+    session.flush()
+    query, triggered_dates = DagModel.dags_needing_dagruns(session=session)
+    models = [model for model in query.all() if model.dag_id in 
triggered_dates]
+    assert {model.dag_id for model in models} == set(dag_ids)
+    runner = SchedulerJobRunner(job=Job(), 
executors=[MockExecutor(do_update=False)])
+    runner._create_dag_runs_asset_triggered(dag_models=models, session=session)
+    session.flush()
+    counts = dict(session.execute(select(DagRun.dag_id, 
func.count()).group_by(DagRun.dag_id)).all())
+    assert counts == dict.fromkeys(dag_ids, 1)
+    assert session.scalar(select(func.count()).select_from(AssetDagRunQueue)) 
== (2 if batch_first_dag else 4)
+
+
 @pytest.mark.need_serialized_dag
 @pytest.mark.usefixtures("clear_asset_partition_rows")
 @pytest.mark.parametrize(
@@ -13807,6 +14031,7 @@ def 
test_consumer_dag_run_partition_date_is_none_when_task_key_diverges(
 
 @pytest.mark.need_serialized_dag
 @pytest.mark.usefixtures("clear_asset_partition_rows")
+@conf_vars({("scheduler", "batch_asset_events"): "True"})
 def test_consumer_dag_listen_to_two_partitioned_asset(
     dag_maker: DagMaker,
     session: Session,
@@ -13887,6 +14112,7 @@ def test_consumer_dag_listen_to_two_partitioned_asset(
 
 @pytest.mark.need_serialized_dag
 @pytest.mark.usefixtures("clear_asset_partition_rows")
+@conf_vars({("scheduler", "batch_asset_events"): "True"})
 def test_partitioned_dag_run_rollup_holds_until_window_complete(
     dag_maker: DagMaker,
     session: Session,
@@ -13945,6 +14171,7 @@ def 
test_partitioned_dag_run_rollup_holds_until_window_complete(
 
 @pytest.mark.need_serialized_dag
 @pytest.mark.usefixtures("clear_asset_partition_rows")
+@conf_vars({("scheduler", "batch_asset_events"): "True"})
 def test_partitioned_dag_run_segment_rollup_holds_until_all_segments_arrive(
     dag_maker: DagMaker,
     session: Session,
@@ -14013,6 +14240,7 @@ def 
test_partitioned_dag_run_segment_rollup_holds_until_all_segments_arrive(
 
 @pytest.mark.need_serialized_dag
 @pytest.mark.usefixtures("clear_asset_partition_rows")
+@conf_vars({("scheduler", "batch_asset_events"): "True"})
 def 
test_partitioned_dag_run_rollup_minimum_count_negative_fires_with_tolerated_gaps(
     dag_maker: DagMaker,
     session: Session,
@@ -14074,6 +14302,7 @@ def 
test_partitioned_dag_run_rollup_minimum_count_negative_fires_with_tolerated_
 
 @pytest.mark.need_serialized_dag
 @pytest.mark.usefixtures("clear_asset_partition_rows")
+@conf_vars({("scheduler", "batch_asset_events"): "True"})
 def test_partitioned_dag_run_rollup_minimum_count_fires_when_threshold_met(
     dag_maker: DagMaker,
     session: Session,
@@ -14186,6 +14415,7 @@ def 
test_partitioned_dag_run_rollup_treats_mapper_exception_as_not_satisfied(
 
 @pytest.mark.need_serialized_dag
 @pytest.mark.usefixtures("clear_asset_partition_rows")
+@conf_vars({("scheduler", "batch_asset_events"): "True"})
 def test_partitioned_dag_run_rollup_survives_scheduler_restart_partial_arrival(
     dag_maker: DagMaker,
     session: Session,
@@ -14553,6 +14783,7 @@ def 
test_partitioned_dag_run_fingerprint_compute_failure_does_not_clear_apdr(
 
 @pytest.mark.need_serialized_dag
 @pytest.mark.usefixtures("clear_asset_partition_rows")
+@conf_vars({("scheduler", "batch_asset_events"): "True"})
 def test_partitioned_dag_run_rollup_idempotent_with_duplicate_upstream_events(
     dag_maker: DagMaker,
     session: Session,
@@ -14631,6 +14862,7 @@ def 
test_partitioned_dag_run_rollup_idempotent_with_duplicate_upstream_events(
 
 @pytest.mark.need_serialized_dag
 @pytest.mark.usefixtures("clear_asset_partition_rows")
+@conf_vars({("scheduler", "batch_asset_events"): "True"})
 def test_consumer_dag_listen_to_two_partitioned_asset_with_key_1_mapper(
     dag_maker: DagMaker,
     session: Session,
@@ -15625,6 +15857,7 @@ class TestReapStaleConnectionTests:
         ),
     ],
 )
+@conf_vars({("scheduler", "batch_asset_events"): "True"})
 def test_partition_date_populated_on_dagrun(
     dag_maker: DagMaker,
     session: Session,
diff --git a/airflow-core/tests/unit/serialization/definitions/test_assets.py 
b/airflow-core/tests/unit/serialization/definitions/test_assets.py
index 473f645fb54..98ba48d0005 100644
--- a/airflow-core/tests/unit/serialization/definitions/test_assets.py
+++ b/airflow-core/tests/unit/serialization/definitions/test_assets.py
@@ -18,6 +18,8 @@
 
 from __future__ import annotations
 
+import pytest
+
 from airflow.api_fastapi.execution_api.datamodels.asset import AssetProfile
 from airflow.serialization.definitions.assets import (
     SerializedAsset,
@@ -36,6 +38,23 @@ asset1 = SerializedAsset(
 )
 
 
[email protected](
+    ("condition", "requires_batching"),
+    [
+        (asset1, False),
+        (SerializedAssetAlias(name="alias", group=""), False),
+        (SerializedAssetAll([]), False),
+        (SerializedAssetAll([asset1]), False),
+        (SerializedAssetAny([asset1, asset1]), False),
+        (SerializedAssetAll([asset1, asset1]), True),
+        (SerializedAssetAny([asset1, SerializedAssetAll([asset1, asset1])]), 
True),
+        (SerializedAssetAll([SerializedAssetAny([SerializedAssetAll([asset1, 
asset1])])]), True),
+    ],
+)
+def test_asset_condition_requires_batching(condition, requires_batching):
+    assert condition.requires_batching is requires_batching
+
+
 def test_asset_iter_assets():
     assert list(asset1.iter_assets()) == [
         (SerializedAssetUniqueKey("asset-1", "s3://bucket1/data1/"), asset1)
diff --git a/airflow-core/tests/unit/timetables/test_assets_timetable.py 
b/airflow-core/tests/unit/timetables/test_assets_timetable.py
index 5ab7667cd11..7f4b41e578c 100644
--- a/airflow-core/tests/unit/timetables/test_assets_timetable.py
+++ b/airflow-core/tests/unit/timetables/test_assets_timetable.py
@@ -19,6 +19,7 @@
 from __future__ import annotations
 
 from collections import defaultdict
+from contextlib import nullcontext
 from typing import Any
 
 import pytest
@@ -36,18 +37,64 @@ from airflow.sdk import (
     AssetOrTimeSchedule as SdkAssetOrTimeSchedule,
 )
 from airflow.sdk.bases.timetable import BaseTimetable
-from airflow.sdk.definitions.timetables.assets import AssetTriggeredTimetable 
as SdkAssetTriggeredTimetable
+from airflow.sdk.definitions.timetables.assets import (
+    AssetTriggeredTimetable as SdkAssetTriggeredTimetable,
+    PartitionedAssetTimetable as SdkPartitionedAssetTimetable,
+)
+from airflow.sdk.definitions.timetables.simple import NullTimetable as 
SdkNullTimetable
 from airflow.sdk.exceptions import AirflowTimetableInvalid
-from airflow.serialization.definitions.assets import SerializedAsset, 
SerializedAssetAll, SerializedAssetAny
+from airflow.serialization.decoders import decode_timetable
+from airflow.serialization.definitions.assets import (
+    SerializedAsset,
+    SerializedAssetAll,
+    SerializedAssetAny,
+    SerializedAssetBase,
+)
+from airflow.serialization.encoders import encode_timetable, 
ensure_serialized_asset
 from airflow.serialization.serialized_objects import DagSerialization
 from airflow.timetables.assets import (
     AssetAndTimeSchedule as CoreAssetAndTimeSchedule,
     AssetOrTimeSchedule as CoreAssetOrTimeSchedule,
 )
 from airflow.timetables.base import DagRunInfo, DataInterval, TimeRestriction, 
Timetable
-from airflow.timetables.simple import AssetTriggeredTimetable
+from airflow.timetables.simple import AssetTriggeredTimetable, NullTimetable, 
PartitionedAssetTimetable
 from airflow.utils.types import DagRunType
 
+from tests_common.test_utils.config import conf_vars
+
+
[email protected]("batch_asset_events", [False, True])
[email protected]("event_count", [0, 1, 3])
+def test_group_asset_events(batch_asset_events, event_count):
+    timetable = AssetTriggeredTimetable(Asset("a"), 
batch_asset_events=batch_asset_events)
+    triggered_date = DateTime(2026, 10, 7, tzinfo=UTC)
+    events = [
+        AssetEvent(asset_id=1, timestamp=triggered_date.subtract(hours=index + 
1))
+        for index in range(event_count)
+    ]
+
+    if batch_asset_events and events:
+        expected = [(triggered_date, events)]
+    else:
+        expected = [(event.timestamp, [event]) for event in events]
+    assert list(timetable.group_asset_events(events, triggered_date)) == 
expected
+
+
+def test_custom_asset_condition_requires_batching():
+    class CustomCondition(SerializedAssetBase):
+        @property
+        def requires_batching(self):
+            return True
+
+    condition = SerializedAssetAny([CustomCondition()])
+    with conf_vars({("scheduler", "batch_asset_events"): "False"}):
+        timetable = AssetTriggeredTimetable(condition)
+    assert timetable.batch_asset_events is True
+    timetable.validate()
+
+    with pytest.raises(AirflowTimetableInvalid, 
match="batch_asset_events=True"):
+        AssetTriggeredTimetable(condition, batch_asset_events=False).validate()
+
 
 class MockTimetable(Timetable):
     """
@@ -268,6 +315,7 @@ def test_serialization(sdk_asset_timetable: 
SdkAssetOrTimeSchedule, monkeypatch:
     )
     serialized = _serializer.serialize_timetable(sdk_asset_timetable)
     assert serialized == {
+        "batch_asset_events": False,
         "timetable": "mock_serialized_timetable",
         "asset_condition": {
             "__type": "asset_all",
@@ -491,6 +539,95 @@ def test_run_ordering_inheritance(core_asset_timetable) -> 
None:
     assert core_asset_timetable.run_ordering == 
AssetTriggeredTimetable.run_ordering
 
 
[email protected]("batch_asset_events", [True, False])
[email protected](
+    "timetable_type",
+    [
+        AssetTriggeredTimetable,
+        SdkAssetTriggeredTimetable,
+        PartitionedAssetTimetable,
+        SdkPartitionedAssetTimetable,
+        CoreAssetOrTimeSchedule,
+        SdkAssetOrTimeSchedule,
+    ],
+)
+def test_batch_asset_events_roundtrip(timetable_type, batch_asset_events):
+    asset = Asset("test")
+    kwargs = {}
+    if timetable_type is CoreAssetOrTimeSchedule:
+        kwargs["timetable"] = NullTimetable()
+    elif timetable_type is SdkAssetOrTimeSchedule:
+        kwargs["timetable"] = SdkNullTimetable()
+    timetable = timetable_type(assets=asset, 
batch_asset_events=batch_asset_events, **kwargs)
+
+    with conf_vars({("scheduler", "batch_asset_events"): str(not 
batch_asset_events)}):
+        serialized = encode_timetable(timetable)
+        deserialized = decode_timetable(serialized)
+
+    assert serialized["__var"]["batch_asset_events"] is batch_asset_events
+    assert deserialized.batch_asset_events is batch_asset_events
+    assert deserialized.asset_condition == ensure_serialized_asset(asset)
+
+
[email protected]("configured", [False, True])
[email protected]("explicit", [None, False, True])
[email protected](
+    "timetable_type", [AssetTriggeredTimetable, PartitionedAssetTimetable, 
CoreAssetOrTimeSchedule]
+)
+def test_batching_configuration(configured, explicit, timetable_type):
+    kwargs = {"timetable": NullTimetable()} if timetable_type is 
CoreAssetOrTimeSchedule else {}
+    if explicit is not None:
+        kwargs["batch_asset_events"] = explicit
+    with conf_vars({("scheduler", "batch_asset_events"): str(configured)}):
+        timetable = timetable_type(assets=Asset("test"), **kwargs)
+    assert timetable.batch_asset_events is (configured if explicit is None 
else explicit)
+
+
[email protected]("batch_asset_events", [False, True])
[email protected]("nested", [False, True])
[email protected](
+    "timetable_type", [AssetTriggeredTimetable, PartitionedAssetTimetable, 
CoreAssetOrTimeSchedule]
+)
+def test_compound_asset_condition_requires_batching(batch_asset_events, 
nested, timetable_type):
+    condition = Asset("a") & Asset("b")
+    if nested:
+        condition = Asset("c") | condition
+    kwargs = {"timetable": NullTimetable()} if timetable_type is 
CoreAssetOrTimeSchedule else {}
+    timetable = timetable_type(assets=condition, 
batch_asset_events=batch_asset_events, **kwargs)
+    expected = nullcontext() if batch_asset_events else 
pytest.raises(AirflowTimetableInvalid, match="AND")
+    with expected:
+        timetable.validate()
+
+
[email protected]("configured", [False, True])
[email protected](
+    "timetable_type", [AssetTriggeredTimetable, PartitionedAssetTimetable, 
CoreAssetOrTimeSchedule]
+)
+def test_compound_asset_condition_defaults_to_batching(configured, 
timetable_type):
+    kwargs = {"timetable": NullTimetable()} if timetable_type is 
CoreAssetOrTimeSchedule else {}
+    with conf_vars({("scheduler", "batch_asset_events"): str(configured)}):
+        timetable = timetable_type(assets=Asset("a") & Asset("b"), **kwargs)
+    assert timetable.batch_asset_events is True
+    timetable.validate()
+
+
+def test_or_condition_can_disable_batching():
+    AssetTriggeredTimetable(assets=Asset("a") | Asset("b"), 
batch_asset_events=False).validate()
+
+
[email protected]("configured", [False, True])
[email protected](
+    "timetable_type", [AssetTriggeredTimetable, PartitionedAssetTimetable, 
CoreAssetOrTimeSchedule]
+)
+def test_legacy_asset_timetable_defaults_to_batching(timetable_type, 
configured):
+    kwargs = {"timetable": NullTimetable()} if timetable_type is 
CoreAssetOrTimeSchedule else {}
+    serialized = encode_timetable(timetable_type(assets=Asset("test"), 
**kwargs))
+    del serialized["__var"]["batch_asset_events"]
+
+    with conf_vars({("scheduler", "batch_asset_events"): str(configured)}):
+        assert decode_timetable(serialized).batch_asset_events is True
+
+
 @pytest.mark.db_test
 class TestAssetConditionWithTimetable:
     @pytest.fixture(autouse=True)
@@ -567,6 +704,7 @@ class TestAssetConditionWithTimetable:
 
         serialized_timetable_dict = 
DagSerialization.to_dict(dag)["dag"]["timetable"]["__var"]
         assert serialized_timetable_dict == {
+            "batch_asset_events": True,
             "asset_condition": {
                 "__type": "asset_any",
                 "objects": [
diff --git a/airflow-core/tests/unit/timetables/test_partitioned_timetable.py 
b/airflow-core/tests/unit/timetables/test_partitioned_timetable.py
index 72dd2773bc9..8151426e3b8 100644
--- a/airflow-core/tests/unit/timetables/test_partitioned_timetable.py
+++ b/airflow-core/tests/unit/timetables/test_partitioned_timetable.py
@@ -18,7 +18,7 @@
 from __future__ import annotations
 
 from collections.abc import Callable, Iterable
-from contextlib import ExitStack
+from contextlib import ExitStack, nullcontext
 from typing import TYPE_CHECKING
 from unittest import mock
 
@@ -26,7 +26,7 @@ import pendulum
 import pytest
 
 from airflow._shared.module_loading import qualname
-from airflow.exceptions import InvalidPartitionKeyError
+from airflow.exceptions import AirflowTimetableInvalid, 
InvalidPartitionKeyError
 from airflow.partition_mappers.base import RollupMapper
 from airflow.partition_mappers.identity import IdentityMapper as IdentityMapper
 from airflow.partition_mappers.temporal import StartOfDayMapper
@@ -37,6 +37,8 @@ from airflow.serialization.encoders import 
ensure_serialized_asset
 from airflow.serialization.enums import DagAttributeTypes
 from airflow.timetables.simple import PartitionedAssetTimetable
 
+from tests_common.test_utils.config import conf_vars
+
 if TYPE_CHECKING:
     from airflow.partition_mappers.base import PartitionMapper
 
@@ -194,6 +196,7 @@ class TestPartitionedAssetTimetable:
             assets=ser_asset, partition_mapper_config={ser_asset: 
IdentityMapper()}
         )
         assert timetable.serialize() == {
+            "batch_asset_events": False,
             "asset_condition": {
                 "__type": DagAttributeTypes.ASSET,
                 "name": "test",
@@ -225,6 +228,7 @@ class TestPartitionedAssetTimetable:
     def test_deserialize(self):
         timetable = PartitionedAssetTimetable.deserialize(
             {
+                "batch_asset_events": True,
                 "asset_condition": {
                     "__type": DagAttributeTypes.ASSET,
                     "name": "test",
@@ -257,6 +261,52 @@ class TestPartitionedAssetTimetable:
         assert timetable.asset_condition == ser_asset
         assert isinstance(timetable.default_partition_mapper, IdentityMapper)
         assert isinstance(timetable.partition_mapper_config[ser_asset], 
IdentityMapper)
+        assert timetable.batch_asset_events is True
+
+    def test_serialize_deserialize_batch_asset_events_false(self):
+        """Serialize/deserialize round-trip preserves 
batch_asset_events=False."""
+        ser_asset = ensure_serialized_asset(Asset("test"))
+        timetable = PartitionedAssetTimetable(
+            assets=ser_asset,
+            partition_mapper_config={ser_asset: IdentityMapper()},
+            batch_asset_events=False,
+        )
+        serialized = timetable.serialize()
+        assert serialized["batch_asset_events"] is False
+
+        deserialized = PartitionedAssetTimetable.deserialize(serialized)
+        assert deserialized.batch_asset_events is False
+
+    @pytest.mark.parametrize("batch_asset_events", [False, True])
+    @pytest.mark.parametrize("use_default_mapper", [False, True])
+    def test_rollup_requires_batching(self, batch_asset_events, 
use_default_mapper):
+        asset = ensure_serialized_asset(Asset("test"))
+        mapper = RollupMapper(upstream_mapper=StartOfDayMapper(), 
window=DayWindow())
+        kwargs = (
+            {"default_partition_mapper": mapper}
+            if use_default_mapper
+            else {"partition_mapper_config": {asset: mapper}}
+        )
+        timetable = PartitionedAssetTimetable(assets=asset, 
batch_asset_events=batch_asset_events, **kwargs)
+        expected = (
+            nullcontext() if batch_asset_events else 
pytest.raises(AirflowTimetableInvalid, match="rollups")
+        )
+        with expected:
+            timetable.validate()
+
+    @pytest.mark.parametrize("use_default_mapper", [False, True])
+    def test_rollup_defaults_to_batching(self, use_default_mapper):
+        asset = ensure_serialized_asset(Asset("test"))
+        mapper = RollupMapper(upstream_mapper=StartOfDayMapper(), 
window=DayWindow())
+        kwargs = (
+            {"default_partition_mapper": mapper}
+            if use_default_mapper
+            else {"partition_mapper_config": {asset: mapper}}
+        )
+        with conf_vars({("scheduler", "batch_asset_events"): "False"}):
+            timetable = PartitionedAssetTimetable(assets=asset, **kwargs)
+        assert timetable.batch_asset_events is True
+        timetable.validate()
 
     @pytest.mark.parametrize(
         "asset_like",
diff --git a/providers/openlineage/tests/unit/openlineage/plugins/test_utils.py 
b/providers/openlineage/tests/unit/openlineage/plugins/test_utils.py
index 159ac9d316f..88006cf74dc 100644
--- a/providers/openlineage/tests/unit/openlineage/plugins/test_utils.py
+++ b/providers/openlineage/tests/unit/openlineage/plugins/test_utils.py
@@ -50,7 +50,11 @@ from airflow.utils.types import DagRunType
 from tests_common.test_utils.compat import (
     BashOperator,
 )
-from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS, 
AIRFLOW_V_3_1_PLUS
+from tests_common.test_utils.version_compat import (
+    AIRFLOW_V_3_0_PLUS,
+    AIRFLOW_V_3_1_PLUS,
+    AIRFLOW_V_3_4_PLUS,
+)
 
 if AIRFLOW_V_3_1_PLUS:
     from airflow.models.dag import get_next_data_interval
@@ -437,7 +441,7 @@ def test_serialize_timetable_complex_with_alias():
     dag.timetable = AssetTriggeredTimetable(asset)
     dag_info = DagInfo(dag)
 
-    assert dag_info.timetable == {
+    expected: dict = {
         "asset_condition": {
             "__type": DagAttributeTypes.ASSET_ANY,
             "objects": [
@@ -478,38 +482,47 @@ def test_serialize_timetable_complex_with_alias():
                     ],
                 },
             ],
-        }
+        },
     }
+    if AIRFLOW_V_3_4_PLUS:
+        expected["batch_asset_events"] = True
+    assert dag_info.timetable == expected
 
 
 @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="This test checks 
serialization only in 3.0 conditions")
 def test_serialize_timetable_single_asset():
     dag = DAG(dag_id="test", start_date=datetime.datetime(2025, 1, 1), 
schedule=Asset("a"))
     dag_info = DagInfo(dag)
-    assert dag_info.timetable == {
+    expected: dict = {
         "asset_condition": {
             "__type": DagAttributeTypes.ASSET,
             "uri": "a",
             "name": "a",
             "group": "asset",
             "extra": {},
-        }
+        },
     }
+    if AIRFLOW_V_3_4_PLUS:
+        expected["batch_asset_events"] = False
+    assert dag_info.timetable == expected
 
 
 @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="This test checks 
serialization only in 3.0 conditions")
 def test_serialize_timetable_list_of_assets():
     dag = DAG(dag_id="test", start_date=datetime.datetime(2025, 1, 1), 
schedule=[Asset("a"), Asset("b")])
     dag_info = DagInfo(dag)
-    assert dag_info.timetable == {
+    expected: dict = {
         "asset_condition": {
             "__type": DagAttributeTypes.ASSET_ALL,
             "objects": [
                 {"__type": DagAttributeTypes.ASSET, "uri": "a", "name": "a", 
"group": "asset", "extra": {}},
                 {"__type": DagAttributeTypes.ASSET, "uri": "b", "name": "b", 
"group": "asset", "extra": {}},
             ],
-        }
+        },
     }
+    if AIRFLOW_V_3_4_PLUS:
+        expected["batch_asset_events"] = True
+    assert dag_info.timetable == expected
 
 
 @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="This test checks 
serialization only in 3.0 conditions")
@@ -521,7 +534,7 @@ def 
test_serialize_timetable_with_complex_logical_condition():
         & (Asset("ds3") | Asset("ds4", extra={"another_extra": 345})),
     )
     dag_info = DagInfo(dag)
-    assert dag_info.timetable == {
+    expected: dict = {
         "asset_condition": {
             "__type": DagAttributeTypes.ASSET_ALL,
             "objects": [
@@ -564,8 +577,11 @@ def 
test_serialize_timetable_with_complex_logical_condition():
                     ],
                 },
             ],
-        }
+        },
     }
+    if AIRFLOW_V_3_4_PLUS:
+        expected["batch_asset_events"] = True
+    assert dag_info.timetable == expected
 
 
 @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="This test checks 
serialization only in 3.0 conditions")
@@ -583,7 +599,7 @@ def 
test_serialize_timetable_with_dataset_or_time_schedule():
         ),
     )
     dag_info = DagInfo(dag)
-    assert dag_info.timetable == {
+    expected: dict = {
         "timetable": {
             Encoding.TYPE: "airflow.timetables.trigger.CronTriggerTimetable",
             Encoding.VAR: {
@@ -637,6 +653,9 @@ def 
test_serialize_timetable_with_dataset_or_time_schedule():
             ],
         },
     }
+    if AIRFLOW_V_3_4_PLUS:
+        expected["batch_asset_events"] = True
+    assert dag_info.timetable == expected
 
 
 @pytest.mark.skipif(
diff --git a/providers/openlineage/tests/unit/openlineage/utils/test_utils.py 
b/providers/openlineage/tests/unit/openlineage/utils/test_utils.py
index 3fa10b1e1ab..4078993937e 100644
--- a/providers/openlineage/tests/unit/openlineage/utils/test_utils.py
+++ b/providers/openlineage/tests/unit/openlineage/utils/test_utils.py
@@ -2645,7 +2645,7 @@ class TestDagInfoAirflow3:
         )
 
         result = DagInfo(dag)
-        assert dict(result) == {
+        expected: dict = {
             "dag_id": "dag_id",
             "description": None,
             "fileloc": pathlib.Path(__file__).resolve().as_posix(),
@@ -2660,10 +2660,13 @@ class TestDagInfoAirflow3:
                     "name": "uri1",
                     "group": "asset",
                     "extra": {"a": 1},
-                }
+                },
             },
             "timetable_summary": "Asset",
         }
+        if AIRFLOW_V_3_4_PLUS:
+            expected["timetable"]["batch_asset_events"] = False
+        assert dict(result) == expected
 
     def test_dag_info_schedule_list_single_assets(self):
         dag = DAG(
@@ -2673,7 +2676,7 @@ class TestDagInfoAirflow3:
         )
 
         result = DagInfo(dag)
-        assert dict(result) == {
+        expected: dict = {
             "dag_id": "dag_id",
             "description": None,
             "fileloc": pathlib.Path(__file__).resolve().as_posix(),
@@ -2693,10 +2696,13 @@ class TestDagInfoAirflow3:
                             "extra": {"a": 1},
                         }
                     ],
-                }
+                },
             },
             "timetable_summary": "Asset",
         }
+        if AIRFLOW_V_3_4_PLUS:
+            expected["timetable"]["batch_asset_events"] = False
+        assert dict(result) == expected
 
     def test_dag_info_schedule_list_two_assets(self):
         dag = DAG(
@@ -2706,7 +2712,7 @@ class TestDagInfoAirflow3:
         )
 
         result = DagInfo(dag)
-        assert dict(result) == {
+        expected: dict = {
             "dag_id": "dag_id",
             "description": None,
             "fileloc": pathlib.Path(__file__).resolve().as_posix(),
@@ -2727,10 +2733,13 @@ class TestDagInfoAirflow3:
                         },
                         {"__type": "asset", "uri": "uri2", "name": "uri2", 
"group": "asset", "extra": {}},
                     ],
-                }
+                },
             },
             "timetable_summary": "Asset",
         }
+        if AIRFLOW_V_3_4_PLUS:
+            expected["timetable"]["batch_asset_events"] = True
+        assert dict(result) == expected
 
     def test_dag_info_schedule_assets_logical_condition(self):
         dag = DAG(
@@ -2740,7 +2749,7 @@ class TestDagInfoAirflow3:
         )
 
         result = DagInfo(dag)
-        assert dict(result) == {
+        expected: dict = {
             "dag_id": "dag_id",
             "description": None,
             "fileloc": pathlib.Path(__file__).resolve().as_posix(),
@@ -2791,10 +2800,13 @@ class TestDagInfoAirflow3:
                             ],
                         },
                     ],
-                }
+                },
             },
             "timetable_summary": "Asset",
         }
+        if AIRFLOW_V_3_4_PLUS:
+            expected["timetable"]["batch_asset_events"] = True
+        assert dict(result) == expected
 
     def test_dag_info_schedule_asset_or_time_schedule(self):
         from airflow.timetables.assets import AssetOrTimeSchedule
@@ -2809,7 +2821,7 @@ class TestDagInfoAirflow3:
         )
 
         result = DagInfo(dag)
-        assert dict(result) == {
+        expected: dict = {
             "dag_id": "dag_id",
             "description": None,
             "fileloc": pathlib.Path(__file__).resolve().as_posix(),
@@ -2873,6 +2885,9 @@ class TestDagInfoAirflow3:
             },
             "timetable_summary": "Asset or */4 3 * * *",
         }
+        if AIRFLOW_V_3_4_PLUS:
+            expected["timetable"]["batch_asset_events"] = True
+        assert dict(result) == expected
 
 
 class TestDagRunInfoDeadlines:
diff --git a/task-sdk/docs/api.rst b/task-sdk/docs/api.rst
index 78809e802ef..1de617447fd 100644
--- a/task-sdk/docs/api.rst
+++ b/task-sdk/docs/api.rst
@@ -208,6 +208,8 @@ Timetables
 
 .. autoapiclass:: airflow.sdk.AssetOrTimeSchedule
 
+.. autoapiclass:: airflow.sdk.AssetTriggeredTimetable
+
 .. autoapiclass:: airflow.sdk.CronDataIntervalTimetable
 
 .. autoapiclass:: airflow.sdk.CronTriggerTimetable
diff --git a/task-sdk/src/airflow/sdk/__init__.py 
b/task-sdk/src/airflow/sdk/__init__.py
index 91822202553..c163df52577 100644
--- a/task-sdk/src/airflow/sdk/__init__.py
+++ b/task-sdk/src/airflow/sdk/__init__.py
@@ -28,6 +28,7 @@ __all__ = [
     "AssetAny",
     "AssetAndTimeSchedule",
     "AssetOrTimeSchedule",
+    "AssetTriggeredTimetable",
     "AssetWatcher",
     "AsyncCallback",
     "BaseAsyncOperator",
@@ -210,6 +211,7 @@ if TYPE_CHECKING:
     from airflow.sdk.definitions.timetables.assets import (
         AssetAndTimeSchedule,
         AssetOrTimeSchedule,
+        AssetTriggeredTimetable,
         PartitionedAssetTimetable,
         PartitionedAtRuntime,
     )
@@ -242,6 +244,7 @@ __lazy_imports: dict[str, str] = {
     "AssetAndTimeSchedule": ".definitions.timetables.assets",
     "AssetAny": ".definitions.asset",
     "AssetOrTimeSchedule": ".definitions.timetables.assets",
+    "AssetTriggeredTimetable": ".definitions.timetables.assets",
     "AssetWatcher": ".definitions.asset",
     "AsyncCallback": ".definitions.callback",
     "BaseAsyncOperator": ".bases.operator",
diff --git a/task-sdk/src/airflow/sdk/__init__.pyi 
b/task-sdk/src/airflow/sdk/__init__.pyi
index c3a94d30a35..1f9e10b5f5a 100644
--- a/task-sdk/src/airflow/sdk/__init__.pyi
+++ b/task-sdk/src/airflow/sdk/__init__.pyi
@@ -114,6 +114,7 @@ from airflow.sdk.definitions.template import literal as 
literal
 from airflow.sdk.definitions.timetables.assets import (
     AssetAndTimeSchedule,
     AssetOrTimeSchedule,
+    AssetTriggeredTimetable,
     PartitionedAssetTimetable,
     PartitionedAtRuntime,
 )
@@ -147,6 +148,7 @@ __all__ = [
     "AssetAndTimeSchedule",
     "AssetAny",
     "AssetOrTimeSchedule",
+    "AssetTriggeredTimetable",
     "AssetWatcher",
     "AsyncCallback",
     "BaseAsyncOperator",
diff --git a/task-sdk/src/airflow/sdk/definitions/asset/__init__.py 
b/task-sdk/src/airflow/sdk/definitions/asset/__init__.py
index 9f91bd4a78a..a5318804b68 100644
--- a/task-sdk/src/airflow/sdk/definitions/asset/__init__.py
+++ b/task-sdk/src/airflow/sdk/definitions/asset/__init__.py
@@ -231,6 +231,11 @@ class BaseAsset:
     :meta private:
     """
 
+    @property
+    def requires_batching(self) -> bool:
+        """Whether this condition needs multiple asset events to be 
satisfied."""
+        return False
+
     def __or__(self, other: BaseAsset) -> BaseAsset:
         if not isinstance(other, BaseAsset):
             return NotImplemented
@@ -487,6 +492,10 @@ class AssetBooleanCondition(BaseAsset):
     def __hash__(self) -> int:
         return hash(tuple(self.objects))
 
+    @property
+    def requires_batching(self) -> bool:
+        return any(obj.requires_batching for obj in self.objects)
+
 
 class AssetAny(AssetBooleanCondition):
     """Use to combine assets schedule references in an "or" relationship."""
@@ -506,6 +515,10 @@ class AssetAll(AssetBooleanCondition):
 
     agg_func = all  # type: ignore[assignment]
 
+    @property
+    def requires_batching(self) -> bool:
+        return len(self.objects) > 1 or super().requires_batching
+
     def __and__(self, other: BaseAsset) -> BaseAsset:
         if not isinstance(other, BaseAsset):
             return NotImplemented
diff --git a/task-sdk/src/airflow/sdk/definitions/timetables/assets.py 
b/task-sdk/src/airflow/sdk/definitions/timetables/assets.py
index bc09cd8f2b4..0ba492a7b0d 100644
--- a/task-sdk/src/airflow/sdk/definitions/timetables/assets.py
+++ b/task-sdk/src/airflow/sdk/definitions/timetables/assets.py
@@ -24,6 +24,7 @@ import attrs
 from airflow.sdk.bases.timetable import BaseTimetable
 from airflow.sdk.definitions.asset import AssetAll, BaseAsset
 from airflow.sdk.definitions.partition_mappers.identity import IdentityMapper
+from airflow.sdk.exceptions import AirflowTimetableInvalid
 
 if TYPE_CHECKING:
     from collections.abc import Collection
@@ -32,28 +33,64 @@ if TYPE_CHECKING:
     from airflow.sdk.definitions.partition_mappers.base import PartitionMapper
 
 
+def _get_default_batch_asset_events(timetable: AssetTriggeredTimetable) -> 
bool:
+    from airflow.sdk.configuration import conf
+
+    return (
+        conf.getboolean("scheduler", "batch_asset_events", fallback=False)
+        or timetable.get_batching_requirement() is not None
+    )
+
+
 @attrs.define
 class AssetTriggeredTimetable(BaseTimetable):
     """
-    Timetable that never schedules anything.
+    Schedule a Dag when its asset condition is satisfied.
 
-    This should not be directly used anywhere, but only set if a DAG is 
triggered by assets.
-
-    :meta private:
+    :param assets: The asset expression that triggers the Dag.
+    :param batch_asset_events: Consume queued events together in one Dag run. 
Defaults
+        to ``[scheduler] batch_asset_events``, which is false, except that 
conditions
+        combining multiple assets with ``&`` and ``RollupMapper`` partition 
mappers always
+        default to batching. Passing ``False`` explicitly for those is 
rejected.
     """
 
     asset_triggered = True
     asset_condition: BaseAsset = attrs.field(alias="assets")
+    batch_asset_events: bool = attrs.field(
+        default=attrs.Factory(_get_default_batch_asset_events, 
takes_self=True), kw_only=True
+    )
+
+    def get_batching_requirement(self) -> str | None:
+        """Return why this timetable cannot run without event batching, or 
``None``."""
+        if self.asset_condition.requires_batching:
+            return "Asset AND conditions require batch_asset_events=True"
+        return None
+
+    def validate(self) -> None:
+        if not self.batch_asset_events and (reason := 
self.get_batching_requirement()):
+            raise AirflowTimetableInvalid(reason)
 
 
 @attrs.define
 class PartitionedAssetTimetable(AssetTriggeredTimetable):
     """Asset-driven timetable that listens for partitioned assets."""
 
-    asset_condition: BaseAsset = attrs.field(alias="assets")
     partition_mapper_config: dict[BaseAsset, PartitionMapper] = 
attrs.field(factory=dict)
     default_partition_mapper: PartitionMapper = IdentityMapper()
 
+    # The factory needs the partition mappers to be initialized first.
+    batch_asset_events: bool = attrs.field(
+        default=attrs.Factory(_get_default_batch_asset_events, 
takes_self=True), kw_only=True
+    )
+
+    def get_batching_requirement(self) -> str | None:
+        if any(
+            mapper.is_rollup
+            for mapper in (self.default_partition_mapper, 
*self.partition_mapper_config.values())
+        ):
+            return "Partition rollups require batch_asset_events=True"
+        return super().get_batching_requirement()
+
 
 class PartitionedAtRuntime(BaseTimetable):
     """Marker timetable indicating that partition key(s) are determined at 
runtime."""
@@ -82,6 +119,10 @@ class AssetOrTimeSchedule(AssetTriggeredTimetable):
     asset_condition: BaseAsset = attrs.field(alias="assets", 
converter=_coerce_assets)
     timetable: BaseTimetable
 
+    batch_asset_events: bool = attrs.field(
+        default=attrs.Factory(_get_default_batch_asset_events, 
takes_self=True), kw_only=True
+    )
+
     def __attrs_post_init__(self) -> None:
         self.active_runs_limit = self.timetable.active_runs_limit
         self.can_be_scheduled = self.timetable.can_be_scheduled
diff --git a/task-sdk/tests/task_sdk/definitions/test_asset.py 
b/task-sdk/tests/task_sdk/definitions/test_asset.py
index 38c9cd2064f..413d0f4a02a 100644
--- a/task-sdk/tests/task_sdk/definitions/test_asset.py
+++ b/task-sdk/tests/task_sdk/definitions/test_asset.py
@@ -45,6 +45,22 @@ from tests_common.test_utils.dag import create_scheduler_dag
 ASSET_MODULE_PATH = "airflow.sdk.definitions.asset"
 
 
[email protected](
+    ("condition", "requires_batching"),
+    [
+        (Asset("a"), False),
+        (AssetAll(), False),
+        (AssetAll(Asset("a")), False),
+        (Asset("a") | Asset("b"), False),
+        (Asset("a") & Asset("b"), True),
+        (Asset("a") | (Asset("b") & Asset("c")), True),
+        (AssetAll(Asset("a") | (Asset("b") & Asset("c"))), True),
+    ],
+)
+def test_asset_condition_requires_batching(condition, requires_batching):
+    assert condition.requires_batching is requires_batching
+
+
 @pytest.mark.parametrize(
     ("sql_conn_value", "name", "should_raise"),
     [
diff --git a/task-sdk/tests/task_sdk/definitions/timetables/test_assets.py 
b/task-sdk/tests/task_sdk/definitions/timetables/test_assets.py
new file mode 100644
index 00000000000..2c5fee02206
--- /dev/null
+++ b/task-sdk/tests/task_sdk/definitions/timetables/test_assets.py
@@ -0,0 +1,166 @@
+# 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 contextlib import nullcontext
+
+import pytest
+
+from airflow.sdk import (
+    DAG,
+    Asset,
+    AssetOrTimeSchedule,
+    AssetTriggeredTimetable,
+    DayWindow,
+    IdentityMapper,
+    PartitionedAssetTimetable,
+    RollupMapper,
+    StartOfDayMapper,
+)
+from airflow.sdk.definitions.timetables.simple import NullTimetable
+from airflow.sdk.exceptions import AirflowTimetableInvalid
+
+from tests_common.test_utils.config import conf_vars
+
+
[email protected]("batch_asset_events", [True, False])
+def 
test_partitioned_timetable_preserves_positional_mapper_arguments(batch_asset_events):
+    asset = Asset("test")
+    mapper = IdentityMapper()
+    mapping = {asset: mapper}
+    timetable = PartitionedAssetTimetable(asset, mapping, mapper, 
batch_asset_events=batch_asset_events)
+
+    assert timetable.asset_condition == asset
+    assert timetable.partition_mapper_config == mapping
+    assert timetable.default_partition_mapper is mapper
+    assert timetable.batch_asset_events is batch_asset_events
+
+
[email protected]("configured", [False, True])
[email protected]("explicit", [None, False, True])
[email protected](
+    "timetable_type", [AssetTriggeredTimetable, PartitionedAssetTimetable, 
AssetOrTimeSchedule]
+)
+def test_batching_configuration(configured, explicit, timetable_type):
+    kwargs = {"timetable": NullTimetable()} if timetable_type is 
AssetOrTimeSchedule else {}
+    if explicit is not None:
+        kwargs["batch_asset_events"] = explicit
+    with conf_vars({("scheduler", "batch_asset_events"): str(configured)}):
+        timetable = timetable_type(assets=Asset("test"), **kwargs)
+    assert timetable.batch_asset_events is (configured if explicit is None 
else explicit)
+
+
+def test_asset_schedules_do_not_batch_by_default():
+    dag = DAG("default-asset-batching", schedule=Asset("test"))
+    assert dag.timetable.batch_asset_events is False
+
+
[email protected]("batch_asset_events", [False, True])
[email protected](
+    "timetable_type", [AssetTriggeredTimetable, PartitionedAssetTimetable, 
AssetOrTimeSchedule]
+)
[email protected]("nested", [False, True])
+def test_compound_asset_condition_requires_batching(batch_asset_events, 
timetable_type, nested):
+    condition = Asset("a") & Asset("b")
+    if nested:
+        condition = Asset("c") | condition
+    kwargs = {"timetable": NullTimetable()} if timetable_type is 
AssetOrTimeSchedule else {}
+    dag = DAG(
+        "compound-assets",
+        schedule=timetable_type(assets=condition, 
batch_asset_events=batch_asset_events, **kwargs),
+    )
+    expected = nullcontext() if batch_asset_events else 
pytest.raises(AirflowTimetableInvalid, match="AND")
+    with expected:
+        dag.validate()
+
+
[email protected]("batch_asset_events", [False, True])
[email protected]("use_default_mapper", [False, True])
+def test_rollup_requires_batching(batch_asset_events, use_default_mapper):
+    asset = Asset("test")
+    mapper = RollupMapper(upstream_mapper=StartOfDayMapper(), 
window=DayWindow())
+    kwargs = (
+        {"default_partition_mapper": mapper}
+        if use_default_mapper
+        else {"partition_mapper_config": {asset: mapper}}
+    )
+    timetable = PartitionedAssetTimetable(assets=asset, 
batch_asset_events=batch_asset_events, **kwargs)
+    expected = (
+        nullcontext() if batch_asset_events else 
pytest.raises(AirflowTimetableInvalid, match="rollups")
+    )
+    with expected:
+        timetable.validate()
+
+
[email protected]("configured", [False, True])
[email protected](
+    "timetable_type", [AssetTriggeredTimetable, PartitionedAssetTimetable, 
AssetOrTimeSchedule]
+)
+def test_compound_asset_condition_defaults_to_batching(configured, 
timetable_type):
+    kwargs = {"timetable": NullTimetable()} if timetable_type is 
AssetOrTimeSchedule else {}
+    with conf_vars({("scheduler", "batch_asset_events"): str(configured)}):
+        timetable = timetable_type(assets=Asset("a") & Asset("b"), **kwargs)
+    assert timetable.batch_asset_events is True
+    timetable.validate()
+
+
+def test_asset_list_schedule_defaults_to_batching():
+    dag = DAG("asset-list-schedule", schedule=[Asset("a"), Asset("b")])
+    assert dag.timetable.batch_asset_events is True
+    dag.validate()
+
+
[email protected]("use_default_mapper", [False, True])
+def test_rollup_defaults_to_batching(use_default_mapper):
+    asset = Asset("test")
+    mapper = RollupMapper(upstream_mapper=StartOfDayMapper(), 
window=DayWindow())
+    kwargs = (
+        {"default_partition_mapper": mapper}
+        if use_default_mapper
+        else {"partition_mapper_config": {asset: mapper}}
+    )
+    with conf_vars({("scheduler", "batch_asset_events"): "False"}):
+        timetable = PartitionedAssetTimetable(assets=asset, **kwargs)
+    assert timetable.batch_asset_events is True
+    timetable.validate()
+
+
+def test_or_condition_can_disable_batching():
+    timetable = AssetTriggeredTimetable(assets=Asset("a") | Asset("b"), 
batch_asset_events=False)
+    timetable.validate()
+
+
+def test_asset_or_time_schedule_list_defaults_to_batching():
+    timetable = AssetOrTimeSchedule(assets=[Asset("a"), Asset("b")], 
timetable=NullTimetable())
+    assert timetable.batch_asset_events is True
+    timetable.validate()
+
+
+def test_custom_asset_condition_requires_batching():
+    class CustomAsset(Asset):
+        @property
+        def requires_batching(self):
+            return True
+
+    condition = Asset("a") | CustomAsset("b")
+    with conf_vars({("scheduler", "batch_asset_events"): "False"}):
+        timetable = AssetTriggeredTimetable(condition)
+    assert timetable.batch_asset_events is True
+    timetable.validate()
+
+    with pytest.raises(AirflowTimetableInvalid, 
match="batch_asset_events=True"):
+        AssetTriggeredTimetable(condition, batch_asset_events=False).validate()

Reply via email to