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 e4fdf692ece Show running task logs in the UI with OpenSearch remote
logging (#69407)
e4fdf692ece is described below
commit e4fdf692ece6a906626caf6b4bfd09f67ed3b247
Author: Jorge Rocamora <[email protected]>
AuthorDate: Mon Jul 6 17:42:30 2026 +0200
Show running task logs in the UI with OpenSearch remote logging (#69407)
---
.../providers/opensearch/log/os_task_handler.py | 11 ++++++
.../unit/opensearch/log/test_os_task_handler.py | 39 ++++++++++++++++++++++
2 files changed, 50 insertions(+)
diff --git
a/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
b/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
index 348795bde9c..77ed6830e8b 100644
---
a/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
+++
b/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
@@ -49,6 +49,7 @@ from airflow.providers.opensearch.version_compat import
AIRFLOW_V_3_0_PLUS, AIRF
from airflow.utils.log.file_task_handler import FileTaskHandler
from airflow.utils.log.logging_mixin import ExternalLoggingMixin, LoggingMixin
from airflow.utils.session import create_session
+from airflow.utils.state import TaskInstanceState
if TYPE_CHECKING:
from airflow.models.taskinstance import TaskInstance, TaskInstanceKey
@@ -550,6 +551,16 @@ class OpensearchTaskHandler(FileTaskHandler,
ExternalLoggingMixin, LoggingMixin)
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 OpenSearch 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.
+ 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/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
b/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
index 46a0e9805cd..f9d9fa89426 100644
--- a/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
+++ b/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
@@ -43,6 +43,7 @@ from airflow.providers.opensearch.log.os_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
@@ -290,6 +291,9 @@ class TestOpensearchTaskHandler:
@pytest.mark.db_test
@pytest.mark.parametrize("metadata_mode", ["provided", "none", "empty"])
def test_read(self, ti, metadata_mode):
+ # A finished task reads from OpenSearch 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_os_response(self.os_task_handler.io,
self.base_log_source)
@@ -318,6 +322,7 @@ class TestOpensearchTaskHandler:
@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.os_task_handler.io, "_os_read",
return_value=None):
logs, metadatas = self.os_task_handler.read(ti, 1, {"end_of_log":
False})
@@ -330,6 +335,7 @@ class TestOpensearchTaskHandler:
@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.os_task_handler.io, "_os_read",
return_value=None):
logs, metadatas = self.os_task_handler.read(
@@ -350,6 +356,7 @@ class TestOpensearchTaskHandler:
@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.os_task_handler.io, "_os_read",
return_value=None):
logs, metadatas = self.os_task_handler.read(
@@ -365,6 +372,7 @@ class TestOpensearchTaskHandler:
@pytest.mark.db_test
def test_read_with_custom_offset_and_host_fields(self, ti):
+ ti.state = TaskInstanceState.SUCCESS
self.os_task_handler.host_field = "host.name"
self.os_task_handler.offset_field = "log.offset"
self.os_task_handler.io.host_field = "host.name"
@@ -396,6 +404,37 @@ class TestOpensearchTaskHandler:
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.os_task_handler.io, "_os_read") as os_read,
+ ):
+ result = self.os_task_handler._read(ti, 1, {})
+
+ assert result == base_result
+ base_read.assert_called_once()
+ os_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_os_response(self.os_task_handler.io,
self.base_log_source)
+ with (
+ patch.object(FileTaskHandler, "_read") as base_read,
+ patch.object(self.os_task_handler.io, "_os_read",
return_value=response) as os_read,
+ ):
+ self.os_task_handler.read(ti, 1, {"offset": 0})
+
+ base_read.assert_not_called()
+ os_read.assert_called_once()
+
@pytest.mark.db_test
def test_set_context(self, ti):
self.os_task_handler.set_context(ti)