jason810496 commented on code in PR #66854:
URL: https://github.com/apache/airflow/pull/66854#discussion_r3671539907


##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py:
##########
@@ -497,12 +502,38 @@ def ti_update_state(
                 extra=json.dumps({"host_name": hostname}) if hostname else 
None,
             )
         )
+        # Durably record the successful task's asset events in the SAME 
transaction that commits the
+        # state, so the marker can never be lost in a crash between this 
commit and their
+        # registration. The scheduler drain is the single writer that 
registers them; the request
+        # never runs asset registration itself, which is what keeps the 
task_instance row lock short
+        # under high fan-out. Only a genuine RUNNING->SUCCESS transition 
reaches here (a duplicate
+        # SUCCESS->SUCCESS short-circuits earlier), so a completion is never 
enqueued twice.
+        if (
+            updated_state == TaskInstanceState.SUCCESS
+            and isinstance(ti_patch_payload, TISuccessStatePayload)
+            and (ti_patch_payload.task_outlets or 
ti_patch_payload.outlet_events)
+        ):
+            _enqueue_asset_events(
+                task_instance_id=task_instance_id,
+                dag_id=dag_id,
+                run_id=run_id,
+                task_id=task_id,
+                map_index=map_index,
+                task_outlets=ti_patch_payload.task_outlets,
+                outlet_events=ti_patch_payload.outlet_events,
+                session=session,
+            )
+        # Commit the state, log entry and durable queue marker together so 
they land atomically and
+        # the row lock is released promptly.
+        session.commit()

Review Comment:
   We shouldn't call the `commit` the `rollback` explicitly in the router.
   
   ```suggestion
   ```



##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py:
##########
@@ -521,7 +552,9 @@ def ti_update_state(
                     task_id=task_id,
                     map_index=map_index,
                 )
+                session.commit()
             except Exception:
+                session.rollback()

Review Comment:
   ```suggestion
   except Exception:
   ```



##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py:
##########
@@ -618,6 +651,58 @@ def _validate_outlet_event_partition_keys(outlet_events: 
list[dict[str, Any]]) -
             )
 
 
