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

shahar1 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 0f2827e36e5 Fix EcsRunTaskOperator deferred logs read from the wrong 
region (#70474)
0f2827e36e5 is described below

commit 0f2827e36e53ae32779f480913dfc95fbf9c24c9
Author: Jyun-An Chen <[email protected]>
AuthorDate: Mon Jul 27 20:48:47 2026 +0800

    Fix EcsRunTaskOperator deferred logs read from the wrong region (#70474)
    
    TaskDoneTrigger received a single region_name and used it to build both the
    ECS waiter client and the CloudWatch client, so a task definition shipping
    its logs to a different region got no logs forwarded while the task was
    deferred -- only repeated "didn't exist (yet)" messages for the whole run.
    The non-deferrable path and the post-deferral fetch already resolve
    awslogs_region; this brings the in-deferral forwarding in line with them.
    
    closes: #70465
---
 .../airflow/providers/amazon/aws/operators/ecs.py  |  1 +
 .../airflow/providers/amazon/aws/triggers/ecs.py   | 11 +++-
 .../tests/unit/amazon/aws/operators/test_ecs.py    | 20 +++++++
 .../tests/unit/amazon/aws/triggers/test_ecs.py     | 68 ++++++++++++++++++----
 4 files changed, 89 insertions(+), 11 deletions(-)

diff --git a/providers/amazon/src/airflow/providers/amazon/aws/operators/ecs.py 
b/providers/amazon/src/airflow/providers/amazon/aws/operators/ecs.py
index 3bdc3e63cc5..89fc67b6d04 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/ecs.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/ecs.py
@@ -554,6 +554,7 @@ class EcsRunTaskOperator(EcsBaseOperator):
                         waiter_max_attempts=self.waiter_max_attempts,
                         aws_conn_id=self.aws_conn_id,
                         region_name=self.region_name,
+                        log_region_name=self.resolve_awslogs_region(),
                         log_group=self.awslogs_group,
                         log_stream=self._get_logs_stream_name(),
                         verify=self.verify,
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/triggers/ecs.py 
b/providers/amazon/src/airflow/providers/amazon/aws/triggers/ecs.py
index 39896960cfa..630a2c1de55 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/triggers/ecs.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/triggers/ecs.py
@@ -128,6 +128,9 @@ class TaskDoneTrigger(BaseTrigger):
         Will fail after that many unsuccessful attempts.
     :param aws_conn_id: The Airflow connection used for AWS credentials.
     :param region_name: The AWS region where the cluster is located. Used to 
build the hook.
+    :param log_region_name: The AWS region where the CloudWatch logs are 
stored. Defaults to
+        ``region_name`` when not set, which is correct unless the task 
definition ships its logs
+        to another region.
     :param verify: Whether or not to verify SSL certificates. Used to build 
the hook.
     :param botocore_config: Configuration dictionary for the botocore client. 
Used to build the hook.
     :param region: (deprecated) use ``region_name`` instead.
@@ -146,6 +149,7 @@ class TaskDoneTrigger(BaseTrigger):
         verify: bool | str | None = None,
         botocore_config: dict | None = None,
         region: str | None = None,
+        log_region_name: str | None = None,
     ):
         if region is not None:
             warnings.warn(
@@ -167,6 +171,7 @@ class TaskDoneTrigger(BaseTrigger):
 
         self.log_group = log_group
         self.log_stream = log_stream
+        self.log_region_name = log_region_name
 
     def serialize(self) -> tuple[str, dict[str, Any]]:
         return (
@@ -178,6 +183,7 @@ class TaskDoneTrigger(BaseTrigger):
                 "waiter_max_attempts": self.waiter_max_attempts,
                 "aws_conn_id": self.aws_conn_id,
                 "region_name": self.region_name,
+                "log_region_name": self.log_region_name,
                 "log_group": self.log_group,
                 "log_stream": self.log_stream,
                 "verify": self.verify,
@@ -186,6 +192,9 @@ class TaskDoneTrigger(BaseTrigger):
         )
 
     async def run(self) -> AsyncIterator[TriggerEvent]:
+        # Triggers serialized before ``log_region_name`` existed deserialize 
without it, so an
+        # unset value keeps reading logs from the cluster region as before.
+        log_region_name = self.log_region_name if self.log_region_name is not 
None else self.region_name
         async with (
             await EcsHook(
                 aws_conn_id=self.aws_conn_id,
@@ -195,7 +204,7 @@ class TaskDoneTrigger(BaseTrigger):
             ).get_async_conn() as ecs_client,
             await AwsLogsHook(
                 aws_conn_id=self.aws_conn_id,
-                region_name=self.region_name,
+                region_name=log_region_name,
                 verify=self.verify,
                 config=self.botocore_config,
             ).get_async_conn() as logs_client,
diff --git a/providers/amazon/tests/unit/amazon/aws/operators/test_ecs.py 
b/providers/amazon/tests/unit/amazon/aws/operators/test_ecs.py
index 346c29efbaf..8a28069fd25 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_ecs.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_ecs.py
@@ -846,6 +846,26 @@ class TestEcsRunTaskOperator(EcsBaseTestCase):
         assert isinstance(deferred.value.trigger, TaskDoneTrigger)
         assert deferred.value.trigger.task_arn == 
f"arn:aws:ecs:us-east-1:012345678910:task/{TASK_ID}"
 
+    @mock.patch.object(EcsRunTaskOperator, "client")
+    def test_with_defer_passes_awslogs_region_to_trigger(self, client_mock):
+        self.set_up_operator(
+            awslogs_group="awslogs-group",
+            awslogs_region="logs-region",
+            awslogs_stream_prefix="prefix",
+            region_name="task-region",
+            deferrable=True,
+        )
+        client_mock.run_task.return_value = RESPONSE_WITHOUT_FAILURES
+
+        mock_ti = mock.MagicMock()
+        mock_context = {"ti": mock_ti, "task_instance": mock_ti}
+
+        with pytest.raises(TaskDeferred) as deferred:
+            self.ecs.execute(mock_context)
+
+        assert deferred.value.trigger.region_name == "task-region"
+        assert deferred.value.trigger.log_region_name == "logs-region"
+
     @mock.patch.object(EcsRunTaskOperator, "client", new_callable=PropertyMock)
     def test_execute_complete(self, client_mock):
         event = {"status": "success", "task_arn": "my_arn", "cluster": 
"test_cluster"}
diff --git a/providers/amazon/tests/unit/amazon/aws/triggers/test_ecs.py 
b/providers/amazon/tests/unit/amazon/aws/triggers/test_ecs.py
index a46fc053c38..2a38af826ea 100644
--- a/providers/amazon/tests/unit/amazon/aws/triggers/test_ecs.py
+++ b/providers/amazon/tests/unit/amazon/aws/triggers/test_ecs.py
@@ -35,6 +35,15 @@ if TYPE_CHECKING:
     from airflow.triggers.base import TriggerEvent
 
 
+def _make_async_hook(client):
+    ctx = mock.MagicMock()
+    ctx.__aenter__ = AsyncMock(return_value=client)
+    ctx.__aexit__ = AsyncMock(return_value=False)
+    instance = mock.MagicMock()
+    instance.get_async_conn = AsyncMock(return_value=ctx)
+    return instance
+
+
 class TestTaskDoneTrigger:
     def test_deprecated_region_alias(self):
         with pytest.warns(AirflowProviderDeprecationWarning, match="region"):
@@ -73,28 +82,35 @@ class TestTaskDoneTrigger:
             "waiter_max_attempts": 10,
             "aws_conn_id": "my_conn",
             "region_name": "eu-west-1",
+            "log_region_name": None,
             "log_group": "lg",
             "log_stream": "ls",
             "verify": False,
             "botocore_config": {"read_timeout": 7},
         }
 
+    def test_serialize_keeps_log_region_name_as_passed(self):
+        trigger = TaskDoneTrigger(
+            cluster="cluster",
+            task_arn="task_arn",
+            waiter_delay=5,
+            waiter_max_attempts=10,
+            aws_conn_id="my_conn",
+            region_name="ap-northeast-1",
+            log_region_name="us-east-1",
+        )
+        _, kwargs = trigger.serialize()
+        assert kwargs["log_region_name"] == "us-east-1"
+        assert kwargs["region_name"] == "ap-northeast-1"
+
     @pytest.mark.asyncio
     @mock.patch("airflow.providers.amazon.aws.triggers.ecs.AwsLogsHook")
     @mock.patch("airflow.providers.amazon.aws.triggers.ecs.EcsHook")
     async def test_run_builds_hooks_with_generic_params(self, ecs_hook_cls, 
logs_hook_cls):
-        def make_hook(client):
-            ctx = mock.MagicMock()
-            ctx.__aenter__ = AsyncMock(return_value=client)
-            ctx.__aexit__ = AsyncMock(return_value=False)
-            instance = mock.MagicMock()
-            instance.get_async_conn = AsyncMock(return_value=ctx)
-            return instance
-
         ecs_client = mock.MagicMock()
         ecs_client.get_waiter().wait = AsyncMock()
-        ecs_hook_cls.return_value = make_hook(ecs_client)
-        logs_hook_cls.return_value = make_hook(mock.MagicMock())
+        ecs_hook_cls.return_value = _make_async_hook(ecs_client)
+        logs_hook_cls.return_value = _make_async_hook(mock.MagicMock())
 
         trigger = TaskDoneTrigger(
             cluster="cluster",
@@ -117,6 +133,38 @@ class TestTaskDoneTrigger:
         ecs_hook_cls.assert_called_once_with(**expected)
         logs_hook_cls.assert_called_once_with(**expected)
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("log_region_name", "expected_logs_hook_region"),
+        [
+            pytest.param(None, "ap-northeast-1", 
id="falls-back-to-cluster-region"),
+            pytest.param("us-east-1", "us-east-1", 
id="uses-explicit-log-region"),
+        ],
+    )
+    @mock.patch("airflow.providers.amazon.aws.triggers.ecs.AwsLogsHook")
+    @mock.patch("airflow.providers.amazon.aws.triggers.ecs.EcsHook")
+    async def test_run_reads_logs_from_log_region(
+        self, ecs_hook_cls, logs_hook_cls, log_region_name, 
expected_logs_hook_region
+    ):
+        ecs_client = mock.MagicMock()
+        ecs_client.get_waiter().wait = AsyncMock()
+        ecs_hook_cls.return_value = _make_async_hook(ecs_client)
+        logs_hook_cls.return_value = _make_async_hook(mock.MagicMock())
+
+        trigger = TaskDoneTrigger(
+            cluster="cluster",
+            task_arn="task_arn",
+            waiter_delay=0,
+            waiter_max_attempts=10,
+            aws_conn_id="my_conn",
+            region_name="ap-northeast-1",
+            log_region_name=log_region_name,
+        )
+        await trigger.run().asend(None)
+
+        assert ecs_hook_cls.call_args.kwargs["region_name"] == "ap-northeast-1"
+        assert logs_hook_cls.call_args.kwargs["region_name"] == 
expected_logs_hook_region
+
     @pytest.mark.asyncio
     @mock.patch.object(EcsHook, "get_async_conn")
     # this mock is only necessary to avoid a "No module named 'aiobotocore'" 
error in the LatestBoto CI step

Reply via email to