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

eladkal 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 7b3389664a2 Show running task logs in the UI with Elasticsearch remote 
logging (#69090)
7b3389664a2 is described below

commit 7b3389664a298bf2e3e5eb13e00173e417cf5508
Author: Jorge Rocamora <[email protected]>
AuthorDate: Sat Jul 4 17:20:28 2026 +0200

    Show running task logs in the UI with Elasticsearch remote logging (#69090)
---
 .../providers/elasticsearch/log/es_task_handler.py | 11 ++++++
 .../unit/elasticsearch/log/test_es_task_handler.py | 39 ++++++++++++++++++++++
 2 files changed, 50 insertions(+)

diff --git 
a/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
 
b/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
index 3e2fdcc366d..fa05b2a0b34 100644
--- 
a/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
+++ 
b/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
@@ -51,6 +51,7 @@ from airflow.providers.elasticsearch.log.es_response import 
ElasticSearchRespons
 from airflow.providers.elasticsearch.version_compat import AIRFLOW_V_3_0_PLUS, 
AIRFLOW_V_3_2_PLUS
 from airflow.utils.log.file_task_handler import FileTaskHandler
 from airflow.utils.log.logging_mixin import ExternalLoggingMixin, LoggingMixin
+from airflow.utils.state import TaskInstanceState
 
 if AIRFLOW_V_3_2_PLUS:
     from airflow._shared.module_loading import import_string
@@ -388,6 +389,16 @@ class ElasticsearchTaskHandler(FileTaskHandler, 
ExternalLoggingMixin, LoggingMix
                          can be used for steaming log reading and auto-tailing.
         :return: a list of tuple with host and log documents, metadata.
         """
+        # In Airflow 3 logs reach Elasticsearch only after the task finishes, 
so a running task
+        # has nothing to read there yet. Defer to the base handler for live 
worker/executor logs,
+        # as S3/GCS do. Airflow 2 has no remote-log-IO read path, so it keeps 
the ES-only path below.
+        if (
+            AIRFLOW_V_3_0_PLUS
+            and ti.try_number == try_number
+            and ti.state in (TaskInstanceState.RUNNING, 
TaskInstanceState.DEFERRED)
+        ):
+            return super()._read(ti, try_number, metadata)  # type: 
ignore[return-value]
+
         if not metadata:
             # LogMetadata(TypedDict) is used as type annotation for 
log_reader; added ignore to suppress mypy error
             metadata = {"offset": 0}  # type: ignore[assignment]
diff --git 
a/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py 
b/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
index 56c60214089..b923d8b7c6f 100644
--- 
a/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
+++ 
b/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
@@ -48,6 +48,7 @@ from airflow.providers.elasticsearch.log.es_task_handler 
import (
     getattr_nested,
 )
 from airflow.utils import timezone
+from airflow.utils.log.file_task_handler import FileTaskHandler
 from airflow.utils.state import DagRunState, TaskInstanceState
 from airflow.utils.timezone import datetime
 
@@ -270,6 +271,9 @@ class TestElasticsearchTaskHandler:
     @pytest.mark.db_test
     @pytest.mark.parametrize("metadata_mode", ["provided", "none", "empty"])
     def test_read(self, ti, metadata_mode):
+        # A finished task reads from Elasticsearch directly. A running task is 
delegated to the
+        # base handler (covered by 
test_read_running_task_delegates_to_base_handler).
+        ti.state = TaskInstanceState.SUCCESS
         start_time = pendulum.now()
         response = _make_es_response(self.es_task_handler.io, 
self.base_log_source)
 
@@ -298,6 +302,7 @@ class TestElasticsearchTaskHandler:
 
     @pytest.mark.db_test
     def test_read_defaults_offset_when_missing_from_metadata(self, ti):
+        ti.state = TaskInstanceState.SUCCESS
         start_time = pendulum.now()
         with patch.object(self.es_task_handler.io, "_es_read", 
return_value=None):
             logs, metadatas = self.es_task_handler.read(ti, 1, {"end_of_log": 
False})
@@ -310,6 +315,7 @@ class TestElasticsearchTaskHandler:
     @pytest.mark.db_test
     @pytest.mark.parametrize("seconds", [3, 6])
     def test_read_missing_logs(self, ti, seconds):
+        ti.state = TaskInstanceState.SUCCESS
         start_time = pendulum.now().add(seconds=-seconds)
         with patch.object(self.es_task_handler.io, "_es_read", 
return_value=None):
             logs, metadatas = self.es_task_handler.read(
@@ -330,6 +336,7 @@ class TestElasticsearchTaskHandler:
 
     @pytest.mark.db_test
     def test_read_timeout(self, ti):
+        ti.state = TaskInstanceState.SUCCESS
         start_time = pendulum.now().subtract(minutes=5)
         with patch.object(self.es_task_handler.io, "_es_read", 
return_value=None):
             logs, metadatas = self.es_task_handler.read(
@@ -349,6 +356,7 @@ class TestElasticsearchTaskHandler:
 
     @pytest.mark.db_test
     def test_read_with_custom_offset_and_host_fields(self, ti):
+        ti.state = TaskInstanceState.SUCCESS
         self.es_task_handler.host_field = "host.name"
         self.es_task_handler.offset_field = "log.offset"
         self.es_task_handler.io.host_field = "host.name"
@@ -380,6 +388,37 @@ class TestElasticsearchTaskHandler:
         assert metadata["offset"] == "1"
         assert not metadata["end_of_log"]
 
+    @pytest.mark.db_test
+    @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="Live-log delegation 
only applies to Airflow 3")
+    @pytest.mark.parametrize("state", [TaskInstanceState.RUNNING, 
TaskInstanceState.DEFERRED])
+    def test_read_running_task_delegates_to_base_handler(self, ti, state):
+        ti.state = state
+        base_result = (["live log line"], {"end_of_log": False})
+        with (
+            patch.object(FileTaskHandler, "_read", return_value=base_result) 
as base_read,
+            patch.object(self.es_task_handler.io, "_es_read") as es_read,
+        ):
+            result = self.es_task_handler._read(ti, 1, {})
+
+        assert result == base_result
+        base_read.assert_called_once()
+        es_read.assert_not_called()
+
+    @pytest.mark.db_test
+    @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="Live-log delegation 
only applies to Airflow 3")
+    def test_read_old_try_of_running_task_does_not_delegate(self, ti):
+        ti.state = TaskInstanceState.RUNNING
+        ti.try_number = 2
+        response = _make_es_response(self.es_task_handler.io, 
self.base_log_source)
+        with (
+            patch.object(FileTaskHandler, "_read") as base_read,
+            patch.object(self.es_task_handler.io, "_es_read", 
return_value=response) as es_read,
+        ):
+            self.es_task_handler.read(ti, 1, {"offset": 0})
+
+        base_read.assert_not_called()
+        es_read.assert_called_once()
+
     @pytest.mark.db_test
     def test_set_context(self, ti):
         self.es_task_handler.set_context(ti)

Reply via email to