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

ashb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 3b188b904fb Pre-assign external_executor_id at queuing time to prevent 
duplicate execution on scheduler crash (#65594)
3b188b904fb is described below

commit 3b188b904fbb9525e17173f6ef1f5e5d388a75fc
Author: Ash Berlin-Taylor <[email protected]>
AuthorDate: Thu Apr 23 09:18:12 2026 +0100

    Pre-assign external_executor_id at queuing time to prevent duplicate 
execution on scheduler crash (#65594)
    
    When a scheduler crashes between dispatching a task to Celery and
    processing the QUEUED event that persists `external_executor_id`, the
    replacement scheduler cannot adopt the in-flight task. Without the
    Celery task ID in the database, `try_adopt_task_instances` has no
    `AsyncResult` to look up, so the task is reset and re-queued — causing
    duplicate execution of an already-running task.
    
    Fix this by generating `external_executor_id` via a DB-side UUID
    function (`gen_random_uuid` on PostgreSQL, `UUID()` on MySQL, a
    Python `uuid4` registered on SQLite) in the same bulk UPDATE that
    sets state=QUEUED. The ID is committed atomically with the state
    transition — no second write, no race window. RETURNING is used on
    PostgreSQL and SQLite to read back the generated UUIDs without a
    second round-trip; MySQL falls back to a SELECT.
    
    The CeleryExecutor passes the pre-assigned ID to `apply_async()` as
    the Celery `task_id`, making it deterministic from DB state. Other
    executors ignore it and overwrite with their own ID (e.g. ECS task
    ARN) during event processing.
    
    This also fixes the separate race in #55004 where `external_executor_id`
    is lost when the task instance row is locked during event processing.
    `process_executor_events` uses `skip_locked=True`, and
    `get_event_buffer()` flushes the executor's in-memory buffer into a
    local variable. If a TI is locked and skipped, its QUEUED event is
    consumed from the buffer but never processed — the event and its
    task ID are silently dropped. With the ID now written to the database
    before the task is even sent to Celery, adoption no longer depends on
    the event being processed.
    
    Closes: #55004
    Closes: #58570
    Closes: #64971
---
 .../src/airflow/executors/base_executor.py         |  7 +-
 .../src/airflow/executors/workloads/task.py        |  1 +
 .../src/airflow/jobs/scheduler_job_runner.py       | 68 +++++++++++++---
 .../src/airflow/utils/orm_event_handlers.py        |  2 +
 airflow-core/src/airflow/utils/sqlalchemy.py       | 31 ++++++-
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 34 +++++++-
 .../providers/celery/executors/celery_executor.py  | 32 ++++----
 .../celery/executors/celery_executor_utils.py      | 11 ++-
 .../unit/celery/executors/test_celery_executor.py  | 95 ++++++++++++++++++++++
 9 files changed, 252 insertions(+), 29 deletions(-)

diff --git a/airflow-core/src/airflow/executors/base_executor.py 
b/airflow-core/src/airflow/executors/base_executor.py
index 36d8c86f5b7..71d43a76a35 100644
--- a/airflow-core/src/airflow/executors/base_executor.py
+++ b/airflow-core/src/airflow/executors/base_executor.py
@@ -24,7 +24,7 @@ from collections import defaultdict, deque
 from collections.abc import Sequence
 from dataclasses import dataclass, field
 from functools import cached_property
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING, Any, ClassVar, cast
 
 import pendulum
 
@@ -173,6 +173,11 @@ class BaseExecutor(LoggingMixin):
     is_local: bool = False
     is_production: bool = True
 
+    # When True, the scheduler pre-assigns external_executor_id (a UUID) at 
queuing time,
+    # committed atomically with the QUEUED state. The executor can then use 
this ID to
+    # correlate the task with its external representation (e.g. Celery 
task_id).
+    pre_assigns_external_executor_id: ClassVar[bool] = False
+
     serve_logs: bool = False
 
     job_id: None | int | str = None
diff --git a/airflow-core/src/airflow/executors/workloads/task.py 
b/airflow-core/src/airflow/executors/workloads/task.py
index 3a95aab8310..d05affe4330 100644
--- a/airflow-core/src/airflow/executors/workloads/task.py
+++ b/airflow-core/src/airflow/executors/workloads/task.py
@@ -48,6 +48,7 @@ class TaskInstanceDTO(BaseModel):
     queue: str
     priority_weight: int
     executor_config: dict | None = Field(default=None, exclude=True)
+    external_executor_id: str | None = Field(default=None, exclude=True)
 
     parent_context_carrier: dict | None = None
     context_carrier: dict | None = None
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index e9db7625c38..67115ba7578 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -33,7 +33,7 @@ from functools import lru_cache, partial
 from itertools import groupby
 from typing import TYPE_CHECKING, Any, cast
 
-from sqlalchemy import CTE, and_, delete, exists, func, inspect, or_, select, 
text, tuple_, update
+from sqlalchemy import CTE, and_, case, delete, exists, func, inspect, or_, 
select, text, tuple_, update
 from sqlalchemy.exc import DBAPIError, OperationalError
 from sqlalchemy.orm import joinedload, lazyload, load_only, make_transient, 
selectinload
 from sqlalchemy.sql import expression
@@ -98,6 +98,7 @@ from airflow.utils.sqlalchemy import (
     get_dialect_name,
     is_lock_not_available_error,
     prohibit_commit,
+    random_db_uuid,
     with_row_locks,
 )
 from airflow.utils.state import CallbackState, DagRunState, State, 
TaskInstanceState
@@ -909,19 +910,66 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
             filter_for_tis = TI.filter_for_tis(executable_tis)
             if filter_for_tis is None:
                 return []
-            session.execute(
+
+            queued_values: dict[str, Any] = {
+                "state": TaskInstanceState.QUEUED,
+                "queued_dttm": timezone.utcnow(),
+                "queued_by_job_id": self.job.id,
+            }
+
+            # Pre-assign external_executor_id atomically with the QUEUED state 
so it
+            # survives a scheduler crash. Only done when an executor opts in 
via
+            # pre_assigns_external_executor_id (e.g. CeleryExecutor uses it as 
the
+            # Celery task_id passed to apply_async). In mixed-executor 
deployments,
+            # a CASE expression limits the UUID to TIs targeting an opt-in 
executor.
+            pre_assign_executors = {e for e in self.executors if 
e.pre_assigns_external_executor_id}
+            if pre_assign_executors == set(self.executors):
+                # All executors opt in — unconditional UUID for every TI.
+                queued_values["external_executor_id"] = random_db_uuid()
+            elif pre_assign_executors:
+                # Mixed — only TIs routed to an opt-in executor get a UUID.
+                opt_in_names: set[str] = set()
+                default_opts_in = self.executor in pre_assign_executors
+                for exc in pre_assign_executors:
+                    if exc.name:
+                        if exc.name.alias:
+                            opt_in_names.add(exc.name.alias)
+                        opt_in_names.add(exc.name.module_path)
+                whens = []
+                if opt_in_names:
+                    whens.append((TI.executor.in_(opt_in_names), 
random_db_uuid()))
+                if default_opts_in:
+                    whens.append((TI.executor.is_(None), random_db_uuid()))
+                if whens:
+                    queued_values["external_executor_id"] = case(*whens, 
else_=TI.external_executor_id)
+
+            queued_update = (
                 update(TI)
                 .where(filter_for_tis)
-                .values(
-                    # TODO[ha]: should we use func.now()? How does that work 
with DB timezone
-                    # on mysql when it's not UTC?
-                    state=TaskInstanceState.QUEUED,
-                    queued_dttm=timezone.utcnow(),
-                    queued_by_job_id=self.job.id,
-                )
+                .values(**queued_values)
                 .execution_options(synchronize_session=False)
             )
 
+            if pre_assign_executors:
+                # Read the DB-generated UUIDs back onto the in-memory objects 
so the
+                # workload DTO carries them through to 
send_workload_to_executor (the
+                # objects are about to be detached by make_transient). Use 
RETURNING
+                # where supported (PostgreSQL); fall back to a SELECT for 
MySQL and
+                # SQLite (RETURNING requires SQLite 3.35+ which isn't 
guaranteed).
+                if get_dialect_name(session) == "postgresql":
+                    result = session.execute(queued_update.returning(TI.id, 
TI.external_executor_id))
+                    id_map = {row[0]: row[1] for row in result}
+                else:
+                    session.execute(queued_update)
+                    id_rows = session.execute(
+                        select(TI.id, 
TI.external_executor_id).where(filter_for_tis)
+                    ).all()
+                    id_map = {row[0]: row[1] for row in id_rows}
+                for ti in executable_tis:
+                    ti.external_executor_id = id_map.get(ti.id)
+            else:
+                session.execute(queued_update)
+
             for ti in executable_tis:
                 ti.emit_state_change_metric(TaskInstanceState.QUEUED)
 
@@ -2766,7 +2814,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                         .where(Job.state.is_distinct_from(JobState.RUNNING))
                         .join(TI.dag_run)
                         .where(DagRun.state == DagRunState.RUNNING)
-                        .options(load_only(TI.dag_id, TI.task_id, TI.run_id))
+                        .options(load_only(TI.dag_id, TI.task_id, TI.run_id, 
TI.external_executor_id))
                     )
 
                     # Lock these rows, so that another scheduler can't try and 
adopt these too
diff --git a/airflow-core/src/airflow/utils/orm_event_handlers.py 
b/airflow-core/src/airflow/utils/orm_event_handlers.py
index bbbd2a21d7e..da42433dbb0 100644
--- a/airflow-core/src/airflow/utils/orm_event_handlers.py
+++ b/airflow-core/src/airflow/utils/orm_event_handlers.py
@@ -21,6 +21,7 @@ import logging
 import os
 import time
 import traceback
+from uuid import uuid4
 
 from sqlalchemy import event, exc
 from sqlalchemy.orm import Mapper
@@ -48,6 +49,7 @@ def setup_event_handlers(engine):
             cursor.execute("PRAGMA foreign_keys=ON")
             cursor.execute("PRAGMA journal_mode=WAL")
             cursor.close()
+            dbapi_connection.create_function("uuid4", 0, lambda: str(uuid4()))
 
     # this ensures coherence in mysql when storing datetimes (not required for 
postgres)
     if engine.dialect.name == "mysql":
