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

jason810496 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 97d4d4e2aa9 Skip log accumulator disk spill when reading finished task 
logs (#70671)
97d4d4e2aa9 is described below

commit 97d4d4e2aa920cb2cbaf7b7783217d320068bde7
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Thu Oct 29 20:25:30 2026 +0800

    Skip log accumulator disk spill when reading finished task logs (#70671)
    
    * Skip log accumulator disk spill when reading finished task logs
    
    * Version-gate remote log handler tests for terminal-state metadata
    
    Finished-task reads drop the log_pos continuation token from 3.4.0
    onward, but the provider test matrix still runs against released cores
    (3.0-3.3) that emit it. Gate the S3, GCS, and WASB task-handler metadata
    assertions on AIRFLOW_V_3_4_0_PLUS so both the source run and the
    older-core compatibility runs pass.
    
    * Add plan for incremental UI log fetch via continuation token
    
    * Wrap only unfinished log reads in the stream accumulator
    
    Apply the log_pos skip and header once for both paths instead of 
duplicating it in the finished-read branch.
    
    * Drop the accidentally committed planning doc
    
    * Test both accumulator paths of FileTaskHandler._read
    
    Cover running, deferred, older-try, and finished reads, including a running
    task's follow-up poll with an inbound log_pos. Update the _read docstring 
for
    the log_pos contract.
    
    * Drop the misleading log_pos note in the log read test
    
    * Drop the redundant accumulator comment in the log read test
    
    * Assert only end_of_log in the remote log handler tests
    
    log_pos is a core detail covered by the core tests, so the provider tests no
    longer need a version gate for it.
    
    * Apply suggestion from @uranusjr to fix the comment
    
    Co-authored-by: Tzu-ping Chung <[email protected]>
    
    ---------
    
    Co-authored-by: Tzu-ping Chung <[email protected]>
---
 .../src/airflow/utils/log/file_task_handler.py     |  40 ++++----
 .../tests/unit/utils/log/test_log_reader.py        |   4 +-
 airflow-core/tests/unit/utils/test_log_handlers.py | 109 ++++++++++++++++++---
 .../unit/amazon/aws/log/test_s3_task_handler.py    |   4 +-
 .../unit/google/cloud/log/test_gcs_task_handler.py |   4 +-
 .../microsoft/azure/log/test_wasb_task_handler.py  |   2 +-
 6 files changed, 124 insertions(+), 39 deletions(-)

diff --git a/airflow-core/src/airflow/utils/log/file_task_handler.py 
b/airflow-core/src/airflow/utils/log/file_task_handler.py
index c4790fe206b..e7fe50854ff 100644
--- a/airflow-core/src/airflow/utils/log/file_task_handler.py
+++ b/airflow-core/src/airflow/utils/log/file_task_handler.py
@@ -545,18 +545,16 @@ class FileTaskHandler(logging.Handler):
         :param ti: task instance record
         :param try_number: current try_number to read log from
         :param metadata: log metadata,
-                         can be used for steaming log reading and auto-tailing.
+                         can be used for streaming log reading and 
auto-tailing.
                          Following attributes are used:
-                         log_pos: (absolute) Char position to which the log
-                                  which was retrieved in previous calls, this
-                                  part will be skipped and only following test
-                                  returned to be added to tail.
-        :return: log message as a string and metadata.
+                         log_pos: Number of log records returned by previous 
calls.
+                                  These records are skipped and only the 
following ones are returned.
+        :return: log stream and metadata.
                  Following attributes are used in metadata:
                  end_of_log: Boolean, True if end of log is reached or False
                              if further calls might get more log text.
-                             This is determined by the status of the 
TaskInstance
-                 log_pos: (absolute) Char position to which the log is 
retrieved
+                             This is determined by the state and try number of 
the TaskInstance
+                 log_pos: Number of log records read so far. Only present when 
end_of_log is False.
         """
         # Task instance here might be different from task instance when
         # initializing the handler. Thus explicitly getting log location
@@ -639,21 +637,21 @@ class FileTaskHandler(logging.Handler):
             TaskInstanceState.DEFERRED,
         )
 
-        with LogStreamAccumulator(out_stream, HEAP_DUMP_SIZE) as 
stream_accumulator:
-            log_pos = stream_accumulator.total_lines
-            out_stream = stream_accumulator.stream
+        out_metadata: LogMetadata = {"end_of_log": end_of_log}
+        if not end_of_log:
+            # Counting lines for log_pos drains the stream; skip when no token 
is needed.
+            with LogStreamAccumulator(out_stream, HEAP_DUMP_SIZE) as 
stream_accumulator:
+                out_metadata["log_pos"] = stream_accumulator.total_lines
+                out_stream = stream_accumulator.stream
 
-            # skip log stream until the last position
-            if metadata and "log_pos" in metadata:
-                out_stream = islice(out_stream, metadata["log_pos"], None)
-            else:
-                # first time reading log, add messages before interleaved log 
stream
-                out_stream = chain(header, out_stream)
+        # skip log stream until the last position
+        if metadata and "log_pos" in metadata:
+            out_stream = islice(out_stream, metadata["log_pos"], None)
+        else:
+            # first time reading log, add messages before interleaved log 
stream
+            out_stream = chain(header, out_stream)
 
-            return out_stream, {
-                "end_of_log": end_of_log,
-                "log_pos": log_pos,
-            }
+        return out_stream, out_metadata
 
     @staticmethod
     def _get_pod_namespace(ti: TaskInstance):
diff --git a/airflow-core/tests/unit/utils/log/test_log_reader.py 
b/airflow-core/tests/unit/utils/log/test_log_reader.py
index 1c65cb1806b..01ba3aa6b56 100644
--- a/airflow-core/tests/unit/utils/log/test_log_reader.py
+++ b/airflow-core/tests/unit/utils/log/test_log_reader.py
@@ -136,7 +136,7 @@ class TestLogView:
         )
         assert logs[2].event == "::endgroup::"
         assert logs[3].event == "try_number=1."
-        assert metadata == {"end_of_log": True, "log_pos": 1}
+        assert metadata == {"end_of_log": True}
 
     def test_test_read_log_chunks_should_read_latest_files(self):
         task_log_reader = TaskLogReader()
@@ -151,7 +151,7 @@ class TestLogView:
         )
         assert logs[2].event == "::endgroup::"
         assert logs[3].event == f"try_number={ti.try_number}."
-        assert metadata == {"end_of_log": True, "log_pos": 1}
+        assert metadata == {"end_of_log": True}
 
     def test_test_test_read_log_stream_should_read_one_try(self):
         task_log_reader = TaskLogReader()
diff --git a/airflow-core/tests/unit/utils/test_log_handlers.py 
b/airflow-core/tests/unit/utils/test_log_handlers.py
index f779ac8b44b..412323cbf7e 100644
--- a/airflow-core/tests/unit/utils/test_log_handlers.py
+++ b/airflow-core/tests/unit/utils/test_log_handlers.py
@@ -25,7 +25,7 @@ import os
 from http import HTTPStatus
 from importlib import reload
 from pathlib import Path
-from typing import cast
+from typing import TYPE_CHECKING, cast
 from unittest import mock
 from unittest.mock import patch
 
@@ -62,6 +62,7 @@ from airflow.utils.log.file_task_handler import (
     _log_stream_to_parsed_log_stream,
     _stream_lines_by_chunk,
 )
+from airflow.utils.log.log_stream_accumulator import LogStreamAccumulator
 from airflow.utils.log.logging_mixin import set_context
 from airflow.utils.net import get_hostname
 from airflow.utils.session import create_session
@@ -77,6 +78,9 @@ from tests_common.test_utils.file_task_handler import (
 )
 from tests_common.test_utils.markers import 
skip_if_force_lowest_dependencies_marker
 
+if TYPE_CHECKING:
+    from collections.abc import Generator
+
 pytestmark = [pytest.mark.db_test]
 
 DEFAULT_DATE = pendulum.datetime(2016, 1, 1)
@@ -93,6 +97,18 @@ def cleanup_tables():
     clear_db_connections()
 
 
+def _tracking_stream(lines: list[str]) -> tuple[Generator[str, None, None], 
list[str]]:
+    """Return a stream of ``lines`` and the list of lines pulled from it so 
far."""
+    pulled: list[str] = []
+
+    def gen() -> Generator[str, None, None]:
+        for line in lines:
+            pulled.append(line)
+            yield line
+
+    return gen(), pulled
+
+
 class TestFileTaskLogHandler:
     def clean_up(self):
         with create_session() as session:
@@ -503,7 +519,7 @@ class TestFileTaskLogHandler:
         log_handler_output_stream, metadata = 
fth._read(ti=local_log_file_read, try_number=1)
         mock_read_local.assert_called_with(path)
         assert extract_events(log_handler_output_stream) == ["the log"]
-        assert metadata == {"end_of_log": True, "log_pos": 1}
+        assert metadata == {"end_of_log": True}
 
     
@patch("airflow.utils.log.file_task_handler.FileTaskHandler._read_from_local")
     def test__read_when_local_respects_log_pos_metadata(self, mock_read_local, 
create_task_instance):
@@ -532,10 +548,74 @@ class TestFileTaskLogHandler:
 
         # Should resume from the third line only.
         assert extract_events(log_handler_output_stream) == ["line 3"]
-        assert metadata == {"end_of_log": True, "log_pos": 3}
+        assert metadata == {"end_of_log": True}
+
+    @pytest.mark.parametrize(
+        ("ti_state", "ti_try_number", "expected_metadata"),
+        [
+            pytest.param(
+                TaskInstanceState.RUNNING,
+                1,
+                {"end_of_log": False, "log_pos": 3},
+                id="running-current-try",
+            ),
+            pytest.param(
+                TaskInstanceState.DEFERRED,
+                1,
+                {"end_of_log": False, "log_pos": 3},
+                id="deferred-current-try",
+            ),
+            pytest.param(TaskInstanceState.RUNNING, 2, {"end_of_log": True}, 
id="running-older-try"),
+            pytest.param(TaskInstanceState.SUCCESS, 1, {"end_of_log": True}, 
id="success"),
+            pytest.param(TaskInstanceState.FAILED, 1, {"end_of_log": True}, 
id="failed"),
+        ],
+    )
+    def test__read_accumulates_only_unfinished_reads(
+        self, ti_state, ti_try_number, expected_metadata, create_task_instance
+    ):
+        ti = create_task_instance(
+            dag_id="dag_for_testing_local_log_read",
+            task_id="task_for_testing_local_log_read",
+            run_type=DagRunType.SCHEDULED,
+            logical_date=DEFAULT_DATE,
+            state=ti_state,
+        )
+        ti.try_number = ti_try_number
+        source, pulled = _tracking_stream(["line 1", "line 2", "line 3"])
+        fth = FileTaskHandler("")
+
+        with (
+            patch.object(fth, "_read_from_local", return_value=(["the 
messages"], [source])),
+            patch.object(fth, "_read_from_logs_server", return_value=([], [])),
+            patch.object(fth, "_get_executor") as get_executor,
+            patch(
+                "airflow.utils.log.file_task_handler.LogStreamAccumulator", 
wraps=LogStreamAccumulator
+            ) as accumulator,
+        ):
+            get_executor.return_value.get_streaming_task_log.return_value = 
([], [])
+            log_handler_output_stream, metadata = fth._read(ti=ti, 
try_number=1)
+
+            accumulated = not expected_metadata["end_of_log"]
+            assert len(pulled) == (3 if accumulated else 0)
+            assert accumulator.called is accumulated
+            assert extract_events(log_handler_output_stream) == ["line 1", 
"line 2", "line 3"]
+        assert metadata == expected_metadata
 
+    @pytest.mark.parametrize(
+        ("ti_state", "expected_metadata"),
+        [
+            pytest.param(TaskInstanceState.SUCCESS, {"end_of_log": True}, 
id="finished"),
+            pytest.param(
+                TaskInstanceState.RUNNING,
+                {"end_of_log": False, "log_pos": 3},
+                id="running-follow-up-poll",
+            ),
+        ],
+    )
     
@patch("airflow.utils.log.file_task_handler.FileTaskHandler._read_from_local")
-    def test_read_respects_log_pos_metadata(self, mock_read_local, 
create_task_instance):
+    def test_read_respects_log_pos_metadata(
+        self, mock_read_local, ti_state, expected_metadata, 
create_task_instance
+    ):
         """The public `read()` wrapper must accept the `islice` stream 
`_read()` returns for log_pos reads."""
         mock_read_local.return_value = (
             ["the messages"],
@@ -546,18 +626,25 @@ class TestFileTaskLogHandler:
             task_id="task_for_testing_local_log_read",
             run_type=DagRunType.SCHEDULED,
             logical_date=DEFAULT_DATE,
+            state=ti_state,
         )
+        local_log_file_read.try_number = 1
         fth = FileTaskHandler("")
 
-        log_handler_output_stream, metadata = fth.read(
-            local_log_file_read,
-            try_number=1,
-            metadata={"log_pos": 2},
-        )
+        with (
+            patch.object(fth, "_read_from_logs_server", return_value=([], [])),
+            patch.object(fth, "_get_executor") as get_executor,
+        ):
+            get_executor.return_value.get_streaming_task_log.return_value = 
([], [])
+            log_handler_output_stream, metadata = fth.read(
+                local_log_file_read,
+                try_number=1,
+                metadata={"log_pos": 2},
+            )
 
         # Should resume from the third line only.
         assert extract_events(log_handler_output_stream) == ["line 3"]
-        assert metadata == {"end_of_log": True, "log_pos": 3}
+        assert metadata == expected_metadata
 
     def test__read_from_local(self, tmp_path):
         """Tests the behavior of method _read_from_local"""
@@ -652,7 +739,7 @@ class TestFileTaskLogHandler:
         else:
             fth._read_from_logs_server.assert_not_called()
         assert extract_events(logs, False) == expected_logs
-        assert metadata == {"end_of_log": True, "log_pos": 3}
+        assert metadata == {"end_of_log": True}
 
     @pytest.mark.parametrize("is_historical", [False, True])
     def test_read_served_logs(self, is_historical, create_task_instance, 
session):
diff --git a/providers/amazon/tests/unit/amazon/aws/log/test_s3_task_handler.py 
b/providers/amazon/tests/unit/amazon/aws/log/test_s3_task_handler.py
index 06144952072..85ef2325e64 100644
--- a/providers/amazon/tests/unit/amazon/aws/log/test_s3_task_handler.py
+++ b/providers/amazon/tests/unit/amazon/aws/log/test_s3_task_handler.py
@@ -467,7 +467,7 @@ class TestS3TaskHandler:
             assert log[5].event == "Line 2"
             assert log[6].event == "Log line 3"
             assert log[7].event == "Line 4"
-            assert metadata == {"end_of_log": True, "log_pos": 4}
+            assert metadata["end_of_log"] is True
         elif AIRFLOW_V_3_0_PLUS:
             log = list(log)
             assert log[0].event == "::group::Log message source details"
@@ -492,7 +492,7 @@ class TestS3TaskHandler:
         if AIRFLOW_V_3_0_PLUS:
             log = list(log)
             assert len(log) == 2
-            assert metadata == {"end_of_log": True, "log_pos": 0}
+            assert metadata["end_of_log"] is True
         else:
             assert len(log) == 1
             assert len(log) == len(metadata)
diff --git 
a/providers/google/tests/unit/google/cloud/log/test_gcs_task_handler.py 
b/providers/google/tests/unit/google/cloud/log/test_gcs_task_handler.py
index 9cbbc19b74b..78fc5f809a0 100644
--- a/providers/google/tests/unit/google/cloud/log/test_gcs_task_handler.py
+++ b/providers/google/tests/unit/google/cloud/log/test_gcs_task_handler.py
@@ -525,7 +525,7 @@ class TestGCSTaskHandler:
             assert logs[1].event == expected_gs_uri
             assert logs[2].event == "::endgroup::"
             assert logs[3].event == "CONTENT"
-            assert metadata == {"end_of_log": True, "log_pos": 1}
+            assert metadata["end_of_log"] is True
         elif AIRFLOW_V_3_0_PLUS:
             logs = list(logs)
             assert logs[0].event == "::group::Log message source details"
@@ -563,7 +563,7 @@ class TestGCSTaskHandler:
             assert log[1].event == expected_gs_uri
             assert log[2].event == f"{self.gcs_task_handler.local_base}/1.log"
             assert log[3].event == "::endgroup::"
-            assert metadata == {"end_of_log": True, "log_pos": 0}
+            assert metadata["end_of_log"] is True
         elif AIRFLOW_V_3_0_PLUS:
             log = list(log)
             assert log[0].event == "::group::Log message source details"
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/log/test_wasb_task_handler.py
 
b/providers/microsoft/azure/tests/unit/microsoft/azure/log/test_wasb_task_handler.py
index f4834e2612b..d4c9eab9272 100644
--- 
a/providers/microsoft/azure/tests/unit/microsoft/azure/log/test_wasb_task_handler.py
+++ 
b/providers/microsoft/azure/tests/unit/microsoft/azure/log/test_wasb_task_handler.py
@@ -210,7 +210,7 @@ class TestWasbTaskHandler:
             )
             assert logs[2].event == "::endgroup::"
             assert logs[3].event == "Log line"
-            assert metadata == {"end_of_log": True, "log_pos": 1}
+            assert metadata["end_of_log"] is True
         elif AIRFLOW_V_3_0_PLUS:
             logs = list(logs)
             assert logs[0].event == "::group::Log message source details"

Reply via email to