+def _asset_event_payload(
+    task_outlets: list[AssetProfile],
+    outlet_events: list[dict[str, Any]],
+    ti_key: dict[str, Any],
+) -> dict[str, Any]:
+    """Serialize a task's outlets, outlet events, and natural key into the 
queue row's JSON payload."""
+    return {
+        "task_outlets": [outlet.model_dump(mode="json") for outlet in 
task_outlets],
+        "outlet_events": outlet_events,
+        # The scheduler drain resolves the live task instance by this natural 
key rather than by the
+        # surrogate ``ti_id``. Clearing a task reassigns its uuid7 id, and 
relying on the foreign
+        # key's ON UPDATE CASCADE to re-point the row only works on Postgres 
-- SQLite does not
+        # enforce foreign keys in Airflow's production engine -- so a 
natural-key lookup is what
+        # keeps the pending events reachable on every backend.
+        "ti_key": ti_key,

Review Comment:
   The comment here is too verbose. `_enqueue_asset_events` already explained 
the overall archciture.
   ```suggestion
           "ti_key": ti_key,
   ```



##########
airflow-core/src/airflow/models/asset.py:
##########
@@ -788,6 +789,101 @@ def __repr__(self):
         return f"{self.__class__.__name__}({', '.join(args)})"
 
 
+class AssetEventQueue(Base):
+    """
+    Durable marker of asset events a successful task emitted, awaiting 
registration.
+
+    On task success the execution API commits one of these rows atomically 
with the task
+    state instead of registering the asset events inline, so the 
``ti_update_state`` request
+    holds the ``task_instance`` row lock only for the state write plus this 
insert rather than
+    for the whole ``register_asset_changes_in_db`` call (which was the source 
of API-server
+    lock contention under high fan-out). The scheduler drains this table, 
resolves the live
+    task instance by natural key, 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. The in-process runner behind ``dag.test`` has no scheduler, so it 
drains the row
+    itself via :func:`register_pending_asset_events` right after the task 
finishes.
+
+    ``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)
+    # Both the emitted task outlets and the outlet events live in one JSON 
payload
+    # (``{"task_outlets": [...], "outlet_events": [...], "ti_key": {...}}``). 
The queue is a durable
+    # buffer only ever read back in full when draining, so a single column 
keeps the enqueue cheap.
+    payload: Mapped[dict] = mapped_column(sa.JSON(), nullable=False, 
default=dict)
+    attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, 
server_default="0")
+    created_at: Mapped[datetime] = mapped_column(UtcDateTime, 
default=timezone.utcnow, nullable=False)
+
+    __tablename__ = "asset_event_queue"
+    __table_args__ = (
+        PrimaryKeyConstraint(ti_id, name="asset_event_queue_pkey"),
+        ForeignKeyConstraint(
+            (ti_id,),
+            ["task_instance.id"],
+            name="aeq_ti_fkey",
+            ondelete="CASCADE",
+            # Referential cleanup only. Correctness on clear does not rely on 
these cascades:
+            # SQLite does not enforce foreign keys in Airflow's production 
engine, so they silently
+            # no-op there. The drain re-resolves the task instance by natural 
key instead, which
+            # survives the id reassignment a clear performs on every backend.
+            onupdate="CASCADE",
+        ),
+        Index("idx_asset_event_queue_created_at", created_at),
+    )
+
+    def __repr__(self):
+        return f"AssetEventQueue(ti_id={self.ti_id!r}, 
attempts={self.attempts!r})"
+
+
+def _register_queued_asset_event(row: AssetEventQueue, *, session: Session) -> 
None:
+    """
+    Register the asset events captured in one :class:`AssetEventQueue` row, 
then delete it.
+
+    Resolves the live task instance by natural key 
(``dag_id``/``run_id``/``task_id``/``map_index``)
+    rather than the surrogate ``ti_id``: clearing a task reassigns its id, so 
a lookup by the
+    enqueued id would miss the row on any backend that does not cascade the id 
change. If the task
+    instance no longer exists there is nothing to register and the row is 
simply dropped. The caller
+    owns the surrounding transaction (the scheduler wraps each row in a 
savepoint; the in-process
+    runner commits the session).
+    """
+    from airflow.api_fastapi.execution_api.datamodels.asset import AssetProfile
+    from airflow.models.taskinstance import TaskInstance
+
+    payload = row.payload
+    ti_key = payload["ti_key"]
+    ti = session.scalar(
+        select(TaskInstance).where(
+            TaskInstance.dag_id == ti_key["dag_id"],
+            TaskInstance.run_id == ti_key["run_id"],
+            TaskInstance.task_id == ti_key["task_id"],
+            TaskInstance.map_index == ti_key["map_index"],
+        )
+    )
+    if ti is not None:
+        task_outlets = [AssetProfile.model_validate(outlet) for outlet in 
payload["task_outlets"]]
+        TaskInstance.register_asset_changes_in_db(ti, task_outlets, 
payload["outlet_events"], session=session)
+    session.delete(row)
+
+
+def register_pending_asset_events(*, ti_ids: Iterable[UUID], session: Session) 
-> None:

Review Comment:
   We should rename this method and mention that this method can only be used 
for `dag.test` purpose.
   This shouldn't be used at any other components. 
`register_pending_asset_events` is ambigious.
   
   



##########
airflow-core/src/airflow/models/asset.py:
##########
@@ -788,6 +789,101 @@ def __repr__(self):
         return f"{self.__class__.__name__}({', '.join(args)})"
 
 
+class AssetEventQueue(Base):
+    """
+    Durable marker of asset events a successful task emitted, awaiting 
registration.
+
+    On task success the execution API commits one of these rows atomically 
with the task
+    state instead of registering the asset events inline, so the 
``ti_update_state`` request
+    holds the ``task_instance`` row lock only for the state write plus this 
insert rather than
+    for the whole ``register_asset_changes_in_db`` call (which was the source 
of API-server
+    lock contention under high fan-out). The scheduler drains this table, 
resolves the live
+    task instance by natural key, 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. The in-process runner behind ``dag.test`` has no scheduler, so it 
drains the row
+    itself via :func:`register_pending_asset_events` right after the task 
finishes.
+
+    ``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)
+    # Both the emitted task outlets and the outlet events live in one JSON 
payload
+    # (``{"task_outlets": [...], "outlet_events": [...], "ti_key": {...}}``). 
The queue is a durable
+    # buffer only ever read back in full when draining, so a single column 
keeps the enqueue cheap.
+    payload: Mapped[dict] = mapped_column(sa.JSON(), nullable=False, 
default=dict)
+    attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, 
server_default="0")
+    created_at: Mapped[datetime] = mapped_column(UtcDateTime, 
default=timezone.utcnow, nullable=False)
+
+    __tablename__ = "asset_event_queue"
+    __table_args__ = (
+        PrimaryKeyConstraint(ti_id, name="asset_event_queue_pkey"),
+        ForeignKeyConstraint(
+            (ti_id,),
+            ["task_instance.id"],
+            name="aeq_ti_fkey",
+            ondelete="CASCADE",
+            # Referential cleanup only. Correctness on clear does not rely on 
these cascades:
+            # SQLite does not enforce foreign keys in Airflow's production 
engine, so they silently
+            # no-op there. The drain re-resolves the task instance by natural 
key instead, which
+            # survives the id reassignment a clear performs on every backend.
+            onupdate="CASCADE",
+        ),
+        Index("idx_asset_event_queue_created_at", created_at),
+    )
+
+    def __repr__(self):
+        return f"AssetEventQueue(ti_id={self.ti_id!r}, 
attempts={self.attempts!r})"
+
+
+def _register_queued_asset_event(row: AssetEventQueue, *, session: Session) -> 
None:

