Copilot commented on code in PR #74726:
URL: https://github.com/apache/airflow/pull/74726#discussion_r4501530425


##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_events.py:
##########
@@ -32,19 +39,314 @@
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.api_fastapi.execution_api.datamodels.asset import AssetResponse
 from airflow.api_fastapi.execution_api.datamodels.asset_event import (
+    AssetEventReceipt,
     AssetEventResponse,
     AssetEventsResponse,
+    EmitAssetEventBody,
 )
-from airflow.models.asset import AssetAliasModel, AssetEvent, AssetModel
+from airflow.api_fastapi.execution_api.datamodels.token import TIToken
+from airflow.api_fastapi.execution_api.security import (
+    _IN_PROCESS_NON_TI_CALLER,
+    CurrentTIToken,
+    ExecutionAPIRoute,
+    skip_auto_ti_attempt_live,
+)
+from airflow.assets.manager import AssetManager
+from airflow.configuration import conf
+from airflow.models.asset import AssetAliasModel, AssetEvent, AssetModel, 
TaskOutletAssetReference
+from airflow.models.callback import Callback, ExecutorCallback
+from airflow.models.dagrun import DagRun
+from airflow.models.deadline import Deadline
+from airflow.models.taskinstance import TaskInstance
 from airflow.utils.sqlalchemy import JsonContains
+from airflow.utils.state import CallbackState
 
 router = APIRouter(
+    route_class=ExecutionAPIRoute,
     responses={
         status.HTTP_404_NOT_FOUND: {"description": "Asset not found"},
         status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
     },
 )
 