diff --git a/airflow-core/src/airflow/utils/sqlalchemy.py 
b/airflow-core/src/airflow/utils/sqlalchemy.py
index 266be08c3bb..a767c65fad9 100644
--- a/airflow-core/src/airflow/utils/sqlalchemy.py
+++ b/airflow-core/src/airflow/utils/sqlalchemy.py
@@ -24,9 +24,11 @@ import logging
 from collections.abc import Generator
 from typing import TYPE_CHECKING
 
-from sqlalchemy import TIMESTAMP, PickleType, event, nullsfirst
+from sqlalchemy import TIMESTAMP, PickleType, String, event, nullsfirst
 from sqlalchemy.dialects import mysql
 from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.ext.compiler import compiles
+from sqlalchemy.sql.functions import FunctionElement
 from sqlalchemy.types import JSON, Text, TypeDecorator
 
 from airflow._shared.timezones.timezone import make_naive, utc
@@ -56,6 +58,33 @@ def get_dialect_name(session: Session) -> str | None:
     return getattr(bind.dialect, "name", None)
 
 
+class random_db_uuid(FunctionElement):
+    """
+    Cross-dialect random UUID generation for use in SQL expressions.
+
+    Compiles to ``gen_random_uuid()`` on PostgreSQL, ``UUID()`` on MySQL,
+    and ``uuid4()`` on SQLite (registered via :func:`setup_event_handlers`).
+    """
+
+    type = String()
+    inherit_cache = True
+
+
+@compiles(random_db_uuid, "postgresql")
+def _random_db_uuid_pg(element, compiler, **kw):
+    return "gen_random_uuid()"
+
+
+@compiles(random_db_uuid, "mysql")
+def _random_db_uuid_mysql(element, compiler, **kw):
+    return "UUID()"
+
+
+@compiles(random_db_uuid, "sqlite")
+def _random_db_uuid_sqlite(element, compiler, **kw):
+    return "uuid4()"
+
+
 class UtcDateTime(TypeDecorator):
     """
     Similar to :class:`~sqlalchemy.types.TIMESTAMP` with ``timezone=True`` 
option, with some differences.
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 84694b47c69..061a87e3aa4 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -30,7 +30,7 @@ from pathlib import Path
 from typing import TYPE_CHECKING
 from unittest import mock
 from unittest.mock import MagicMock, patch
-from uuid import uuid4
+from uuid import UUID, uuid4
 
 import pendulum
 import psutil
@@ -2421,6 +2421,38 @@ class TestSchedulerJob:
         assert mock_queue_workload.called
         session.rollback()
 
+    def 
test_executable_task_instances_to_queued_sets_external_executor_id(self, 
dag_maker, session):
+        """external_executor_id is written to the DB in the same UPDATE that 
sets state=QUEUED."""
+        dag_id = "SchedulerJobTest.test_executable_sets_external_executor_id"
+        session = settings.Session()
+        with dag_maker(dag_id=dag_id, start_date=DEFAULT_DATE, 
session=session):
+            EmptyOperator(task_id="dummy")
+
+        class PreAssigningExecutor(MockExecutor):
+            pre_assigns_external_executor_id = True
+
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, 
executors=[PreAssigningExecutor()])
+
+        dr = dag_maker.create_dagrun()
+        ti = dr.get_task_instance("dummy", session)
+        ti.state = State.SCHEDULED
+        session.merge(ti)
+        session.flush()
+
+        returned_tis = 
self.job_runner._executable_task_instances_to_queued(max_tis=32, 
session=session)
+
+        assert len(returned_tis) == 1
+        # In-memory object (post make_transient) should carry the UUID
+        assert returned_tis[0].external_executor_id is not None
+        UUID(returned_tis[0].external_executor_id)
+
+        # DB row should also have it (the whole point — survives a crash)
+        db_value = 
session.scalar(select(TaskInstance.external_executor_id).where(TaskInstance.id 
== ti.id))
+        assert db_value == returned_tis[0].external_executor_id
+
+        session.rollback()
+
     @pytest.mark.parametrize("state", [State.FAILED, State.SUCCESS])
     def 
test_enqueue_task_instances_sets_ti_state_to_None_if_dagrun_in_finish_state(self,
 state, dag_maker):
         """This tests that task instances whose dagrun is in finished state 
