This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 acb2208f15f Let the ECS executor start tasks in parallel (#75907)
acb2208f15f is described below
commit acb2208f15fd7b1acc9dff33aa48455611da8235
Author: Vincent <[email protected]>
AuthorDate: Fri Oct 30 08:41:37 2026 -0400
Let the ECS executor start tasks in parallel (#75907)
The ECS executor calls RunTask for one task at a time, inside the
scheduler loop, and waits for each answer. A call takes about 0.7 s, so a
scheduler starts at most about 1.4 tasks per second however many slots
are free, and its loop stalls while it works through a backlog:
scheduling, heartbeats and executor events all wait, for up to 13 minutes
in a test with 10,000 tasks.
A throttled RunTask call no longer uses one of the task's attempts, since
the throttling says nothing about the task; parallel calls make it more
likely.
---
providers/amazon/docs/executors/ecs-executor.rst | 4 +
providers/amazon/provider.yaml | 9 +
.../amazon/aws/executors/ecs/ecs_executor.py | 230 ++++++++++++------
.../providers/amazon/aws/executors/ecs/utils.py | 3 +
.../airflow/providers/amazon/get_provider_info.py | 7 +
.../amazon/aws/executors/ecs/test_ecs_executor.py | 265 ++++++++++++++++++++-
.../observability/metrics/metrics_template.yaml | 6 +
7 files changed, 453 insertions(+), 71 deletions(-)
diff --git a/providers/amazon/docs/executors/ecs-executor.rst
b/providers/amazon/docs/executors/ecs-executor.rst
index ce66b3884ba..67d3f2165b1 100644
--- a/providers/amazon/docs/executors/ecs-executor.rst
+++ b/providers/amazon/docs/executors/ecs-executor.rst
@@ -111,6 +111,10 @@ Optional config options:
fails to start (i.e. ECS API failures, container failures etc.)
- CHECK_HEALTH_ON_STARTUP - Whether or not to check the ECS Executor
health on startup
+- RUN_TASK_CONCURRENCY - The maximum number of ECS ``run_task`` calls
+ the ECS Executor makes in parallel, in background threads. Defaults
+ to 1: tasks are launched one at a time, and the scheduler loop waits
+ for each call.
For a more detailed description of available options, including type
hints and examples, see the ``config_templates`` folder in the Amazon
diff --git a/providers/amazon/provider.yaml b/providers/amazon/provider.yaml
index 72669ee719e..190a90162c2 100644
--- a/providers/amazon/provider.yaml
+++ b/providers/amazon/provider.yaml
@@ -1511,6 +1511,15 @@ config:
type: integer
example: "3"
default: "3"
+ run_task_concurrency:
+ description: |
+ The maximum number of ECS RunTask calls the ECS Executor makes in
parallel. With 1, tasks are
+ launched one at a time and the scheduler loop waits for each call.
With more, the calls run in
+ background threads and the scheduler loop does not wait for them.
+ version_added: "9.40.0"
+ type: integer
+ example: "16"
+ default: "1"
run_task_kwargs:
description: |
A JSON string containing arguments to provide the ECS `run_task` API
(see url above).
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/executors/ecs/ecs_executor.py
b/providers/amazon/src/airflow/providers/amazon/aws/executors/ecs/ecs_executor.py
index 77cd60b704b..35c6a3b4afc 100644
---
a/providers/amazon/src/airflow/providers/amazon/aws/executors/ecs/ecs_executor.py
+++
b/providers/amazon/src/airflow/providers/amazon/aws/executors/ecs/ecs_executor.py
@@ -25,11 +25,14 @@ from __future__ import annotations
import time
import warnings
-from collections import defaultdict, deque
-from collections.abc import Sequence
+from collections import deque
+from collections.abc import Callable, Sequence
+from concurrent.futures import Future, ThreadPoolExecutor
from copy import deepcopy
+from functools import partial
from typing import TYPE_CHECKING, TypeAlias
+from botocore.config import Config
from botocore.exceptions import ClientError, NoCredentialsError
from airflow.exceptions import AirflowProviderDeprecationWarning
@@ -83,6 +86,10 @@ INVALID_CREDENTIALS_EXCEPTIONS = [
"InvalidClientTokenId",
"UnrecognizedClientException",
]
+THROTTLING_EXCEPTIONS = ["ThrottlingException"]
+# Retries on top of botocore's own. Past this number, a throttled RunTask call
uses one of the workload's
+# ``max_run_task_attempts``, so a workload cannot stay pending for as long as
the throttling lasts.
+MAX_THROTTLED_RUN_TASK_RETRIES = 5
class AwsEcsExecutor(BaseExecutor):
@@ -143,6 +150,28 @@ class AwsEcsExecutor(BaseExecutor):
self.container_name = self.conf.get(CONFIG_GROUP_NAME,
AllEcsConfigKeys.CONTAINER_NAME)
self.attempts_since_last_successful_connection = 0
+ # ``ExecutorConf`` has no ``getint`` before Airflow 3.2.
+ self.run_task_concurrency = int(
+ self.conf.get(
+ CONFIG_GROUP_NAME,
+ AllEcsConfigKeys.RUN_TASK_CONCURRENCY,
+
fallback=CONFIG_DEFAULTS[AllEcsConfigKeys.RUN_TASK_CONCURRENCY],
+ )
+ )
+ if self.run_task_concurrency < 1:
+ raise ValueError(
+ f"{AllEcsConfigKeys.RUN_TASK_CONCURRENCY} must be 1 or more,
got {self.run_task_concurrency}"
+ )
+ # With a concurrency of 1, RunTask calls are made one by one in the
scheduler loop.
+ self._run_task_threads = 0
+ self._run_task_pool: ThreadPoolExecutor | None = None
+ if self.run_task_concurrency > 1:
+ self._run_task_threads = self.run_task_concurrency
+ self._run_task_pool = ThreadPoolExecutor(
+ max_workers=self._run_task_threads,
thread_name_prefix="ecs-run-task"
+ )
+ self._run_task_futures: dict[Future, EcsQueuedTask] = {}
+
self.load_ecs_connection(check_connection=False)
self.IS_BOTO_CONNECTION_HEALTHY = False
@@ -271,7 +300,15 @@ class AwsEcsExecutor(BaseExecutor):
fallback=CONFIG_DEFAULTS[AllEcsConfigKeys.AWS_CONN_ID],
)
region_name = self.conf.get(CONFIG_GROUP_NAME,
AllEcsConfigKeys.REGION_NAME, fallback=None)
- self.ecs = EcsHook(aws_conn_id=aws_conn_id,
region_name=region_name).conn
+ hook = EcsHook(aws_conn_id=aws_conn_id, region_name=region_name)
+ # Each RunTask thread, and the scheduler thread, needs a connection of
its own: past the client's pool
+ # size (10 by default), the extra connections are closed with a
warning when a burst of calls is over,
+ # and the next burst opens them again.
+ config = hook.config
+ connections = self._run_task_threads + 1
+ if config.max_pool_connections < connections:
+ config = config.merge(Config(max_pool_connections=connections))
+ self.ecs = hook.get_client_type(region_name=hook.region_name,
config=config)
self.attempts_since_last_successful_connection += 1
self.last_connection_reload = timezone.utcnow()
@@ -424,83 +461,120 @@ class AwsEcsExecutor(BaseExecutor):
called again in the next heart-beat.
If the launch type is FARGATE, this will run the tasks on new AWS
Fargate instances.
+
+ With ``run_task_concurrency`` above 1, the RunTask calls are made in
background threads, at most
+ that many at a time, and the scheduler loop does not wait for them:
each call is handled by the
+ first sync that finds it finished.
"""
- queue_len = len(self.pending_workloads)
- failure_reasons = defaultdict(int)
- for _ in range(queue_len):
+ for _ in range(len(self.pending_workloads)):
ecs_task = self.pending_workloads.popleft()
- task_key = ecs_task.key
- cmd = ecs_task.command
- queue = ecs_task.queue
- exec_config = ecs_task.executor_config
- attempt_number = ecs_task.attempt_number
- failure_reasons = []
if timezone.utcnow() < ecs_task.next_attempt_time:
self.pending_workloads.append(ecs_task)
continue
+ run_task = partial(
+ self._run_task, ecs_task.key, ecs_task.command,
ecs_task.queue, ecs_task.executor_config
+ )
+ if self._run_task_pool is None:
+ self._handle_run_task_response(ecs_task, run_task)
+ else:
+ self._run_task_futures[self._run_task_pool.submit(run_task)] =
ecs_task
+ # All the calls made with expired credentials fail together: raising
on the first one would leave
+ # the others to fail one sync at a time, each forcing its own
reconnection.
+ credentials_error: ClientError | NoCredentialsError | None = None
+ for future in [future for future in self._run_task_futures if
future.done()]:
try:
- run_task_response = self._run_task(task_key, cmd, queue,
exec_config)
- except NoCredentialsError:
+
self._handle_run_task_response(self._run_task_futures.pop(future),
future.result)
+ except (ClientError, NoCredentialsError) as error:
+ credentials_error = credentials_error or error
+ if credentials_error is not None:
+ raise credentials_error
+
+ def _handle_run_task_response(self, ecs_task: EcsQueuedTask, get_response:
Callable[[], dict]) -> None:
+ """Get the RunTask response for ``ecs_task``, then track the ECS task,
or retry or fail the workload."""
+ task_key = ecs_task.key
+ cmd = ecs_task.command
+ queue = ecs_task.queue
+ exec_config = ecs_task.executor_config
+ attempt_number = ecs_task.attempt_number
+ failure_reasons = []
+ try:
+ run_task_response = get_response()
+ except NoCredentialsError:
+ self.pending_workloads.append(ecs_task)
+ raise
+ except ClientError as e:
+ error_code = e.response["Error"]["Code"]
+ if error_code in INVALID_CREDENTIALS_EXCEPTIONS:
self.pending_workloads.append(ecs_task)
raise
- except ClientError as e:
- error_code = e.response["Error"]["Code"]
- if error_code in INVALID_CREDENTIALS_EXCEPTIONS:
- self.pending_workloads.append(ecs_task)
- raise
- failure_reasons.append(str(e))
- except Exception as e:
- # Failed to even get a response back from the Boto3 API or
something else went
- # wrong. For any possible failure we want to add the
exception reasons to the
- # failure list so that it is logged to the user and most
importantly the task is
- # added back to the pending list to be retried later.
- failure_reasons.append(str(e))
+ if (
+ error_code in THROTTLING_EXCEPTIONS
+ and ecs_task.throttled_attempts <
MAX_THROTTLED_RUN_TASK_RETRIES
+ ):
+ # ECS is asking to slow down; that says nothing about the
workload, so keep its attempts.
+ ecs_task.throttled_attempts += 1
+ delay =
calculate_next_attempt_delay(ecs_task.throttled_attempts, exponent_base=2)
+ self.log.info(
+ "ECS throttled the RunTask call for %s, retrying in %s
(retry %s of %s)",
+ task_key,
+ delay,
+ ecs_task.throttled_attempts,
+ MAX_THROTTLED_RUN_TASK_RETRIES,
+ )
+ ecs_task.next_attempt_time = timezone.utcnow() + delay
+ self.pending_workloads.append(ecs_task)
+ return
+ failure_reasons.append(str(e))
+ except Exception as e:
+ # Failed to even get a response back from the Boto3 API or
something else went
+ # wrong. For any possible failure we want to add the exception
reasons to the
+ # failure list so that it is logged to the user and most
importantly the task is
+ # added back to the pending list to be retried later.
+ failure_reasons.append(str(e))
+ else:
+ # We got a response back, check if there were failures. If so, add
them to the
+ # failures list so that it is logged to the user and most
importantly the task
+ # is added back to the pending list to be retried later.
+ if run_task_response["failures"]:
+ failure_reasons.extend([f["reason"] for f in
run_task_response["failures"]])
+
+ if failure_reasons:
+ # Make sure the number of attempts does not exceed
max_run_task_attempts
+ if int(attempt_number) < int(self.max_run_task_attempts):
+ ecs_task.attempt_number += 1
+ ecs_task.next_attempt_time = timezone.utcnow() +
calculate_next_attempt_delay(attempt_number)
+ self.pending_workloads.append(ecs_task)
else:
- # We got a response back, check if there were failures. If so,
add them to the
- # failures list so that it is logged to the user and most
importantly the task
- # is added back to the pending list to be retried later.
- if run_task_response["failures"]:
- failure_reasons.extend([f["reason"] for f in
run_task_response["failures"]])
-
- if failure_reasons:
- # Make sure the number of attempts does not exceed
max_run_task_attempts
- if int(attempt_number) < int(self.max_run_task_attempts):
- ecs_task.attempt_number += 1
- ecs_task.next_attempt_time = timezone.utcnow() +
calculate_next_attempt_delay(
- attempt_number
- )
- self.pending_workloads.append(ecs_task)
- else:
- reasons_str = ", ".join(failure_reasons)
- self.log.error(
- "ECS workload %s has failed a maximum of %s times.
Marking as failed. Reasons: %s",
- task_key,
- attempt_number,
- reasons_str,
- )
- self.log_task_event(
- event="ecs task submit failure",
- ti_key=task_key,
- extra=(
- f"Task could not be queued after {attempt_number}
attempts. "
- f"Marking as failed. Reasons: {reasons_str}"
- ),
- )
- self.fail(task_key)
- elif not run_task_response["tasks"]:
- self.log.error("ECS RunTask Response: %s", run_task_response)
+ reasons_str = ", ".join(failure_reasons)
+ self.log.error(
+ "ECS workload %s has failed a maximum of %s times. Marking
as failed. Reasons: %s",
+ task_key,
+ attempt_number,
+ reasons_str,
+ )
self.log_task_event(
event="ecs task submit failure",
- extra=f"ECS RunTask Response: {run_task_response}",
ti_key=task_key,
+ extra=(
+ f"Task could not be queued after {attempt_number}
attempts. "
+ f"Marking as failed. Reasons: {reasons_str}"
+ ),
)
- raise EcsExecutorException(
- "No failures and no ECS tasks provided in response. This
should never happen."
- )
- else:
- task = run_task_response["tasks"][0]
- self.active_workers.add_task(task, task_key, queue, cmd,
exec_config, attempt_number)
- self.running_state(task_key, task.task_arn)
+ self.fail(task_key)
+ elif not run_task_response["tasks"]:
+ self.log.error("ECS RunTask Response: %s", run_task_response)
+ self.log_task_event(
+ event="ecs task submit failure",
+ extra=f"ECS RunTask Response: {run_task_response}",
+ ti_key=task_key,
+ )
+ raise EcsExecutorException(
+ "No failures and no ECS tasks provided in response. This
should never happen."
+ )
+ else:
+ task = run_task_response["tasks"][0]
+ self.active_workers.add_task(task, task_key, queue, cmd,
exec_config, attempt_number)
+ self.running_state(task_key, task.task_arn)
def _run_task(
self,
@@ -517,7 +591,8 @@ class AwsEcsExecutor(BaseExecutor):
section of the JSON request before calling Boto3's "run_task" function.
"""
run_task_kwargs = self._run_task_kwargs(task_id, cmd, queue,
exec_config)
- boto_run_task = self.ecs.run_task(**run_task_kwargs)
+ with Stats.timer("ecs_executor.run_task.duration",
tags=prune_dict({"team_name": self.team_name})):
+ boto_run_task = self.ecs.run_task(**run_task_kwargs)
run_task_response = BotoRunTaskSchema().load(boto_run_task)
return run_task_response
@@ -570,17 +645,34 @@ class AwsEcsExecutor(BaseExecutor):
try:
while True:
self.sync()
- if not self.active_workers:
+ if not self.active_workers and not self._run_task_futures:
break
time.sleep(heartbeat_interval)
except Exception:
# We catch any and all exceptions because otherwise they would
bubble
# up and kill the scheduler process.
self.log.exception("Failed to end %s", self.__class__.__name__)
+ if self._run_task_pool is not None:
+ self._run_task_pool.shutdown(wait=False, cancel_futures=True)
def terminate(self):
"""Kill all ECS processes by calling Boto3's StopTask API."""
try:
+ if self._run_task_pool is not None:
+ # A RunTask call still in flight may start an ECS task after
the StopTask calls below:
+ # wait for the calls already started, and drop the ones not
started yet.
+ self._run_task_pool.shutdown(wait=True, cancel_futures=True)
+ # The pool no longer takes calls: ``end()`` below makes them
in the scheduler loop.
+ self._run_task_pool = None
+ for future, ecs_task in list(self._run_task_futures.items()):
+ del self._run_task_futures[future]
+ if future.cancelled():
+ continue
+ # One failure must not keep the other ECS tasks from being
tracked, then stopped.
+ try:
+ self._handle_run_task_response(ecs_task, future.result)
+ except Exception:
+ self.log.exception("Failed to handle the RunTask
response for %s", ecs_task.key)
for arn in self.active_workers.get_all_arns():
self.ecs.stop_task(
cluster=self.cluster, task=arn, reason="Airflow Executor
received a SIGTERM"
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/executors/ecs/utils.py
b/providers/amazon/src/airflow/providers/amazon/aws/executors/ecs/utils.py
index fa36377abfd..6d49c4030ba 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/executors/ecs/utils.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/executors/ecs/utils.py
@@ -61,6 +61,7 @@ CONFIG_DEFAULTS = {
"assign_public_ip": "False",
"platform_version": "LATEST",
"check_health_on_startup": "True",
+ "run_task_concurrency": "1",
}
@@ -74,6 +75,7 @@ class EcsQueuedTask:
executor_config: ExecutorConfigType
attempt_number: int
next_attempt_time: datetime.datetime
+ throttled_attempts: int = 0
@dataclass
@@ -106,6 +108,7 @@ class AllEcsConfigKeys(RunTaskKwargsConfigKeys):
CHECK_HEALTH_ON_STARTUP = "check_health_on_startup"
MAX_RUN_TASK_ATTEMPTS = "max_run_task_attempts"
REGION_NAME = "region_name"
+ RUN_TASK_CONCURRENCY = "run_task_concurrency"
RUN_TASK_KWARGS = "run_task_kwargs"
diff --git a/providers/amazon/src/airflow/providers/amazon/get_provider_info.py
b/providers/amazon/src/airflow/providers/amazon/get_provider_info.py
index 76bfd3ec6e2..71a338b87c5 100644
--- a/providers/amazon/src/airflow/providers/amazon/get_provider_info.py
+++ b/providers/amazon/src/airflow/providers/amazon/get_provider_info.py
@@ -1554,6 +1554,13 @@ def get_provider_info():
"example": "3",
"default": "3",
},
+ "run_task_concurrency": {
+ "description": "The maximum number of ECS RunTask
calls the ECS Executor makes in parallel. With 1, tasks are\nlaunched one at a
time and the scheduler loop waits for each call. With more, the calls run
in\nbackground threads and the scheduler loop does not wait for them.\n",
+ "version_added": "9.40.0",
+ "type": "integer",
+ "example": "16",
+ "default": "1",
+ },
"run_task_kwargs": {
"description": "A JSON string containing arguments to
provide the ECS `run_task` API (see url above).\n",
"version_added": "8.10",
diff --git
a/providers/amazon/tests/unit/amazon/aws/executors/ecs/test_ecs_executor.py
b/providers/amazon/tests/unit/amazon/aws/executors/ecs/test_ecs_executor.py
index 9b5d2e96108..12b816588f5 100644
--- a/providers/amazon/tests/unit/amazon/aws/executors/ecs/test_ecs_executor.py
+++ b/providers/amazon/tests/unit/amazon/aws/executors/ecs/test_ecs_executor.py
@@ -22,14 +22,17 @@ import json
import logging
import os
import re
+import threading
import time
from collections.abc import Callable
+from concurrent.futures import Future, wait
from functools import partial
from unittest import mock
from unittest.mock import MagicMock, patch
from uuid import UUID, uuid4
import pytest
+import time_machine
import yaml
from botocore.exceptions import ClientError
from inflection import camelize
@@ -49,6 +52,7 @@ from airflow.providers.amazon.aws.executors.ecs.ecs_executor
import (
from airflow.providers.amazon.aws.executors.ecs.utils import (
CONFIG_DEFAULTS,
EcsExecutorTask,
+ EcsQueuedTask,
_recursive_flatten_dict,
parse_assign_public_ip,
)
@@ -1406,6 +1410,262 @@ class TestAwsEcsExecutor:
mock_timer.assert_called_once_with("ecs_executor.adopt_task_instances.duration",
tags=expected_tags)
[email protected]
+def concurrent_executor(set_env_vars):
+ with conf_vars({(CONFIG_GROUP_NAME,
AllEcsConfigKeys.RUN_TASK_CONCURRENCY): "2"}):
+ executor = AwsEcsExecutor()
+ executor.IS_BOTO_CONNECTION_HEALTHY = True
+ executor.ecs = mock.Mock(spec=executor.ecs)
+ yield executor
+ if executor._run_task_pool is not None:
+ executor._run_task_pool.shutdown(wait=True, cancel_futures=True)
+
+
+class TestRunTaskConcurrency:
+ """RunTask calls made in background threads when ``run_task_concurrency``
is above 1."""
+
+ def test_scheduler_loop_does_not_wait_for_run_task(self,
concurrent_executor):
+ started = threading.Semaphore(0)
+ release = threading.Event()
+
+ def run_task(**kwargs):
+ started.release()
+ release.wait(timeout=10)
+ task_number =
kwargs["overrides"]["containerOverrides"][0]["command"][-1]
+ task = {
+ "taskArn": f"{ARN1}-{task_number}",
+ "lastStatus": "",
+ "desiredStatus": "",
+ "containers": [{"name": "some-ecs-container"}],
+ }
+ return {"tasks": [task], "failures": []}
+
+ concurrent_executor.ecs.run_task.side_effect = run_task
+ keys = [TaskInstanceKey("dag", f"task_{i}", "run", 1, -1) for i in
range(3)]
+ for i, key in enumerate(keys):
+ concurrent_executor.execute_async(key, ["airflow", "tasks",
str(i)])
+
+ concurrent_executor.attempt_workload_runs()
+
+ assert started.acquire(timeout=10)
+ assert started.acquire(timeout=10)
+ assert not started.acquire(timeout=0.2), "more RunTask calls in flight
than run_task_concurrency"
+ assert not concurrent_executor.pending_workloads
+ assert len(concurrent_executor._run_task_futures) == 3
+ assert len(concurrent_executor.active_workers) == 0
+
+ release.set()
+ wait(list(concurrent_executor._run_task_futures), timeout=10)
+ concurrent_executor.attempt_workload_runs()
+
+ assert not concurrent_executor._run_task_futures
+ assert set(concurrent_executor.active_workers.get_all_task_keys()) ==
set(keys)
+ assert set(concurrent_executor.active_workers.get_all_arns()) ==
{f"{ARN1}-{i}" for i in range(3)}
+
+ def test_credentials_error_is_raised_once_for_all_finished_calls(self,
concurrent_executor):
+ release = threading.Event()
+
+ def run_task(**kwargs):
+ release.wait(timeout=10)
+ raise ClientError({"Error": {"Code": "ExpiredTokenException",
"Message": "expired"}}, "RunTask")
+
+ concurrent_executor.ecs.run_task.side_effect = run_task
+ for i in range(2):
+ concurrent_executor.execute_async(
+ TaskInstanceKey("dag", f"task_{i}", "run", 1, -1), ["airflow",
"tasks", str(i)]
+ )
+ concurrent_executor.attempt_workload_runs()
+ release.set()
+ wait(list(concurrent_executor._run_task_futures), timeout=10)
+
+ with pytest.raises(ClientError, match="ExpiredTokenException"):
+ concurrent_executor.attempt_workload_runs()
+
+ assert not concurrent_executor._run_task_futures
+ assert len(concurrent_executor.pending_workloads) == 2
+
+ def test_end_waits_for_in_flight_run_task_calls(self, concurrent_executor):
+ in_flight = Future()
+ concurrent_executor._run_task_futures[in_flight] =
mock.Mock(spec=EcsQueuedTask)
+
+ def finish_run_task_on_second_sync():
+ if concurrent_executor.sync.call_count == 2:
+ del concurrent_executor._run_task_futures[in_flight]
+
+ concurrent_executor.sync = mock.create_autospec(
+ concurrent_executor.sync,
side_effect=finish_run_task_on_second_sync
+ )
+
+ concurrent_executor.end(heartbeat_interval=0)
+
+ assert concurrent_executor.sync.call_count == 2
+
+ def test_terminate_stops_the_tasks_of_run_task_calls_in_flight(self,
concurrent_executor):
+ started = threading.Semaphore(0)
+ release = threading.Event()
+
+ def run_task(**kwargs):
+ started.release()
+ release.wait(timeout=10)
+ task_number =
kwargs["overrides"]["containerOverrides"][0]["command"][-1]
+ task = {
+ "taskArn": f"{ARN1}-{task_number}",
+ "lastStatus": "",
+ "desiredStatus": "",
+ "containers": [{"name": "some-ecs-container"}],
+ }
+ return {"tasks": [task], "failures": []}
+
+ concurrent_executor.ecs.run_task.side_effect = run_task
+ for i in range(3):
+ concurrent_executor.execute_async(
+ TaskInstanceKey("dag", f"task_{i}", "run", 1, -1), ["airflow",
"tasks", str(i)]
+ )
+ concurrent_executor.attempt_workload_runs()
+ # Two calls run (run_task_concurrency is 2), the third waits for a
thread.
+ assert started.acquire(timeout=10)
+ assert started.acquire(timeout=10)
+ threading.Timer(0.2, release.set).start()
+
+ with mock.patch.object(concurrent_executor, "end"):
+ concurrent_executor.terminate()
+
+ assert concurrent_executor.ecs.run_task.call_count == 2
+ assert not concurrent_executor._run_task_futures
+ stopped = {call.kwargs["task"] for call in
concurrent_executor.ecs.stop_task.call_args_list}
+ assert stopped == {f"{ARN1}-0", f"{ARN1}-1"}
+
+ def
test_terminate_stops_the_tasks_started_when_another_response_fails(self,
concurrent_executor):
+ task = {
+ "taskArn": ARN1,
+ "lastStatus": "",
+ "desiredStatus": "",
+ "containers": [{"name": "some-ecs-container"}],
+ }
+ release = threading.Event()
+
+ def run_task(**kwargs):
+ release.wait(timeout=10)
+ if kwargs["overrides"]["containerOverrides"][0]["command"][-1] ==
"0":
+ # Neither tasks nor failures: handling this response raises an
EcsExecutorException.
+ return {"tasks": [], "failures": []}
+ return {"tasks": [task], "failures": []}
+
+ concurrent_executor.ecs.run_task.side_effect = run_task
+ for i in range(2):
+ concurrent_executor.execute_async(
+ TaskInstanceKey("dag", f"task_{i}", "run", 1, -1), ["airflow",
"tasks", str(i)]
+ )
+ concurrent_executor.attempt_workload_runs()
+ release.set()
+ wait(list(concurrent_executor._run_task_futures), timeout=10)
+
+ with mock.patch.object(concurrent_executor, "end"):
+ concurrent_executor.terminate()
+
+ concurrent_executor.ecs.stop_task.assert_called_once_with(
+ cluster=concurrent_executor.cluster, task=ARN1, reason="Airflow
Executor received a SIGTERM"
+ )
+
+ def
test_run_task_calls_are_made_in_the_scheduler_loop_after_terminate(self,
concurrent_executor):
+ task = {
+ "taskArn": ARN1,
+ "lastStatus": "",
+ "desiredStatus": "",
+ "containers": [{"name": "some-ecs-container"}],
+ }
+ concurrent_executor.ecs.run_task.return_value = {"tasks": [task],
"failures": []}
+ with mock.patch.object(concurrent_executor, "end"):
+ concurrent_executor.terminate()
+ key = TaskInstanceKey("dag", "task", "run", 1, -1)
+ concurrent_executor.execute_async(key, ["airflow", "tasks", "run"])
+
+ concurrent_executor.attempt_workload_runs()
+
+ assert not concurrent_executor._run_task_futures
+ assert concurrent_executor.active_workers.get_all_task_keys() == [key]
+
+ @pytest.mark.parametrize("concurrency", ["0", "-1"])
+ def test_run_task_concurrency_below_one_is_rejected(self, set_env_vars,
concurrency):
+ with conf_vars({(CONFIG_GROUP_NAME,
AllEcsConfigKeys.RUN_TASK_CONCURRENCY): concurrency}):
+ with pytest.raises(
+ ValueError, match=f"run_task_concurrency must be 1 or more,
got {concurrency}"
+ ):
+ AwsEcsExecutor()
+
+ @pytest.mark.parametrize(
+ ("concurrency", "connection_config", "expected_pool_size"),
+ [
+ pytest.param("16", {}, 17,
id="one-per-thread-and-for-the-scheduler-thread"),
+ pytest.param("16", {"max_pool_connections": 50}, 50,
id="larger-connection-setting-kept"),
+ ],
+ )
+ def test_client_has_a_connection_for_each_thread(
+ self, set_env_vars, monkeypatch, concurrency, connection_config,
expected_pool_size
+ ):
+ config_kwargs = {"connect_timeout": 7, **connection_config}
+ monkeypatch.setenv(
+ "AIRFLOW_CONN_AWS_DEFAULT",
+ json.dumps({"conn_type": "aws", "extra": {"config_kwargs":
config_kwargs}}),
+ )
+ with conf_vars({(CONFIG_GROUP_NAME,
AllEcsConfigKeys.RUN_TASK_CONCURRENCY): concurrency}):
+ executor = AwsEcsExecutor(parallelism=32)
+
+ assert executor.ecs.meta.config.max_pool_connections ==
expected_pool_size
+ assert executor.ecs.meta.config.connect_timeout == 7
+
+
+class TestRunTaskThrottling:
+ @pytest.mark.parametrize("concurrency", ["1", "2"])
+ @time_machine.travel("2026-10-27 12:00:00", tick=False)
+ def test_throttled_run_task_is_retried_without_using_an_attempt(self,
set_env_vars, concurrency):
+ with conf_vars({(CONFIG_GROUP_NAME,
AllEcsConfigKeys.RUN_TASK_CONCURRENCY): concurrency}):
+ executor = AwsEcsExecutor()
+ executor.ecs = mock.Mock(spec=executor.ecs)
+ executor.ecs.run_task.side_effect = ClientError(
+ {"Error": {"Code": "ThrottlingException", "Message": "Rate
exceeded"}}, "RunTask"
+ )
+ key = TaskInstanceKey("dag", "task", "run", 1, -1)
+ executor.execute_async(key, ["airflow", "tasks", "run"])
+
+ executor.attempt_workload_runs()
+ if executor._run_task_pool is not None:
+ wait(list(executor._run_task_futures), timeout=10)
+ executor.attempt_workload_runs()
+ executor._run_task_pool.shutdown(wait=True)
+
+ assert executor.ecs.run_task.call_count == 1
+ [ecs_task] = executor.pending_workloads
+ assert ecs_task.attempt_number == 1
+ assert ecs_task.throttled_attempts == 1
+ assert ecs_task.next_attempt_time == timezone.utcnow() +
dt.timedelta(seconds=2)
+ assert key not in executor.event_buffer
+
+ @time_machine.travel("2026-10-27 12:00:00", tick=False)
+ def test_throttled_run_task_retries_back_off_then_use_attempts(self,
mock_executor):
+ mock_executor.ecs.run_task.side_effect = ClientError(
+ {"Error": {"Code": "ThrottlingException", "Message": "Rate
exceeded"}}, "RunTask"
+ )
+ key = TaskInstanceKey("dag", "task", "run", 1, -1)
+ mock_executor.execute_async(key, ["airflow", "tasks", "run"])
+ delays = []
+ for _ in range(ecs_executor.MAX_THROTTLED_RUN_TASK_RETRIES + 1):
+ mock_executor.pending_workloads[0].next_attempt_time =
timezone.utcnow()
+ mock_executor.attempt_workload_runs()
+
delays.append((mock_executor.pending_workloads[0].next_attempt_time -
timezone.utcnow()).seconds)
+
+ assert delays == [2, 4, 8, 16, 32, 4]
+ assert mock_executor.pending_workloads[0].attempt_number == 2
+
+ @mock.patch.object(ecs_executor, "Stats")
+ def test_run_task_duration_is_timed(self, stats_mock, mock_executor):
+ mock_executor.execute_async(TaskInstanceKey("dag", "task", "run", 1,
-1), ["airflow", "tasks", "run"])
+
+ mock_executor.attempt_workload_runs()
+
+
stats_mock.timer.assert_called_once_with("ecs_executor.run_task.duration",
tags={})
+
+
class TestEcsExecutorConfig:
@pytest.fixture
def assign_subnets(self):
@@ -1537,12 +1797,13 @@ class TestEcsExecutorConfig:
found_keys = {convert_camel_to_snake(key): key for key in
task_kwargs.keys()}
for expected_key, expected_value_raw in CONFIG_DEFAULTS.items():
- # conn_id, max_run_task_attempts, and check_health_on_startup are
used by the executor,
- # but are not expected to appear in the task_kwargs.
+ # conn_id, max_run_task_attempts, check_health_on_startup and
run_task_concurrency are used by
+ # the executor, but are not expected to appear in the task_kwargs.
if expected_key in [
AllEcsConfigKeys.AWS_CONN_ID,
AllEcsConfigKeys.MAX_RUN_TASK_ATTEMPTS,
AllEcsConfigKeys.CHECK_HEALTH_ON_STARTUP,
+ AllEcsConfigKeys.RUN_TASK_CONCURRENCY,
]:
assert expected_key not in found_keys.keys()
else:
diff --git
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
index 4a70cc9eb1b..5758848964f 100644
---
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
+++
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
@@ -961,6 +961,12 @@ metrics:
legacy_name: "-"
name_variables: []
+ - name: "ecs_executor.run_task.duration"
+ description: "Milliseconds taken by one ECS RunTask call of the AWS ECS
Executor"
+ type: "timer"
+ legacy_name: "-"
+ name_variables: []
+
- name: "lambda_executor.adopt_task_instances.duration"
description: "Milliseconds taken to adopt the task instances in the AWS
Lambda Executor"
type: "timer"