+EMISSION_ORIGIN = "emission"
+# Prototype defaults; the AIP leaves the final values open.
+ARCHIVED_RETRY_EMISSION_WINDOW = timedelta(minutes=10)
+MAX_EMISSION_EXTRA_BYTES = 64 * 1024
+MAX_EMISSIONS_PER_SOURCE = 10_000
+
+
+class _EmissionSource(NamedTuple):
+    task_instance: TaskInstance | None
+    dag_run: DagRun
+    # Retries keep these values, so a repeated key stays in one scope; a clear 
changes clear_number.
+    scope: tuple[str | int | None, ...]
+
+
+def _get_dag_run(dag_id: str | None, run_id: str | None, *, session: Session) 
-> DagRun:
+    dag_run = session.scalar(select(DagRun).where(DagRun.dag_id == dag_id, 
DagRun.run_id == run_id))
+    if dag_run is None:
+        raise HTTPException(
+            status.HTTP_404_NOT_FOUND, detail={"reason": "not_found", 
"message": "Source Dag run not found"}
+        )
+    return dag_run
+
+
+def _build_dag_run_source(dag_run: DagRun) -> _EmissionSource:
+    return _EmissionSource(
+        task_instance=None,
+        dag_run=dag_run,
+        scope=("dag_run", dag_run.dag_id, dag_run.run_id, 
dag_run.clear_number),
+    )
+
+
+def _build_task_source(ti: TaskInstance, *, session: Session) -> 
_EmissionSource:
+    dag_run = _get_dag_run(ti.dag_id, ti.run_id, session=session)
+    return _EmissionSource(
+        task_instance=ti,
+        dag_run=dag_run,
+        scope=("task", ti.dag_id, ti.run_id, ti.task_id, ti.map_index, 
dag_run.clear_number),
+    )
+
+
+def _require_emitting_attempt(ti: TaskInstance) -> None:
+    if ti.working_set:
+        return
+    # The task runner reports up_for_retry before it runs on_retry_callback, 
and that report
+    # archives the attempt, so the callback can only emit through this bounded 
exception.
+    if (
+        ti.archived_reason == "retry"
+        and ti.end_date is not None
+        and timezone.utcnow() - ti.end_date <= ARCHIVED_RETRY_EMISSION_WINDOW
+    ):
+        return
+    raise HTTPException(
+        status.HTTP_410_GONE,
+        detail={"reason": "attempt_archived", "message": "This task attempt 
can no longer emit events"},
+    )
+
+
+def _resolve_emission_source(
+    body: EmitAssetEventBody, request: Request, token: TIToken, *, session: 
Session
+) -> _EmissionSource:
+    if body.source_dag_id is not None or body.source_run_id is not None:
+        # Only the trusted, in-process Dag processor transport names a 
callback's Dag run.
+        if not request.scope.get(_IN_PROCESS_NON_TI_CALLER):
+            raise HTTPException(
+                status.HTTP_403_FORBIDDEN,
+                detail={"reason": "forbidden", "message": "Callback provenance 
requires the Dag processor"},
+            )
+        return _build_dag_run_source(_get_dag_run(body.source_dag_id, 
body.source_run_id, session=session))
+
+    ti = session.scalar(
+        select(TaskInstance).where(TaskInstance.id == 
token.id).execution_options(include_all_attempts=True)
+    )
+    if ti is not None:
+        _require_emitting_attempt(ti)
+        return _build_task_source(ti, session=session)
+
+    callback = session.get(Callback, token.id)
+    if isinstance(callback, ExecutorCallback):
+        if callback.state != CallbackState.RUNNING:
+            raise HTTPException(
+                status.HTTP_410_GONE,
+                detail={"reason": "callback_finished", "message": "This 
callback can no longer emit events"},
+            )
+        dagrun_id = 
session.scalar(select(Deadline.dagrun_id).where(Deadline.callback_id == 
callback.id))
+        dag_run = session.get(DagRun, dagrun_id) if dagrun_id is not None else 
None
+        if dag_run is None:
+            raise HTTPException(
+                status.HTTP_403_FORBIDDEN,
+                detail={"reason": "forbidden", "message": "This callback has 
no Dag run to emit for"},
+            )
+        return _build_dag_run_source(dag_run)
+
+    raise HTTPException(
+        status.HTTP_403_FORBIDDEN,
+        detail={"reason": "forbidden", "message": "Asset emission requires a 
task or a callback"},
+    )
+
+
+def _get_emission_asset(body: EmitAssetEventBody, *, session: Session) -> 
AssetModel:
+    query = select(AssetModel).options(joinedload(AssetModel.active))
+    if body.name is not None:
+        query = query.where(AssetModel.name == body.name)
+    if body.uri is not None:
+        query = query.where(AssetModel.uri == body.uri)
+    assets = session.scalars(query).unique().all()
+    active = [asset for asset in assets if asset.active is not None]
+    if len(active) == 1:
+        return active[0]
+    if len(active) > 1:
+        raise HTTPException(
+            status.HTTP_409_CONFLICT,
+            detail={"reason": "asset_ambiguous", "message": "The reference 
matches more than one asset"},
+        )
+    if assets:
+        raise HTTPException(
+            status.HTTP_409_CONFLICT,
+            detail={"reason": "asset_inactive", "message": "No active Dag 
references this asset"},
+        )
+    raise HTTPException(
+        status.HTTP_404_NOT_FOUND,
+        detail={"reason": "asset_not_found", "message": "The asset is not in 
the Airflow database"},
+    )
+
+
+def _compute_hash(value: object) -> str:
+    """Hash a value as canonical JSON; a NaN or infinite float raises 
ValueError."""
+    payload = json.dumps(value, sort_keys=True, separators=(",", ":"), 
allow_nan=False)
+    return hashlib.sha256(payload.encode()).hexdigest()
+
+
+def _compute_payload_hash(asset: AssetModel, body: EmitAssetEventBody) -> str:
+    try:
+        extra_size = len(json.dumps(body.extra, separators=(",", ":"), 
allow_nan=False).encode())
+        payload_hash = _compute_hash(
+            {"asset_id": asset.id, "extra": body.extra, "partition_key": 
body.partition_key}
+        )
+    except ValueError:
+        raise HTTPException(
+            status.HTTP_422_UNPROCESSABLE_CONTENT,
+            detail={
+                "reason": "invalid_json",
+                "message": "extra must be strict JSON, without NaN or 
Infinity",
+            },
+        )
+    if extra_size > MAX_EMISSION_EXTRA_BYTES:
+        raise HTTPException(
+            status.HTTP_413_CONTENT_TOO_LARGE,
+            detail={
+                "reason": "extra_too_large",
+                "message": f"extra is larger than {MAX_EMISSION_EXTRA_BYTES} 
bytes",
+            },
+        )
+    return payload_hash
+
+
+def _require_emission_list_entry(source: _EmissionSource, asset: AssetModel, 
*, session: Session) -> None:
+    ti = source.task_instance
+    if ti is None or not conf.getboolean("core", "multi_team"):
+        return
+    declared = session.scalar(
+        select(TaskOutletAssetReference.asset_id).where(
+            TaskOutletAssetReference.dag_id == ti.dag_id,
+            TaskOutletAssetReference.task_id == ti.task_id,
+            TaskOutletAssetReference.asset_id == asset.id,
+        )
+    )
+    if declared is None:
+        raise HTTPException(
+            status.HTTP_403_FORBIDDEN,
+            detail={
+                "reason": "asset_not_in_emission_list",
+                "message": "The task does not declare this asset",
+            },
+        )
+
+
+def _require_emission_budget(source: _EmissionSource, *, session: Session) -> 
None:
+    query = 
select(func.count()).select_from(AssetEvent).where(AssetEvent.origin == 
EMISSION_ORIGIN)
+    if source.task_instance is not None:
+        query = query.where(AssetEvent.source_task_instance_id == 
source.task_instance.id)
+    else:
+        query = query.where(
+            AssetEvent.source_dag_id == source.dag_run.dag_id,
+            AssetEvent.source_run_id == source.dag_run.run_id,
+            AssetEvent.source_task_id.is_(None),
+        )
+    if (session.scalar(query) or 0) >= MAX_EMISSIONS_PER_SOURCE:

Review Comment:
   The budget check and event insertion are not serialized. With one slot 
remaining, two requests for the same source and different keys can both observe 
a count below the maximum and both commit; the unique scope-hash index only 
prevents requests using the same key. This also occurs with concurrent 
callbacks sharing a Dag-run source. Reserve the budget atomically, or lock the 
source before counting and retain that lock through insertion, and test 
concurrent distinct-key requests at the limit.



##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_events.py:
##########
@@ -32,19 +39,314 @@
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.api_fastapi.execution_api.datamodels.asset import AssetResponse
 from airflow.api_fastapi.execution_api.datamodels.asset_event import (
+    AssetEventReceipt,
     AssetEventResponse,
     AssetEventsResponse,
+    EmitAssetEventBody,
 )
-from airflow.models.asset import AssetAliasModel, AssetEvent, AssetModel
+from airflow.api_fastapi.execution_api.datamodels.token import TIToken
+from airflow.api_fastapi.execution_api.security import (
+    _IN_PROCESS_NON_TI_CALLER,
+    CurrentTIToken,
+    ExecutionAPIRoute,
+    skip_auto_ti_attempt_live,
+)
+from airflow.assets.manager import AssetManager
+from airflow.configuration import conf
+from airflow.models.asset import AssetAliasModel, AssetEvent, AssetModel, 
TaskOutletAssetReference
+from airflow.models.callback import Callback, ExecutorCallback
+from airflow.models.dagrun import DagRun
+from airflow.models.deadline import Deadline
+from airflow.models.taskinstance import TaskInstance
 from airflow.utils.sqlalchemy import JsonContains
+from airflow.utils.state import CallbackState
 
 router = APIRouter(
+    route_class=ExecutionAPIRoute,
     responses={
         status.HTTP_404_NOT_FOUND: {"description": "Asset not found"},
         status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
     },
 )
 
+EMISSION_ORIGIN = "emission"
+# Prototype defaults; the AIP leaves the final values open.
+ARCHIVED_RETRY_EMISSION_WINDOW = timedelta(minutes=10)
+MAX_EMISSION_EXTRA_BYTES = 64 * 1024
+MAX_EMISSIONS_PER_SOURCE = 10_000
+
+
+class _EmissionSource(NamedTuple):
+    task_instance: TaskInstance | None
+    dag_run: DagRun
+    # Retries keep these values, so a repeated key stays in one scope; a clear 
changes clear_number.
+    scope: tuple[str | int | None, ...]
+
+
+def _get_dag_run(dag_id: str | None, run_id: str | None, *, session: Session) 
-> DagRun:
+    dag_run = session.scalar(select(DagRun).where(DagRun.dag_id == dag_id, 
DagRun.run_id == run_id))
+    if dag_run is None:
+        raise HTTPException(
+            status.HTTP_404_NOT_FOUND, detail={"reason": "not_found", 
"message": "Source Dag run not found"}
+        )
+    return dag_run
+
+
+def _build_dag_run_source(dag_run: DagRun) -> _EmissionSource:
+    return _EmissionSource(
+        task_instance=None,
+        dag_run=dag_run,
+        scope=("dag_run", dag_run.dag_id, dag_run.run_id, 
dag_run.clear_number),
+    )
+
+
+def _build_task_source(ti: TaskInstance, *, session: Session) -> 
_EmissionSource:
+    dag_run = _get_dag_run(ti.dag_id, ti.run_id, session=session)
+    return _EmissionSource(
+        task_instance=ti,
+        dag_run=dag_run,
+        scope=("task", ti.dag_id, ti.run_id, ti.task_id, ti.map_index, 
dag_run.clear_number),

Review Comment:
   `TaskInstance.map_index` is now a synonym for `region_index`, which is not 
unique across regional executions. A mapped task expanded inside two loop 
iterations can have the same Dag/run/task/index but different `region_id`s, so 
reusing a key incorrectly replays the other instance's event or returns an 
idempotency conflict. Include the regional execution identity in the scope; 
retries preserve `region_id`, so this can distinguish independent instances 
without breaking retry deduplication.



##########
airflow-core/src/airflow/dag_processing/collection.py:
##########
@@ -1104,6 +1104,8 @@ def _get_task_ports(data: dict, inlets: bool, outlets: 
bool) -> Iterable[str]:
         yield from data.get("inlets") or ()
     if outlets:
         yield from data.get("outlets") or ()
+        # An emission list declares a producer like an outlet does, but 
success emits nothing for it.
+        yield from data.get("emits") or ()

Review Comment:
   When the same asset appears in both `outlets` and `emits` with different 
access controls, this ordering makes `add_task_asset_references()` overwrite 
the outlet's consumer policy with the `emits` policy for the shared row. Adding 
an unrestricted emission declaration can thus cause existing success-only 
outlet events to trigger teams previously excluded. Preserve the outlet policy 
or reject conflicting declarations rather than silently replacing it.



##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_events.py:
##########
@@ -32,19 +39,314 @@
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.api_fastapi.execution_api.datamodels.asset import AssetResponse
 from airflow.api_fastapi.execution_api.datamodels.asset_event import (
+    AssetEventReceipt,
     AssetEventResponse,
     AssetEventsResponse,
+    EmitAssetEventBody,
 )
-from airflow.models.asset import AssetAliasModel, AssetEvent, AssetModel
+from airflow.api_fastapi.execution_api.datamodels.token import TIToken
+from airflow.api_fastapi.execution_api.security import (
+    _IN_PROCESS_NON_TI_CALLER,
+    CurrentTIToken,
+    ExecutionAPIRoute,
+    skip_auto_ti_attempt_live,
+)
+from airflow.assets.manager import AssetManager
+from airflow.configuration import conf
+from airflow.models.asset import AssetAliasModel, AssetEvent, AssetModel, 
TaskOutletAssetReference
+from airflow.models.callback import Callback, ExecutorCallback
+from airflow.models.dagrun import DagRun
+from airflow.models.deadline import Deadline
+from airflow.models.taskinstance import TaskInstance
 from airflow.utils.sqlalchemy import JsonContains
+from airflow.utils.state import CallbackState
 
 router = APIRouter(
+    route_class=ExecutionAPIRoute,
     responses={
         status.HTTP_404_NOT_FOUND: {"description": "Asset not found"},
         status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
     },
 )
 
+EMISSION_ORIGIN = "emission"
+# Prototype defaults; the AIP leaves the final values open.
+ARCHIVED_RETRY_EMISSION_WINDOW = timedelta(minutes=10)
+MAX_EMISSION_EXTRA_BYTES = 64 * 1024
+MAX_EMISSIONS_PER_SOURCE = 10_000
+
+
+class _EmissionSource(NamedTuple):
+    task_instance: TaskInstance | None
+    dag_run: DagRun
+    # Retries keep these values, so a repeated key stays in one scope; a clear 
changes clear_number.
+    scope: tuple[str | int | None, ...]
+
+
+def _get_dag_run(dag_id: str | None, run_id: str | None, *, session: Session) 
-> DagRun:
+    dag_run = session.scalar(select(DagRun).where(DagRun.dag_id == dag_id, 
DagRun.run_id == run_id))
+    if dag_run is None:
+        raise HTTPException(
+            status.HTTP_404_NOT_FOUND, detail={"reason": "not_found", 
"message": "Source Dag run not found"}
+        )
+    return dag_run
+
+
+def _build_dag_run_source(dag_run: DagRun) -> _EmissionSource:
+    return _EmissionSource(
+        task_instance=None,
+        dag_run=dag_run,
+        scope=("dag_run", dag_run.dag_id, dag_run.run_id, 
dag_run.clear_number),
+    )
+
+
+def _build_task_source(ti: TaskInstance, *, session: Session) -> 
_EmissionSource:
+    dag_run = _get_dag_run(ti.dag_id, ti.run_id, session=session)
+    return _EmissionSource(
+        task_instance=ti,
+        dag_run=dag_run,
+        scope=("task", ti.dag_id, ti.run_id, ti.task_id, ti.map_index, 
dag_run.clear_number),
+    )
+
+
+def _require_emitting_attempt(ti: TaskInstance) -> None:
+    if ti.working_set:
+        return
+    # The task runner reports up_for_retry before it runs on_retry_callback, 
and that report
+    # archives the attempt, so the callback can only emit through this bounded 
exception.
+    if (
+        ti.archived_reason == "retry"
+        and ti.end_date is not None
+        and timezone.utcnow() - ti.end_date <= ARCHIVED_RETRY_EMISSION_WINDOW
+    ):
+        return
+    raise HTTPException(
+        status.HTTP_410_GONE,
+        detail={"reason": "attempt_archived", "message": "This task attempt 
can no longer emit events"},
+    )

Review Comment:
   The reason this comment gives for the exception doesn't match the 
supervisor. `ActivitySubprocess._handle_task_state` (registered for both 
`TaskState` and `RetryTask`) only stores the message in 
`_pending_terminal_state_msg`. `update_task_state_if_needed()` sends it to the 
API after the child process exits, and `InProcessTestSupervisor` does the same 
after `finalize()`. So `on_retry_callback` runs while the attempt is still in 
the working set, and the normal `ti.working_set` check already lets it emit.
   
   The exception is also wider than intended. `archived_reason == "retry"` is 
set by every `prepare_db_for_next_try()`, including scheduler-driven retries: 
`handle_failure` for heartbeat timeouts and executor failures, and the adoption 
reset in `scheduler_job_runner`. With this exception, an attempt the server 
already declared dead and replaced can keep writing asset events for 10 minutes 
while its successor runs, and those events are attributed to the dead attempt. 
Unless a concrete runtime path archives the attempt before its callbacks run, 
please remove the exception (the `test_archived_attempt` "retry-callback" case 
would then expect 410).



##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_events.py:
##########
@@ -32,19 +39,314 @@
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.api_fastapi.execution_api.datamodels.asset import AssetResponse
 from airflow.api_fastapi.execution_api.datamodels.asset_event import (
+    AssetEventReceipt,
     AssetEventResponse,
     AssetEventsResponse,
+    EmitAssetEventBody,
 )
-from airflow.models.asset import AssetAliasModel, AssetEvent, AssetModel
+from airflow.api_fastapi.execution_api.datamodels.token import TIToken
+from airflow.api_fastapi.execution_api.security import (
+    _IN_PROCESS_NON_TI_CALLER,
+    CurrentTIToken,
+    ExecutionAPIRoute,
+    skip_auto_ti_attempt_live,
+)
+from airflow.assets.manager import AssetManager
+from airflow.configuration import conf
+from airflow.models.asset import AssetAliasModel, AssetEvent, AssetModel, 
TaskOutletAssetReference
+from airflow.models.callback import Callback, ExecutorCallback
+from airflow.models.dagrun import DagRun
+from airflow.models.deadline import Deadline
+from airflow.models.taskinstance import TaskInstance
 from airflow.utils.sqlalchemy import JsonContains
+from airflow.utils.state import CallbackState
 
 router = APIRouter(
+    route_class=ExecutionAPIRoute,
     responses={
         status.HTTP_404_NOT_FOUND: {"description": "Asset not found"},
         status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
     },
 )
 
+EMISSION_ORIGIN = "emission"
+# Prototype defaults; the AIP leaves the final values open.
+ARCHIVED_RETRY_EMISSION_WINDOW = timedelta(minutes=10)
+MAX_EMISSION_EXTRA_BYTES = 64 * 1024
+MAX_EMISSIONS_PER_SOURCE = 10_000
+
+
+class _EmissionSource(NamedTuple):
+    task_instance: TaskInstance | None
+    dag_run: DagRun
+    # Retries keep these values, so a repeated key stays in one scope; a clear 
changes clear_number.
+    scope: tuple[str | int | None, ...]
+
+
+def _get_dag_run(dag_id: str | None, run_id: str | None, *, session: Session) 
-> DagRun:
+    dag_run = session.scalar(select(DagRun).where(DagRun.dag_id == dag_id, 
DagRun.run_id == run_id))
+    if dag_run is None:
+        raise HTTPException(
+            status.HTTP_404_NOT_FOUND, detail={"reason": "not_found", 
"message": "Source Dag run not found"}
+        )
+    return dag_run
+
+
+def _build_dag_run_source(dag_run: DagRun) -> _EmissionSource:
+    return _EmissionSource(
+        task_instance=None,
+        dag_run=dag_run,
+        scope=("dag_run", dag_run.dag_id, dag_run.run_id, 
dag_run.clear_number),
+    )
+
+
+def _build_task_source(ti: TaskInstance, *, session: Session) -> 
_EmissionSource:
+    dag_run = _get_dag_run(ti.dag_id, ti.run_id, session=session)
+    return _EmissionSource(
+        task_instance=ti,
+        dag_run=dag_run,
+        scope=("task", ti.dag_id, ti.run_id, ti.task_id, ti.map_index, 
dag_run.clear_number),
+    )
+
+
+def _require_emitting_attempt(ti: TaskInstance) -> None:
+    if ti.working_set:
+        return
+    # The task runner reports up_for_retry before it runs on_retry_callback, 
and that report
+    # archives the attempt, so the callback can only emit through this bounded 
exception.
+    if (
+        ti.archived_reason == "retry"
+        and ti.end_date is not None
+        and timezone.utcnow() - ti.end_date <= ARCHIVED_RETRY_EMISSION_WINDOW
+    ):
+        return
+    raise HTTPException(
+        status.HTTP_410_GONE,
+        detail={"reason": "attempt_archived", "message": "This task attempt 
can no longer emit events"},
+    )
+
+
+def _resolve_emission_source(
+    body: EmitAssetEventBody, request: Request, token: TIToken, *, session: 
Session
+) -> _EmissionSource:
+    if body.source_dag_id is not None or body.source_run_id is not None:
+        # Only the trusted, in-process Dag processor transport names a 
callback's Dag run.
+        if not request.scope.get(_IN_PROCESS_NON_TI_CALLER):
+            raise HTTPException(
+                status.HTTP_403_FORBIDDEN,
+                detail={"reason": "forbidden", "message": "Callback provenance 
requires the Dag processor"},
+            )
+        return _build_dag_run_source(_get_dag_run(body.source_dag_id, 
body.source_run_id, session=session))
+
+    ti = session.scalar(
+        select(TaskInstance).where(TaskInstance.id == 
token.id).execution_options(include_all_attempts=True)
+    )
+    if ti is not None:
+        _require_emitting_attempt(ti)
+        return _build_task_source(ti, session=session)
+
+    callback = session.get(Callback, token.id)
+    if isinstance(callback, ExecutorCallback):
+        if callback.state != CallbackState.RUNNING:
+            raise HTTPException(
+                status.HTTP_410_GONE,
+                detail={"reason": "callback_finished", "message": "This 
callback can no longer emit events"},
+            )
+        dagrun_id = 
session.scalar(select(Deadline.dagrun_id).where(Deadline.callback_id == 
callback.id))
+        dag_run = session.get(DagRun, dagrun_id) if dagrun_id is not None else 
None
+        if dag_run is None:
+            raise HTTPException(
+                status.HTTP_403_FORBIDDEN,
+                detail={"reason": "forbidden", "message": "This callback has 
no Dag run to emit for"},
+            )
+        return _build_dag_run_source(dag_run)
+
+    raise HTTPException(
+        status.HTTP_403_FORBIDDEN,
+        detail={"reason": "forbidden", "message": "Asset emission requires a 
task or a callback"},
+    )
+
+
+def _get_emission_asset(body: EmitAssetEventBody, *, session: Session) -> 
AssetModel:
+    query = select(AssetModel).options(joinedload(AssetModel.active))
+    if body.name is not None:
+        query = query.where(AssetModel.name == body.name)
+    if body.uri is not None:
+        query = query.where(AssetModel.uri == body.uri)
+    assets = session.scalars(query).unique().all()
+    active = [asset for asset in assets if asset.active is not None]
+    if len(active) == 1:
+        return active[0]
+    if len(active) > 1:
+        raise HTTPException(
+            status.HTTP_409_CONFLICT,
+            detail={"reason": "asset_ambiguous", "message": "The reference 
matches more than one asset"},
+        )
+    if assets:
+        raise HTTPException(
+            status.HTTP_409_CONFLICT,
+            detail={"reason": "asset_inactive", "message": "No active Dag 
references this asset"},
+        )
+    raise HTTPException(
+        status.HTTP_404_NOT_FOUND,
+        detail={"reason": "asset_not_found", "message": "The asset is not in 
the Airflow database"},
+    )
+
+
+def _compute_hash(value: object) -> str:
+    """Hash a value as canonical JSON; a NaN or infinite float raises 
ValueError."""
+    payload = json.dumps(value, sort_keys=True, separators=(",", ":"), 
allow_nan=False)
+    return hashlib.sha256(payload.encode()).hexdigest()
+
+
+def _compute_payload_hash(asset: AssetModel, body: EmitAssetEventBody) -> str:
+    try:
+        extra_size = len(json.dumps(body.extra, separators=(",", ":"), 
allow_nan=False).encode())
+        payload_hash = _compute_hash(
+            {"asset_id": asset.id, "extra": body.extra, "partition_key": 
body.partition_key}
+        )
+    except ValueError:
+        raise HTTPException(
+            status.HTTP_422_UNPROCESSABLE_CONTENT,
+            detail={
+                "reason": "invalid_json",
+                "message": "extra must be strict JSON, without NaN or 
Infinity",
+            },
+        )
+    if extra_size > MAX_EMISSION_EXTRA_BYTES:
+        raise HTTPException(
+            status.HTTP_413_CONTENT_TOO_LARGE,
+            detail={
+                "reason": "extra_too_large",
+                "message": f"extra is larger than {MAX_EMISSION_EXTRA_BYTES} 
bytes",
+            },
+        )
+    return payload_hash
+
+
+def _require_emission_list_entry(source: _EmissionSource, asset: AssetModel, 
*, session: Session) -> None:
+    ti = source.task_instance
+    if ti is None or not conf.getboolean("core", "multi_team"):
+        return
+    declared = session.scalar(
+        select(TaskOutletAssetReference.asset_id).where(
+            TaskOutletAssetReference.dag_id == ti.dag_id,
+            TaskOutletAssetReference.task_id == ti.task_id,
+            TaskOutletAssetReference.asset_id == asset.id,
+        )
+    )
+    if declared is None:
+        raise HTTPException(
+            status.HTTP_403_FORBIDDEN,
+            detail={
+                "reason": "asset_not_in_emission_list",
+                "message": "The task does not declare this asset",
+            },
+        )
+
+
+def _require_emission_budget(source: _EmissionSource, *, session: Session) -> 
None:
+    query = 
select(func.count()).select_from(AssetEvent).where(AssetEvent.origin == 
EMISSION_ORIGIN)
+    if source.task_instance is not None:
+        query = query.where(AssetEvent.source_task_instance_id == 
source.task_instance.id)
+    else:
+        query = query.where(
+            AssetEvent.source_dag_id == source.dag_run.dag_id,
+            AssetEvent.source_run_id == source.dag_run.run_id,
+            AssetEvent.source_task_id.is_(None),
+        )
+    if (session.scalar(query) or 0) >= MAX_EMISSIONS_PER_SOURCE:
+        raise HTTPException(
+            status.HTTP_429_TOO_MANY_REQUESTS,
+            detail={"reason": "emission_limit", "message": f"More than 
{MAX_EMISSIONS_PER_SOURCE} emissions"},
+        )
+
+
+def _replay(event: AssetEvent, payload_hash: str, response: Response) -> 
AssetEventReceipt:
+    if event.payload_hash != payload_hash:
+        raise HTTPException(
+            status.HTTP_409_CONFLICT,
+            detail={
+                "reason": "idempotency_conflict",
+                "message": "The key was already used with a different payload",
+                "event_id": event.id,
+            },
+        )
+    response.status_code = status.HTTP_200_OK
+    return AssetEventReceipt(id=event.id, timestamp=event.timestamp, 
replayed=True)
+
+
[email protected](
+    "/emit",
+    status_code=status.HTTP_201_CREATED,
+    responses=create_openapi_http_exception_doc(
+        [
+            status.HTTP_403_FORBIDDEN,
+            status.HTTP_404_NOT_FOUND,
+            status.HTTP_409_CONFLICT,
+            status.HTTP_410_GONE,
+            status.HTTP_413_CONTENT_TOO_LARGE,
+            status.HTTP_422_UNPROCESSABLE_CONTENT,
+            status.HTTP_429_TOO_MANY_REQUESTS,
+        ]
+    ),
+)
+@skip_auto_ti_attempt_live
+def emit_asset_event(
+    *,
+    body: EmitAssetEventBody,
+    request: Request,
+    response: Response,
+    session: SessionDep,
+    token: TIToken = CurrentTIToken,
+) -> AssetEventReceipt:
+    """Write an asset event now, independent of the outcome of the task or 
callback that emits it."""
+    source = _resolve_emission_source(body, request, token, session=session)
+    asset = _get_emission_asset(body, session=session)
+    payload_hash = _compute_payload_hash(asset, body)
+    _require_emission_list_entry(source, asset, session=session)
+    scope_hash = _compute_hash([EMISSION_ORIGIN, asset.id, *source.scope, 
body.idempotency_key])
+    if (
+        existing := 
session.scalar(select(AssetEvent).where(AssetEvent.scope_hash == scope_hash))
+    ) is not None:
+        return _replay(existing, payload_hash, response)
+    _require_emission_budget(source, session=session)
+
+    callbacks: list[Callable[[], None]] = []
+    try:
+        event = AssetManager.register_asset_change(
+            asset=asset,
+            task_instance=source.task_instance,
+            dag_run=None if source.task_instance is not None else 
source.dag_run,
+            extra=body.extra,
+            partition_key=body.partition_key,
+            origin=EMISSION_ORIGIN,
+            idempotency_key=body.idempotency_key,
+            scope_hash=scope_hash,
+            payload_hash=payload_hash,
+            callback_sink=callbacks,
+            session=session,
+        )
+    except IntegrityError:
+        # Another request with the same key committed first. This request 
wrote nothing before
+        # the event, so rolling back the whole transaction loses nothing.
+        session.rollback()
+        existing = 
session.scalar(select(AssetEvent).where(AssetEvent.scope_hash == scope_hash))
+        if existing is None:
+            raise
+        return _replay(existing, payload_hash, response)
+    if event is None:
+        raise HTTPException(
+            status.HTTP_404_NOT_FOUND,
+            detail={"reason": "asset_not_found", "message": "The asset is not 
in the Airflow database"},
+        )
+    receipt = AssetEventReceipt(id=event.id, timestamp=event.timestamp)
+    # Listeners must not see an event that the database does not commit.
+    session.commit()

Review Comment:
   This commits a caller-owned `SessionDep` inside the route. The session 
dependency already owns the transaction through `_get_session()` and 
`create_session()` (`common/db/common.py:47–52`), so this also commits any 
pending caller/dependency work and prevents that work from being rolled back on 
a subsequent failure. Keep transaction completion with the session owner and 
arrange listener execution after its successful commit, or explicitly own a 
separate transaction for emission instead of committing the supplied session.



##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_events.py:
##########
@@ -32,19 +39,314 @@
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.api_fastapi.execution_api.datamodels.asset import AssetResponse
 from airflow.api_fastapi.execution_api.datamodels.asset_event import (
+    AssetEventReceipt,
     AssetEventResponse,
     AssetEventsResponse,
+    EmitAssetEventBody,
 )
-from airflow.models.asset import AssetAliasModel, AssetEvent, AssetModel
+from airflow.api_fastapi.execution_api.datamodels.token import TIToken
+from airflow.api_fastapi.execution_api.security import (
+    _IN_PROCESS_NON_TI_CALLER,
+    CurrentTIToken,
+    ExecutionAPIRoute,
+    skip_auto_ti_attempt_live,
+)
+from airflow.assets.manager import AssetManager
+from airflow.configuration import conf
+from airflow.models.asset import AssetAliasModel, AssetEvent, AssetModel, 
TaskOutletAssetReference
+from airflow.models.callback import Callback, ExecutorCallback
+from airflow.models.dagrun import DagRun
+from airflow.models.deadline import Deadline
+from airflow.models.taskinstance import TaskInstance
 from airflow.utils.sqlalchemy import JsonContains
+from airflow.utils.state import CallbackState
 
 router = APIRouter(
+    route_class=ExecutionAPIRoute,
     responses={
         status.HTTP_404_NOT_FOUND: {"description": "Asset not found"},
         status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
     },
 )
 
+EMISSION_ORIGIN = "emission"
+# Prototype defaults; the AIP leaves the final values open.
+ARCHIVED_RETRY_EMISSION_WINDOW = timedelta(minutes=10)
+MAX_EMISSION_EXTRA_BYTES = 64 * 1024
+MAX_EMISSIONS_PER_SOURCE = 10_000
+
+
+class _EmissionSource(NamedTuple):
+    task_instance: TaskInstance | None
+    dag_run: DagRun
+    # Retries keep these values, so a repeated key stays in one scope; a clear 
changes clear_number.
+    scope: tuple[str | int | None, ...]
+
+
+def _get_dag_run(dag_id: str | None, run_id: str | None, *, session: Session) 
-> DagRun:
+    dag_run = session.scalar(select(DagRun).where(DagRun.dag_id == dag_id, 
DagRun.run_id == run_id))
+    if dag_run is None:
+        raise HTTPException(
+            status.HTTP_404_NOT_FOUND, detail={"reason": "not_found", 
"message": "Source Dag run not found"}
+        )
+    return dag_run
+
+
+def _build_dag_run_source(dag_run: DagRun) -> _EmissionSource:
+    return _EmissionSource(
+        task_instance=None,
+        dag_run=dag_run,
+        scope=("dag_run", dag_run.dag_id, dag_run.run_id, 
dag_run.clear_number),
+    )
+
+
+def _build_task_source(ti: TaskInstance, *, session: Session) -> 
_EmissionSource:
+    dag_run = _get_dag_run(ti.dag_id, ti.run_id, session=session)
+    return _EmissionSource(
+        task_instance=ti,
+        dag_run=dag_run,
+        scope=("task", ti.dag_id, ti.run_id, ti.task_id, ti.map_index, 
dag_run.clear_number),
+    )
+
+
+def _require_emitting_attempt(ti: TaskInstance) -> None:
+    if ti.working_set:
+        return
+    # The task runner reports up_for_retry before it runs on_retry_callback, 
and that report
+    # archives the attempt, so the callback can only emit through this bounded 
exception.
+    if (
+        ti.archived_reason == "retry"
+        and ti.end_date is not None
+        and timezone.utcnow() - ti.end_date <= ARCHIVED_RETRY_EMISSION_WINDOW
+    ):
+        return
+    raise HTTPException(
+        status.HTTP_410_GONE,
+        detail={"reason": "attempt_archived", "message": "This task attempt 
can no longer emit events"},
+    )
+
+
+def _resolve_emission_source(
+    body: EmitAssetEventBody, request: Request, token: TIToken, *, session: 
Session
+) -> _EmissionSource:
+    if body.source_dag_id is not None or body.source_run_id is not None:
+        # Only the trusted, in-process Dag processor transport names a 
callback's Dag run.
+        if not request.scope.get(_IN_PROCESS_NON_TI_CALLER):
+            raise HTTPException(
+                status.HTTP_403_FORBIDDEN,
+                detail={"reason": "forbidden", "message": "Callback provenance 
requires the Dag processor"},
+            )
+        return _build_dag_run_source(_get_dag_run(body.source_dag_id, 
body.source_run_id, session=session))
+
+    ti = session.scalar(
+        select(TaskInstance).where(TaskInstance.id == 
token.id).execution_options(include_all_attempts=True)
+    )
+    if ti is not None:
+        _require_emitting_attempt(ti)
+        return _build_task_source(ti, session=session)
+
+    callback = session.get(Callback, token.id)
+    if isinstance(callback, ExecutorCallback):
+        if callback.state != CallbackState.RUNNING:
+            raise HTTPException(
+                status.HTTP_410_GONE,
+                detail={"reason": "callback_finished", "message": "This 
callback can no longer emit events"},
+            )
+        dagrun_id = 
session.scalar(select(Deadline.dagrun_id).where(Deadline.callback_id == 
callback.id))
+        dag_run = session.get(DagRun, dagrun_id) if dagrun_id is not None else 
None
+        if dag_run is None:
+            raise HTTPException(
+                status.HTTP_403_FORBIDDEN,
+                detail={"reason": "forbidden", "message": "This callback has 
no Dag run to emit for"},
+            )
+        return _build_dag_run_source(dag_run)
+
+    raise HTTPException(
+        status.HTTP_403_FORBIDDEN,
+        detail={"reason": "forbidden", "message": "Asset emission requires a 
task or a callback"},
+    )
+
+
+def _get_emission_asset(body: EmitAssetEventBody, *, session: Session) -> 
AssetModel:
+    query = select(AssetModel).options(joinedload(AssetModel.active))
+    if body.name is not None:
+        query = query.where(AssetModel.name == body.name)
+    if body.uri is not None:
+        query = query.where(AssetModel.uri == body.uri)
+    assets = session.scalars(query).unique().all()
+    active = [asset for asset in assets if asset.active is not None]
+    if len(active) == 1:
+        return active[0]
+    if len(active) > 1:
+        raise HTTPException(
+            status.HTTP_409_CONFLICT,
+            detail={"reason": "asset_ambiguous", "message": "The reference 
matches more than one asset"},
+        )
+    if assets:
+        raise HTTPException(
+            status.HTTP_409_CONFLICT,
+            detail={"reason": "asset_inactive", "message": "No active Dag 
references this asset"},
+        )
+    raise HTTPException(
+        status.HTTP_404_NOT_FOUND,
+        detail={"reason": "asset_not_found", "message": "The asset is not in 
the Airflow database"},
+    )
+
+
+def _compute_hash(value: object) -> str:
+    """Hash a value as canonical JSON; a NaN or infinite float raises 
ValueError."""
+    payload = json.dumps(value, sort_keys=True, separators=(",", ":"), 
allow_nan=False)
+    return hashlib.sha256(payload.encode()).hexdigest()
+
+
+def _compute_payload_hash(asset: AssetModel, body: EmitAssetEventBody) -> str:
+    try:
+        extra_size = len(json.dumps(body.extra, separators=(",", ":"), 
allow_nan=False).encode())
+        payload_hash = _compute_hash(
+            {"asset_id": asset.id, "extra": body.extra, "partition_key": 
body.partition_key}
+        )
+    except ValueError:
+        raise HTTPException(
+            status.HTTP_422_UNPROCESSABLE_CONTENT,
+            detail={
+                "reason": "invalid_json",
+                "message": "extra must be strict JSON, without NaN or 
Infinity",
+            },
+        )
+    if extra_size > MAX_EMISSION_EXTRA_BYTES:
+        raise HTTPException(
+            status.HTTP_413_CONTENT_TOO_LARGE,
+            detail={
+                "reason": "extra_too_large",
+                "message": f"extra is larger than {MAX_EMISSION_EXTRA_BYTES} 
bytes",
+            },
+        )
+    return payload_hash
+
+
+def _require_emission_list_entry(source: _EmissionSource, asset: AssetModel, 
*, session: Session) -> None:
+    ti = source.task_instance
+    if ti is None or not conf.getboolean("core", "multi_team"):
+        return
+    declared = session.scalar(
+        select(TaskOutletAssetReference.asset_id).where(
+            TaskOutletAssetReference.dag_id == ti.dag_id,
+            TaskOutletAssetReference.task_id == ti.task_id,
+            TaskOutletAssetReference.asset_id == asset.id,
+        )
+    )
+    if declared is None:
+        raise HTTPException(
+            status.HTTP_403_FORBIDDEN,
+            detail={
+                "reason": "asset_not_in_emission_list",
+                "message": "The task does not declare this asset",
+            },
+        )
+
+
+def _require_emission_budget(source: _EmissionSource, *, session: Session) -> 
None:
+    query = 
select(func.count()).select_from(AssetEvent).where(AssetEvent.origin == 
EMISSION_ORIGIN)
+    if source.task_instance is not None:
+        query = query.where(AssetEvent.source_task_instance_id == 
source.task_instance.id)
+    else:
+        query = query.where(
+            AssetEvent.source_dag_id == source.dag_run.dag_id,
+            AssetEvent.source_run_id == source.dag_run.run_id,
+            AssetEvent.source_task_id.is_(None),

Review Comment:
   The Dag-source budget query has no supporting index on `asset_event`: the 
existing indexes start with asset ID, task-instance ID, or scope hash, none of 
which this branch filters on. Every Dag or executor callback emission therefore 
counts through the historical event table, making emission latency grow with 
all retained events rather than this source's events. Add a matching composite 
source index in the model and migration, or use an indexed per-source counter.



##########
airflow-core/src/airflow/dag_processing/collection.py:
##########
@@ -1104,6 +1104,8 @@ def _get_task_ports(data: dict, inlets: bool, outlets: 
bool) -> Iterable[str]:
         yield from data.get("inlets") or ()
     if outlets:
         yield from data.get("outlets") or ()
+        # An emission list declares a producer like an outlet does, but 
success emits nothing for it.
+        yield from data.get("emits") or ()

Review Comment:
   This adds producer reference rows, but not the serialized scheduling 
dependency. `_DependencyDetector.detect_task_dependencies()` in 
`serialized_objects.py:948` still visits only `task.outlets`, while 
`get_scheduling_dependencies()` consumes 
`SerializedDagModel.get_dag_dependencies()`. An emission-only producer 
therefore remains absent from the scheduling graph despite its new reference 
row. Include `emits` in dependency detection for ordinary and mapped tasks, and 
assert the producer edge in a serialization regression test.



##########
task-sdk/src/airflow/sdk/execution_time/asset_events.py:
##########
@@ -0,0 +1,150 @@
+# 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
+
+import json
+import uuid
+from contextlib import contextmanager
+from contextvars import ContextVar
+from typing import TYPE_CHECKING
+
+import attrs
+
+from airflow.sdk.definitions.asset import Asset, AssetEventReceipt, 
AssetNameRef, AssetUriRef
+from airflow.sdk.exceptions import (
+    AirflowRuntimeError,
+    AssetEmissionError,
+    AssetEmissionUnavailableError,
+    ErrorType,
+)
+from airflow.sdk.execution_time.comms import AssetEventEmissionResult, 
EmitAssetEvent
+
+if TYPE_CHECKING:
+    from collections.abc import Iterator
+
+    from pydantic.types import JsonValue
+
+    from airflow.sdk.definitions.asset import AssetRef
+
+
[email protected]
+class _EmissionScope:
+    callback_index: int | None = None
+    """The callback's position in the trusted parent's batch, for a Dag 
processor callback."""
+
+
+_scope: ContextVar[_EmissionScope | None] = ContextVar("asset_emission_scope", 
default=None)
+
+
+@contextmanager
+def set_asset_emission_scope(*, callback_index: int | None = None) -> 
Iterator[None]:
+    """
+    Let the code inside emit asset events.
+
+    The runtime that starts user code sets this: the task runner around a task 
and its callbacks,
+    the callback supervisor around an executor callback, and the Dag processor 
around a callback.
+    A Dag file parse has supervisor comms too, but no scope, so it cannot emit.
+    """
+    token = _scope.set(_EmissionScope(callback_index=callback_index))
+    try:
+        yield
+    finally:
+        _scope.reset(token)
+
+
+def _raise_emission_error(error: AirflowRuntimeError) -> None:
+    detail = error.error.detail if isinstance(error.error.detail, dict) else {}
+    body = detail.get("detail")
+    server_detail = body.get("detail") if isinstance(body, dict) else None
+    status_code = detail.get("status_code")
+    if error.error.error == ErrorType.PERMISSION_DENIED:
+        raise AssetEmissionUnavailableError(
+            "This context cannot emit asset events", reason="forbidden"
+        ) from error
+    if not isinstance(server_detail, dict):
+        # A server without the endpoint answers with FastAPI's plain "Not 
Found".
+        if status_code == 404:
+            raise AssetEmissionUnavailableError(
+                "The API server cannot emit asset events", 
reason="unsupported_server", status_code=404
+            ) from error
+        raise error

Review Comment:
   FastAPI request-validation errors have a list-valued `detail`, so invalid 
partition keys or an overlong idempotency key reach this branch and escape as 
`AirflowRuntimeError`. Callers catching the public `AssetEmissionError` cannot 
handle these ordinary emission failures, unlike the endpoint's structured 
errors. Translate validation responses to `AssetEmissionError` while preserving 
status 422, and add a test with a list-valued server detail.



##########
task-sdk/src/airflow/sdk/execution_time/asset_events.py:
##########
@@ -0,0 +1,150 @@
+# 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
+
+import json
+import uuid
+from contextlib import contextmanager
+from contextvars import ContextVar
+from typing import TYPE_CHECKING
+
+import attrs
+
+from airflow.sdk.definitions.asset import Asset, AssetEventReceipt, 
AssetNameRef, AssetUriRef
+from airflow.sdk.exceptions import (
+    AirflowRuntimeError,
+    AssetEmissionError,
+    AssetEmissionUnavailableError,
+    ErrorType,
+)
+from airflow.sdk.execution_time.comms import AssetEventEmissionResult, 
EmitAssetEvent
+
+if TYPE_CHECKING:
+    from collections.abc import Iterator
+
+    from pydantic.types import JsonValue
+
+    from airflow.sdk.definitions.asset import AssetRef
+
+
[email protected]
+class _EmissionScope:
+    callback_index: int | None = None
+    """The callback's position in the trusted parent's batch, for a Dag 
processor callback."""
+
+
+_scope: ContextVar[_EmissionScope | None] = ContextVar("asset_emission_scope", 
default=None)
+
+
+@contextmanager
+def set_asset_emission_scope(*, callback_index: int | None = None) -> 
Iterator[None]:
+    """
+    Let the code inside emit asset events.
+
+    The runtime that starts user code sets this: the task runner around a task 
and its callbacks,
+    the callback supervisor around an executor callback, and the Dag processor 
around a callback.
+    A Dag file parse has supervisor comms too, but no scope, so it cannot emit.
+    """
+    token = _scope.set(_EmissionScope(callback_index=callback_index))
+    try:
+        yield
+    finally:
+        _scope.reset(token)
+
+
+def _raise_emission_error(error: AirflowRuntimeError) -> None:
+    detail = error.error.detail if isinstance(error.error.detail, dict) else {}
+    body = detail.get("detail")
+    server_detail = body.get("detail") if isinstance(body, dict) else None
+    status_code = detail.get("status_code")
+    if error.error.error == ErrorType.PERMISSION_DENIED:
+        raise AssetEmissionUnavailableError(
+            "This context cannot emit asset events", reason="forbidden"
+        ) from error
+    if not isinstance(server_detail, dict):
+        # A server without the endpoint answers with FastAPI's plain "Not 
Found".
+        if status_code == 404:
+            raise AssetEmissionUnavailableError(
+                "The API server cannot emit asset events", 
reason="unsupported_server", status_code=404
+            ) from error
+        raise error
+    raise AssetEmissionError(
+        server_detail.get("message", str(error)),
+        reason=server_detail.get("reason"),
+        status_code=status_code,
+    ) from error
+
+
+def emit_asset_event(
+    asset: Asset | AssetRef,
+    *,
+    extra: dict[str, JsonValue] | None = None,
+    partition_key: str | None = None,
+    idempotency_key: str | None = None,
+) -> AssetEventReceipt:
+    """
+    Write an asset event now, from a running task or from a callback.
+
+    Unlike an outlet, the event does not wait for the task to succeed, and a 
later failure does not
+    remove it. The asset must already be in the Airflow database. An omitted 
partition key means no
+    partition; the event does not inherit the partition of the current run.
+
+    Give a stable ``idempotency_key`` when a retry must not write a second 
event. Without one, the
+    SDK makes a key for this call, so only its own automatic HTTP retries 
share it.
+    """
+    if isinstance(asset, Asset):
+        name, uri = asset.name, asset.uri
+    elif isinstance(asset, AssetNameRef):
+        name, uri = asset.name, None
+    elif isinstance(asset, AssetUriRef):
+        name, uri = None, asset.uri
+    else:
+        raise TypeError(f"Expected an Asset or an AssetRef, got 
{type(asset).__name__}")
+
+    try:
+        json.dumps(extra or {}, allow_nan=False)
+    except (TypeError, ValueError) as error:
+        raise AssetEmissionError(
+            f"extra must be strict JSON, without NaN or Infinity: {error}", 
reason="invalid_json"
+        ) from error
+
+    scope = _scope.get()
+    if scope is None:
+        raise AssetEmissionUnavailableError(
+            "Asset events can be emitted only from a running task or a 
callback", reason="no_runtime"
+        )

Review Comment:
   The emission scope is a `ContextVar`, and threads started with 
`threading.Thread`, `ThreadPoolExecutor.submit` or `loop.run_in_executor` begin 
with an empty context. So `emit_asset_event()` called from a worker thread 
inside a running task raises 
`AssetEmissionUnavailableError(reason="no_runtime")`, even though 
`SUPERVISOR_COMMS` is a module global with its own thread lock. Other SDK calls 
such as `Variable.get` and XCom work from those threads.
   
   This breaks the batch-ingestion pattern the PR uses as motivation, when the 
commit loop runs in a pool. Two options:
   - Let the runtime set a process-level flag. Each task or callback process 
runs one scope at a time, and the Dag processor sets the scope only around 
callbacks, after parsing.
   - At least document that emission must happen on the thread that runs the 
task or callback, or that `contextvars.copy_context()` has to be propagated by 
hand.



##########
task-sdk/src/airflow/sdk/execution_time/asset_events.py:
##########
@@ -0,0 +1,150 @@
+# 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
+
+import json
+import uuid
+from contextlib import contextmanager
+from contextvars import ContextVar
+from typing import TYPE_CHECKING
+
+import attrs
+
+from airflow.sdk.definitions.asset import Asset, AssetEventReceipt, 
AssetNameRef, AssetUriRef
+from airflow.sdk.exceptions import (
+    AirflowRuntimeError,
+    AssetEmissionError,
+    AssetEmissionUnavailableError,
+    ErrorType,
+)
+from airflow.sdk.execution_time.comms import AssetEventEmissionResult, 
EmitAssetEvent
+
+if TYPE_CHECKING:
+    from collections.abc import Iterator
+
+    from pydantic.types import JsonValue
+
+    from airflow.sdk.definitions.asset import AssetRef
+
+
[email protected]
+class _EmissionScope:
+    callback_index: int | None = None
+    """The callback's position in the trusted parent's batch, for a Dag 
processor callback."""
+
+
+_scope: ContextVar[_EmissionScope | None] = ContextVar("asset_emission_scope", 
default=None)
+
+
+@contextmanager
+def set_asset_emission_scope(*, callback_index: int | None = None) -> 
Iterator[None]:
+    """
+    Let the code inside emit asset events.
+
+    The runtime that starts user code sets this: the task runner around a task 
and its callbacks,
+    the callback supervisor around an executor callback, and the Dag processor 
around a callback.
+    A Dag file parse has supervisor comms too, but no scope, so it cannot emit.
+    """
+    token = _scope.set(_EmissionScope(callback_index=callback_index))
+    try:
+        yield
+    finally:
+        _scope.reset(token)
+
+
+def _raise_emission_error(error: AirflowRuntimeError) -> None:
+    detail = error.error.detail if isinstance(error.error.detail, dict) else {}
+    body = detail.get("detail")
+    server_detail = body.get("detail") if isinstance(body, dict) else None
+    status_code = detail.get("status_code")
+    if error.error.error == ErrorType.PERMISSION_DENIED:
+        raise AssetEmissionUnavailableError(
+            "This context cannot emit asset events", reason="forbidden"
+        ) from error
+    if not isinstance(server_detail, dict):
+        # A server without the endpoint answers with FastAPI's plain "Not 
Found".
+        if status_code == 404:
+            raise AssetEmissionUnavailableError(
+                "The API server cannot emit asset events", 
reason="unsupported_server", status_code=404
+            ) from error
+        raise error
+    raise AssetEmissionError(
+        server_detail.get("message", str(error)),
+        reason=server_detail.get("reason"),
+        status_code=status_code,
+    ) from error
+
+
+def emit_asset_event(
+    asset: Asset | AssetRef,
+    *,
+    extra: dict[str, JsonValue] | None = None,
+    partition_key: str | None = None,
+    idempotency_key: str | None = None,
+) -> AssetEventReceipt:
+    """
+    Write an asset event now, from a running task or from a callback.
+
+    Unlike an outlet, the event does not wait for the task to succeed, and a 
later failure does not
+    remove it. The asset must already be in the Airflow database. An omitted 
partition key means no
+    partition; the event does not inherit the partition of the current run.
+
+    Give a stable ``idempotency_key`` when a retry must not write a second 
event. Without one, the
+    SDK makes a key for this call, so only its own automatic HTTP retries 
share it.
+    """
+    if isinstance(asset, Asset):
+        name, uri = asset.name, asset.uri
+    elif isinstance(asset, AssetNameRef):
+        name, uri = asset.name, None
+    elif isinstance(asset, AssetUriRef):
+        name, uri = None, asset.uri
+    else:
+        raise TypeError(f"Expected an Asset or an AssetRef, got 
{type(asset).__name__}")
+
+    try:
+        json.dumps(extra or {}, allow_nan=False)
+    except (TypeError, ValueError) as error:
+        raise AssetEmissionError(
+            f"extra must be strict JSON, without NaN or Infinity: {error}", 
reason="invalid_json"
+        ) from error
+
+    scope = _scope.get()
+    if scope is None:
+        raise AssetEmissionUnavailableError(
+            "Asset events can be emitted only from a running task or a 
callback", reason="no_runtime"
+        )
+
+    from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
+
+    try:
+        result = SUPERVISOR_COMMS.send(
+            EmitAssetEvent(
+                name=name,
+                uri=uri,
+                extra=extra or {},
+                partition_key=partition_key,
+                idempotency_key=idempotency_key or str(uuid.uuid4()),

Review Comment:
   An explicitly supplied empty key is treated as though the caller omitted it, 
generating a different UUID on every call. That silently publishes duplicate 
events instead of rejecting the invalid key as the endpoint does. Only generate 
a key for `None`; forward an empty string unchanged so the server rejects it, 
or validate it locally.



##########
task-sdk/src/airflow/sdk/execution_time/callback_supervisor.py:
##########
@@ -306,14 +308,15 @@ def _target():
                     )
 
             _add_xcom_pull_to_context(callback_kwargs)
-            success, error_msg = execute_callback(
-                callback_path=callback_path,
-                callback_kwargs=callback_kwargs,
-                dag_rel_path=dag_rel_path,
-                bundle_path=bundle_path,
-                log=_log,
-                callback_kwargs_encoding=callback_kwargs_encoding,
-            )
+            with set_asset_emission_scope():
+                success, error_msg = execute_callback(

Review Comment:
   This opens the emission scope before `execute_callback()` loads the 
callable. That function executes the Dag module via `spec.loader.exec_module()` 
(or imports the callback module) before invoking the callback, so import-time 
code can now publish events with the deadline callback's identity. This 
contradicts the parsing rejection enforced elsewhere and can produce events 
during module loading rather than callback execution. Open the scope only 
around the actual callback/notifier invocations, leaving module loading outside 
it.



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