Review Comment:
   This should be a public method, scheduler will reference this directly.
   ```suggestion
   def register_queued_asset_event(row: AssetEventQueue, *, session: Session) 
-> None:
   ```



##########
airflow-core/src/airflow/models/asset.py:
##########
@@ -788,6 +789,101 @@ def __repr__(self):
         return f"{self.__class__.__name__}({', '.join(args)})"
 
 
+class AssetEventQueue(Base):
+    """
+    Durable marker of asset events a successful task emitted, awaiting 
registration.
+
+    On task success the execution API commits one of these rows atomically 
with the task
+    state instead of registering the asset events inline, so the 
``ti_update_state`` request
+    holds the ``task_instance`` row lock only for the state write plus this 
insert rather than
+    for the whole ``register_asset_changes_in_db`` call (which was the source 
of API-server
+    lock contention under high fan-out). The scheduler drains this table, 
resolves the live
+    task instance by natural key, 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. The in-process runner behind ``dag.test`` has no scheduler, so it 
drains the row
+    itself via :func:`register_pending_asset_events` right after the task 
finishes.
+
+    ``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)
+    # Both the emitted task outlets and the outlet events live in one JSON 
payload
+    # (``{"task_outlets": [...], "outlet_events": [...], "ti_key": {...}}``). 
The queue is a durable
+    # buffer only ever read back in full when draining, so a single column 
keeps the enqueue cheap.
+    payload: Mapped[dict] = mapped_column(sa.JSON(), nullable=False, 
default=dict)
+    attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, 
server_default="0")
+    created_at: Mapped[datetime] = mapped_column(UtcDateTime, 
default=timezone.utcnow, nullable=False)
+
+    __tablename__ = "asset_event_queue"
+    __table_args__ = (
+        PrimaryKeyConstraint(ti_id, name="asset_event_queue_pkey"),
+        ForeignKeyConstraint(
+            (ti_id,),
+            ["task_instance.id"],
+            name="aeq_ti_fkey",
+            ondelete="CASCADE",
+            # Referential cleanup only. Correctness on clear does not rely on 
these cascades:
+            # SQLite does not enforce foreign keys in Airflow's production 
engine, so they silently
+            # no-op there. The drain re-resolves the task instance by natural 
key instead, which
+            # survives the id reassignment a clear performs on every backend.
+            onupdate="CASCADE",
+        ),
+        Index("idx_asset_event_queue_created_at", created_at),
+    )
+
+    def __repr__(self):
+        return f"AssetEventQueue(ti_id={self.ti_id!r}, 
attempts={self.attempts!r})"
+
+
+def _register_queued_asset_event(row: AssetEventQueue, *, session: Session) -> 
None:
+    """
+    Register the asset events captured in one :class:`AssetEventQueue` row, 
then delete it.
+
+    Resolves the live task instance by natural key 
(``dag_id``/``run_id``/``task_id``/``map_index``)
+    rather than the surrogate ``ti_id``: clearing a task reassigns its id, so 
a lookup by the
+    enqueued id would miss the row on any backend that does not cascade the id 
change. If the task
+    instance no longer exists there is nothing to register and the row is 
simply dropped. The caller
+    owns the surrounding transaction (the scheduler wraps each row in a 
savepoint; the in-process
+    runner commits the session).
+    """
+    from airflow.api_fastapi.execution_api.datamodels.asset import AssetProfile
+    from airflow.models.taskinstance import TaskInstance
+
+    payload = row.payload
+    ti_key = payload["ti_key"]
+    ti = session.scalar(
+        select(TaskInstance).where(
+            TaskInstance.dag_id == ti_key["dag_id"],
+            TaskInstance.run_id == ti_key["run_id"],
+            TaskInstance.task_id == ti_key["task_id"],
+            TaskInstance.map_index == ti_key["map_index"],
+        )
+    )
+    if ti is not None:
+        task_outlets = [AssetProfile.model_validate(outlet) for outlet in 
payload["task_outlets"]]
+        TaskInstance.register_asset_changes_in_db(ti, task_outlets, 
payload["outlet_events"], session=session)

Review Comment:
   We should log (info level should be enough) that if the TI is not found in 
this case.



-- 
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]

Reply via email to