are not queued"""
diff --git 
a/providers/celery/src/airflow/providers/celery/executors/celery_executor.py 
b/providers/celery/src/airflow/providers/celery/executors/celery_executor.py
index d30bca1e570..16d634fbdef 100644
--- a/providers/celery/src/airflow/providers/celery/executors/celery_executor.py
+++ b/providers/celery/src/airflow/providers/celery/executors/celery_executor.py
@@ -32,7 +32,7 @@ import time
 from collections import Counter
 from concurrent.futures import ProcessPoolExecutor
 from multiprocessing import cpu_count
-from typing import TYPE_CHECKING, Any, TypeAlias, cast
+from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, cast
 
 from celery import states as celery_states
 from deprecated import deprecated
@@ -108,6 +108,7 @@ class CeleryExecutor(BaseExecutor):
     supports_ad_hoc_ti_run: bool = True
     supports_callbacks: bool = True
     sentry_integration: str = 
"sentry_sdk.integrations.celery.CeleryIntegration"
+    pre_assigns_external_executor_id: ClassVar[bool] = True
 
     # TODO: Remove this flag once providers depend on Airflow 3.2.
     supports_sentry: bool = True
@@ -312,21 +313,22 @@ class CeleryExecutor(BaseExecutor):
         pass
 
     def try_adopt_task_instances(self, tis: Sequence[TaskInstance]) -> 
Sequence[TaskInstance]:
-        # See which of the TIs are still alive (or have finished even!)
+        # The scheduler pre-assigns external_executor_id at queuing time 
(committed to DB
+        # atomically with the QUEUED state), and the same ID is used as the 
Celery task_id
+        # in apply_async(). This means external_executor_id is always set for 
tasks queued
+        # by current code, and always matches the Celery task.
         #
-        # Since Celery doesn't store "SENT" state for queued commands (if we 
create an AsyncResult with a made
-        # up id it just returns PENDING state for it), we have to store 
Celery's task_id against the TI row to
-        # look at in future.
+        # A narrow window remains: the scheduler could die after committing 
the QUEUED
+        # state (with the pre-assigned ID) but before executor.heartbeat() 
calls
+        # apply_async(). In that case the ID is in the DB but the Celery task 
was never
+        # submitted. AsyncResult returns PENDING and the task is adopted and 
monitored
+        # until task_queued_timeout fires. This is an acceptable trade-off: 
the window is
+        # small (within a single scheduler loop iteration) and the task is 
eventually
+        # recovered, whereas the pre-fix behaviour caused duplicate execution.
         #
-        # This process is not perfect -- we could have sent the task to 
celery, and crashed before we were
-        # able to record the AsyncResult.task_id in the TaskInstance table, in 
which case we won't adopt the
-        # task (it'll either run and update the TI state, or the scheduler 
will clear and re-queue it. Either
-        # way it won't get executed more than once)
-        #
-        # (If we swapped it around, and generated a task_id for Celery, stored 
that in TI and enqueued that
-        # there is also still a race condition where we could generate and 
store the task_id, but die before
-        # we managed to enqueue the command. Since neither way is perfect we 
always have to deal with this
-        # process not being perfect.)
+        # Tasks with external_executor_id=None were queued by older scheduler 
code.
+        # We cannot adopt these — there is no Celery task_id to look up — so 
they go to
+        # not_adopted_tis and get reset by the caller.
         from celery.result import AsyncResult
 
         celery_tasks = {}
@@ -352,8 +354,8 @@ class CeleryExecutor(BaseExecutor):
             result, ti = celery_tasks[celery_task_id]
             result.backend = cached_celery_backend
             if isinstance(result.result, BaseException):
-                e = result.result
                 # Log the exception we got from the remote end
+                e = result.result
                 self.log.warning("Task %s failed with error", ti.key, 
exc_info=e)
 
             # Set the correct elements of the state dicts, then update this
diff --git 
a/providers/celery/src/airflow/providers/celery/executors/celery_executor_utils.py
 
b/providers/celery/src/airflow/providers/celery/executors/celery_executor_utils.py
index 5ff62866e8c..f16b7c226fa 100644
--- 
a/providers/celery/src/airflow/providers/celery/executors/celery_executor_utils.py
+++ 
b/providers/celery/src/airflow/providers/celery/executors/celery_executor_utils.py
@@ -408,11 +408,20 @@ def send_workload_to_executor(
     # Create the Celery app with the correct configuration.
     celery_app = create_celery_app(_conf)
 
+    celery_task_id = None
     if AIRFLOW_V_3_0_PLUS:
         # Get the task from the app.
         celery_task = celery_app.tasks["execute_workload"]
         if TYPE_CHECKING:
             assert isinstance(args, workloads.BaseWorkload)
+        # Extract the pre-assigned Celery task ID before serializing the 
workload.
+        # This ID was committed to the DB at queuing time (as 
external_executor_id) and is
+        # excluded from model_dump_json(), so workers never see it. Passing it 
to apply_async()
+        # makes the Celery task ID deterministic from DB state, closing the 
race window where a
+        # scheduler crash between apply_async() and event processing left 
external_executor_id
+        # unset and the task unadoptable.
+        if executor_id := getattr(getattr(args, "ti", None), 
"external_executor_id", None):
+            celery_task_id = executor_id
         args = (args.model_dump_json(),)
     else:
         # Get the task from the app.
@@ -430,7 +439,7 @@ def send_workload_to_executor(
 
     try:
         with timeout(seconds=OPERATION_TIMEOUT):
-            result = celery_task.apply_async(args=args, queue=queue)
+            result = celery_task.apply_async(args=args, queue=queue, 
task_id=celery_task_id)
     except (Exception, AirflowTaskTimeout) as e:
         exception_traceback = f"Celery Task ID: 
{key}\n{traceback.format_exc()}"
         result = ExceptionWithTraceback(e, exception_traceback)
diff --git 
a/providers/celery/tests/unit/celery/executors/test_celery_executor.py 
b/providers/celery/tests/unit/celery/executors/test_celery_executor.py
index 438c9487192..7b1ec702fc4 100644
--- a/providers/celery/tests/unit/celery/executors/test_celery_executor.py
+++ b/providers/celery/tests/unit/celery/executors/test_celery_executor.py
@@ -286,6 +286,39 @@ class TestCeleryExecutor:
         assert executor.workloads == {key_1: AsyncResult("231"), key_2: 
AsyncResult("232")}
         assert not_adopted_tis == []
 
+    @pytest.mark.backend("mysql", "postgres")
+    @time_machine.travel("2020-01-01", tick=False)
+    def test_try_adopt_with_and_without_external_executor_id(
+        self, clean_dags_dagruns_and_dagbundles, testing_dag_bundle
+    ):
+        """TIs with an ID are adopted; TIs without are returned for reset."""
+        start_date = timezone.utcnow() - timedelta(days=2)
+
+        with DAG("test_try_adopt_mixed", schedule=None) as dag:
+            task_1 = BaseOperator(task_id="task_1", start_date=start_date)
+            task_2 = BaseOperator(task_id="task_2", start_date=start_date)
+
+        if AIRFLOW_V_3_0_PLUS:
+            sync_dag_to_db(dag)
+            dag_version = DagVersion.get_latest_version(dag.dag_id)
+            ti_with_id = create_task_instance(task=task_1, run_id=None, 
dag_version_id=dag_version.id)
+            ti_without_id = create_task_instance(task=task_2, run_id=None, 
dag_version_id=dag_version.id)
+        else:
+            ti_with_id = TaskInstance(task=task_1, run_id=None)
+            ti_without_id = TaskInstance(task=task_2, run_id=None)
+        ti_with_id.external_executor_id = "231"
+        ti_with_id.state = State.QUEUED
+        ti_without_id.external_executor_id = None
+        ti_without_id.state = State.QUEUED
+
+        executor = celery_executor.CeleryExecutor()
+        not_adopted_tis = executor.try_adopt_task_instances([ti_with_id, 
ti_without_id])
+
+        key_1 = TaskInstanceKey(dag.dag_id, task_1.task_id, None, 0)
+        assert key_1 in executor.running
+        assert executor.workloads == {key_1: AsyncResult("231")}
+        assert not_adopted_tis == [ti_without_id]
+
     @pytest.fixture
     def mock_celery_revoke(self):
         with _prepare_app() as app:
@@ -439,6 +472,68 @@ def test_send_workloads_to_celery_hang(register_signals):
         assert results == [(None, None, 1) for _ in workload_tuples_to_send]
 
 
+def _has_external_executor_id_field() -> bool:
+    try:
+        from airflow.executors.workloads.task import TaskInstanceDTO
+
+        return "external_executor_id" in TaskInstanceDTO.model_fields
+    except (ImportError, AttributeError):
+        return False
+
+
[email protected](
+    not _has_external_executor_id_field(),
+    reason="TaskInstanceDTO.external_executor_id not available in this 
airflow-core version",
+)
+def test_send_workload_uses_external_executor_id_as_celery_task_id():
+    """Pre-assigned external_executor_id is passed as task_id to 
apply_async()."""
+    from airflow.executors.workloads.task import TaskInstanceDTO
+
+    pre_assigned_id = "pre-assigned-uuid-for-celery"
+    ti_dto = TaskInstanceDTO(
+        id="00000000-0000-0000-0000-000000000001",
+        dag_version_id="00000000-0000-0000-0000-000000000002",
+        task_id="test_task",
+        dag_id="test_dag",
+        run_id="test_run",
+        try_number=1,
+        map_index=-1,
+        pool_slots=1,
+        queue="default",
+        priority_weight=1,
+        external_executor_id=pre_assigned_id,
+    )
+    key = TaskInstanceKey(
+        dag_id="test_dag", task_id="test_task", run_id="test_run", 
map_index=-1, try_number=1
+    )
+
+    mock_result = mock.Mock(task_id=pre_assigned_id)
+    mock_celery_task = mock.Mock()
+    mock_celery_task.apply_async.return_value = mock_result
+
+    mock_app = mock.Mock()
+    mock_app.tasks = {"execute_workload": mock_celery_task}
+
+    from airflow.executors.workloads import ExecuteTask
+
+    workload = mock.Mock(spec=ExecuteTask)
+    workload.ti = ti_dto
+    workload.model_dump_json.return_value = "{}"
+
+    with mock.patch(
+        
"airflow.providers.celery.executors.celery_executor_utils.create_celery_app",
+        return_value=mock_app,
+    ):
+        result_key, _, result = 
celery_executor_utils.send_workload_to_executor(
+            (key, workload, "default", None)
+        )
+
+    mock_celery_task.apply_async.assert_called_once_with(
+        args=("{}",), queue="default", task_id=pre_assigned_id
+    )
+    assert result.task_id == pre_assigned_id
+
+
 @conf_vars({("celery", "result_backend"): 
"rediss://test_user:test_password@localhost:6379/0"})
 def test_celery_executor_with_no_recommended_result_backend(caplog):
     import importlib

Reply via email to