jason810496 commented on code in PR #66854:
URL: https://github.com/apache/airflow/pull/66854#discussion_r3610071679
##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py:
##########
@@ -622,6 +646,95 @@ def _validate_outlet_event_partition_keys(outlet_events:
list[dict[str, Any]]) -
)
+# Lock timeout (seconds) for the opportunistic synchronous asset-event write.
Kept short so a
+# task completion sheds to the durable queue quickly under asset-row
contention rather than
+# holding an API-server worker. This only bounds lock waits; an uncontended
write does not wait
+# on locks and so is unaffected by the timeout.
+_ASSET_SYNC_LOCK_TIMEOUT = 2
+
+
+def _register_asset_changes_or_enqueue(
Review Comment:
Why not make the `_register_asset_changes_or_enqueue` itself only append the
`AssetEventQueue` record for further processing and skip the
`TI.register_asset_changes_in_db` at all?
##########
airflow-core/docs/authoring-and-scheduling/asset-scheduling.rst:
##########
@@ -51,6 +51,17 @@ In addition to scheduling Dags based on time, you can also
schedule Dags to run
:ref:`asset_definitions` for how to declare assets.
+.. note::
+
+ Asset events are registered asynchronously. When a task succeeds, Airflow
registers its
+ asset events shortly afterwards rather than strictly inside the
task-completion request,
+ usually within one scheduler loop and longer under heavy load or database
contention.
+ Downstream asset-scheduled Dags are therefore triggered after a brief,
eventually
+ consistent delay. The events are never dropped: if the registration cannot
complete
+ inline it is handed to the scheduler, which retries until it succeeds. Use
the
+ ``[scheduler] asset_event_queue_drain_interval`` option to tune how often
the scheduler
+ processes deferred asset events.
+
Review Comment:
It would be nice to point out the new limitation for the local `DAG.test`
that the Asset-triggered downstream runs can silently never be scheduled. (Not
sure do we even list out all the limitation of `DAG.test` or not).
##########
airflow-core/src/airflow/jobs/scheduler_job_runner.py:
##########
@@ -3676,6 +3682,88 @@ def _remove_unreferenced_triggers(self, *, session:
Session = NEW_SESSION) -> No
.execution_options(synchronize_session="fetch")
)
+ @provide_session
+ def _drain_asset_event_queue(self, *, session: Session = NEW_SESSION) ->
None:
+ """
+ Process pending asset-event registrations enqueued by the execution
API.
+
+ The task-success path enqueues an
:class:`~airflow.models.asset.AssetEventQueue` row when
+ synchronous asset-event registration is skipped or fails under
database lock contention.
+ Here we claim pending rows one at a time (``FOR UPDATE SKIP LOCKED``,
so multiple schedulers
+ do not collide), (re)run ``register_asset_changes_in_db`` to create
the ``AssetEvent`` and
+ ``AssetDagRunQueue`` rows, and delete the queue row in the same
transaction so it is
+ processed exactly once. A row that keeps failing is retried on later
passes until it exceeds
+ ``asset_event_queue_max_attempts``, after which it is left in place
(parked) and surfaced via
+ the ``asset.event_queue.failures`` metric for an operator to inspect.
+ """
+ from airflow.api_fastapi.execution_api.datamodels.asset import
AssetProfile
+
+ batch_size = conf.getint("scheduler", "asset_event_queue_batch_size",
fallback=100)
+ max_attempts = conf.getint("scheduler",
"asset_event_queue_max_attempts", fallback=5)
Review Comment:
The config should be set at class attribute level so that we won't retrieve
the conf every time when the `_drain_asset_event_queue` tick (get `conf` might
access the secret backend).
##########
airflow-core/src/airflow/jobs/scheduler_job_runner.py:
##########
@@ -3676,6 +3682,88 @@ def _remove_unreferenced_triggers(self, *, session:
Session = NEW_SESSION) -> No
.execution_options(synchronize_session="fetch")
)
+ @provide_session
+ def _drain_asset_event_queue(self, *, session: Session = NEW_SESSION) ->
None:
+ """
+ Process pending asset-event registrations enqueued by the execution
API.
+
+ The task-success path enqueues an
:class:`~airflow.models.asset.AssetEventQueue` row when
+ synchronous asset-event registration is skipped or fails under
database lock contention.
+ Here we claim pending rows one at a time (``FOR UPDATE SKIP LOCKED``,
so multiple schedulers
+ do not collide), (re)run ``register_asset_changes_in_db`` to create
the ``AssetEvent`` and
+ ``AssetDagRunQueue`` rows, and delete the queue row in the same
transaction so it is
+ processed exactly once. A row that keeps failing is retried on later
passes until it exceeds
+ ``asset_event_queue_max_attempts``, after which it is left in place
(parked) and surfaced via
+ the ``asset.event_queue.failures`` metric for an operator to inspect.
Review Comment:
I don't feel the current batch implementation is right.
IIUC, we should fetch the current batch in single query and commit in the
same session (we shouldn't call the explicit `commit` or `rollback` within the
function decorated by `provide_session` ).
Somehow like:
```python
def _drain_asset_event_queue(self, *, session: Session = NEW_SESSION) ->
None:
from airflow.api_fastapi.execution_api.datamodels.asset import
AssetProfile
batch_size = conf.getint("scheduler", "asset_event_queue_batch_size",
fallback=100)
max_attempts = conf.getint("scheduler",
"asset_event_queue_max_attempts", fallback=5)
rows = session.scalars(
with_row_locks(
select(AssetEventQueue)
.where(AssetEventQueue.attempts < max_attempts)
.order_by(AssetEventQueue.created_at)
.limit(batch_size),
of=AssetEventQueue,
session=session,
skip_locked=True,
)
).all()
processed = 0
for row in rows:
try:
with session.begin_nested():
ti = session.get(TaskInstance, row.ti_id)
if ti is not None:
task_outlets = [AssetProfile.model_validate(o) for o in
row.task_outlets]
TaskInstance.register_asset_changes_in_db(
ti, task_outlets, row.outlet_events, session=session
)
session.delete(row)
processed += 1
except Exception:
with session.begin_nested():
row.attempts += 1
if row.attempts >= max_attempts:
self.log.error("Giving up on asset-event registration for TI
%s after %s attempts", row.ti_id, row.attempts)
stats.incr("asset.event_queue.failures")
else:
self.log.warning("Asset-event registration for TI %s failed;
will retry (attempt %s)", row.ti_id, row.attempts)
session.commit()
if processed:
stats.incr("asset.event_queue.processed", processed)
stats.gauge("asset.event_queue.pending",
session.scalar(select(func.count()).select_from(AssetEventQueue)) or 0)
```
##########
airflow-core/src/airflow/models/asset.py:
##########
@@ -788,6 +789,45 @@ def __repr__(self):
return f"{self.__class__.__name__}({', '.join(args)})"
+class AssetEventQueue(Base):
+ """
+ Pending asset-event registrations awaiting processing by the scheduler.
+
+ A row is enqueued on the task-success path in the execution API when
synchronous
+ asset-event registration is skipped or fails (for example under database
lock
+ contention). The scheduler drains this table, (re)runs
+
:meth:`~airflow.models.taskinstance.TaskInstance.register_asset_changes_in_db`
to
+ create the ``AssetEvent`` and ``AssetDagRunQueue`` rows, and deletes the
queue row
+ once that write commits. This keeps the long-running asset-registration
work off the
+ synchronous ``ti_update_state`` request and guarantees the events are
eventually
+ delivered instead of being dropped when the write cannot complete inline.
+
+ ``ti_id`` is the primary key: at most one pending registration exists per
task
+ instance, and the row is cascade-deleted if the task instance is removed.
+ """
+
+ ti_id: Mapped[UUID] = mapped_column(sa.Uuid(), primary_key=True,
nullable=False)
+ task_outlets: Mapped[list] = mapped_column(sa.JSON(), nullable=False,
default=list)
+ outlet_events: Mapped[list] = mapped_column(sa.JSON(), nullable=False,
default=list)
Review Comment:
Additionally, the `AssetEventQueue` table will only be used for the further
async Asset Event records ingestion purpose. I don't need we need the
`task_outlets` and `outlet_events` columns at all.
I'd like to collapse the both `task_outlets` and `outlet_events` into a
single `JSON` type `payload` or `assets_profiles` column (or other better
naming). Or even just `text` column? Need double check the write path for the
append-only purpose among the DB backend we supported.
The time we need to access the `AssetEventQueue` records, we will need all
the `task_outlets` and `outlet_events` anyway. The scheduler side also
validation the integrity again, so IMO it's fine to collapse the columns.
##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py:
##########
@@ -525,14 +535,28 @@ def ti_update_state(
task_id=task_id,
map_index=map_index,
)
+ session.commit()
except Exception:
+ session.rollback()
log.warning(
Review Comment:
> I think the transactional outbox (durable marker) approach is the right
direction, but IIUC it is not yet implemented atomically here.
Exactly, the `_enqueue_asset_events` should be in the same transaction to
ensure the consistency.
The new `AssetEventQueue` append-only table we introduced should shorten the
lock time comparing to executing the `TI.register_asset_changes_in_db` in the
API request.
##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py:
##########
@@ -622,6 +646,95 @@ def _validate_outlet_event_partition_keys(outlet_events:
list[dict[str, Any]]) -
)
+# Lock timeout (seconds) for the opportunistic synchronous asset-event write.
Kept short so a
+# task completion sheds to the durable queue quickly under asset-row
contention rather than
+# holding an API-server worker. This only bounds lock waits; an uncontended
write does not wait
+# on locks and so is unaffected by the timeout.
+_ASSET_SYNC_LOCK_TIMEOUT = 2
+
+
+def _register_asset_changes_or_enqueue(
+ *,
+ task_instance_id: UUID,
+ task_outlets: list[AssetProfile],
+ outlet_events: list[dict[str, Any]],
+ session: SessionDep,
+) -> None:
+ """
+ Register a successful task's asset events, falling back to the durable
queue on contention.
+
+ Runs after the task-instance state has been committed and its row lock
released. The write is
+ attempted synchronously under a short lock timeout so the common,
uncontended case stays fast.
+ If the write cannot acquire its locks in time (or fails for any other
reason) the events are
+ written to ``asset_event_queue`` and the scheduler drains them, retrying
until they land. The
+ events are therefore never dropped, and the request never holds the
task_instance lock for the
+ registration work.
+ """
+ if not task_outlets and not outlet_events:
+ return
+
+ ti = session.get(TI, task_instance_id)
+ if ti is None:
+ return
+
+ try:
+ with with_db_lock_timeout(session,
lock_timeout=_ASSET_SYNC_LOCK_TIMEOUT):
+ TI.register_asset_changes_in_db(ti, task_outlets, outlet_events,
session=session)
+ # Supersede any row a previous failed attempt for this TI left in
the queue so the
+ # scheduler drain does not re-register the same events after a
synchronous success.
+
session.execute(delete(AssetEventQueue).where(AssetEventQueue.ti_id ==
task_instance_id))
+ session.commit()
+ return
+ except OperationalError as e:
+ session.rollback()
+ if not is_lock_not_available_error(e):
+ log.warning(
+ "Asset registration failed with a database error; deferring to
the asset event "
+ "queue for the scheduler to retry.",
+ task_instance_id=str(task_instance_id),
+ exc_info=True,
+ )
+ except Exception:
+ session.rollback()
+ log.warning(
+ "Asset registration failed; deferring to the asset event queue for
the scheduler to retry.",
+ task_instance_id=str(task_instance_id),
+ exc_info=True,
+ )
+
+ _enqueue_asset_events(
+ task_instance_id=task_instance_id,
+ task_outlets=task_outlets,
+ outlet_events=outlet_events,
+ session=session,
+ )
+
+
+def _enqueue_asset_events(
+ *,
+ task_instance_id: UUID,
+ task_outlets: list[AssetProfile],
+ outlet_events: list[dict[str, Any]],
+ session: SessionDep,
+) -> None:
+ """
+ Durably record a completed task's asset events for the scheduler to
register later.
+
+ Uses ``merge`` so a retried completion overwrites the pending row rather
than conflicting on
+ the ``ti_id`` primary key.
+ """
+ session.merge(
+ AssetEventQueue(
+ ti_id=task_instance_id,
+ task_outlets=[outlet.model_dump(mode="json") for outlet in
task_outlets],
+ outlet_events=outlet_events,
+ attempts=0,
+ )
Review Comment:
Then we could further prevent the serialization overhead here for storing
the `AssetEventQueue` outbox records and to shorten the total lock time as well.
##########
airflow-core/src/airflow/jobs/scheduler_job_runner.py:
##########
@@ -3676,6 +3682,88 @@ def _remove_unreferenced_triggers(self, *, session:
Session = NEW_SESSION) -> No
.execution_options(synchronize_session="fetch")
)
+ @provide_session
+ def _drain_asset_event_queue(self, *, session: Session = NEW_SESSION) ->
None:
+ """
+ Process pending asset-event registrations enqueued by the execution
API.
+
+ The task-success path enqueues an
:class:`~airflow.models.asset.AssetEventQueue` row when
+ synchronous asset-event registration is skipped or fails under
database lock contention.
+ Here we claim pending rows one at a time (``FOR UPDATE SKIP LOCKED``,
so multiple schedulers
+ do not collide), (re)run ``register_asset_changes_in_db`` to create
the ``AssetEvent`` and
+ ``AssetDagRunQueue`` rows, and delete the queue row in the same
transaction so it is
+ processed exactly once. A row that keeps failing is retried on later
passes until it exceeds
+ ``asset_event_queue_max_attempts``, after which it is left in place
(parked) and surfaced via
+ the ``asset.event_queue.failures`` metric for an operator to inspect.
+ """
+ from airflow.api_fastapi.execution_api.datamodels.asset import
AssetProfile
+
+ batch_size = conf.getint("scheduler", "asset_event_queue_batch_size",
fallback=100)
+ max_attempts = conf.getint("scheduler",
"asset_event_queue_max_attempts", fallback=5)
+
+ processed = 0
+ # Track rows attempted this pass so a freshly-failed row (whose
attempts were just bumped)
+ # is not re-claimed and hammered within the same drain; it waits for
the next pass instead.
+ seen: set[UUID] = set()
+ for _ in range(batch_size):
+ conditions = [AssetEventQueue.attempts < max_attempts]
+ if seen:
+ conditions.append(AssetEventQueue.ti_id.notin_(seen))
+ row = session.scalars(
+ with_row_locks(
+
select(AssetEventQueue).where(*conditions).order_by(AssetEventQueue.created_at).limit(1),
+ of=AssetEventQueue,
+ session=session,
+ skip_locked=True,
+ )
+ ).first()
+ if row is None:
+ break
+
+ ti_id = row.ti_id
+ seen.add(ti_id)
+ ti = session.get(TaskInstance, ti_id)
+ try:
Review Comment:
We should fix the case that the TI being clear as we set the FK constraint
between `AssetEventQueue` and `TaskInstance`.
When every "TaskInstance clear", we will set the new `ti_id` UUID. If the
`AssetEventQueue` records are not yet consumed up, we will face the enter the
`if ti is not None:` path and drop the AssetEvent ingestion.
https://github.com/apache/airflow/blob/ffde96203e76c0c27ccf1ae6eeaad270653248bf/airflow-core/src/airflow/models/taskinstance.py#L1034-L1040
---
To fix the above scenario, we should update the `AssetEventQueue.ti_id` to
new ti_id.
```python
def prepare_db_for_next_try(self, session: Session):
from airflow.models.taskinstancehistory import TaskInstanceHistory
TaskInstanceHistory.record_ti(self, session=session)
session.execute(delete(TaskReschedule).filter_by(ti_id=self.id))
session.execute(
update(AssetEventQueue).where(AssetEventQueue.ti_id ==
self.id).values(ti_id=new_id)
) # new
self.id = uuid7()
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]