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

o-nikolas 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 0b9bf0cd855 Stream remote task logs from S3 instead of reading whole 
objects (#73517)
0b9bf0cd855 is described below

commit 0b9bf0cd855483fde15cb365e3e58fd4f42af483
Author: 장성태 <[email protected]>
AuthorDate: Sat Nov 7 02:49:50 2026 +0900

    Stream remote task logs from S3 instead of reading whole objects (#73517)
    
    Adds S3RemoteLogIO.stream() so that remote task logs stored in S3 are read 
as a stream of lines instead of being materialised as one string per object, 
and lets read() delegate to it.
---
 .../providers/amazon/aws/log/s3_task_handler.py    | 86 ++++++++++++++++++----
 .../unit/amazon/aws/log/test_s3_task_handler.py    | 84 +++++++++++++++++++++
 2 files changed, 155 insertions(+), 15 deletions(-)

diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/log/s3_task_handler.py 
b/providers/amazon/src/airflow/providers/amazon/aws/log/s3_task_handler.py
index 32b0b1ee4dd..1bbe883f111 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/log/s3_task_handler.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/log/s3_task_handler.py
@@ -17,6 +17,7 @@
 # under the License.
 from __future__ import annotations
 
+import codecs
 import inspect
 import logging
 import os
@@ -36,7 +37,19 @@ from airflow.utils.log.logging_mixin import LoggingMixin
 if TYPE_CHECKING:
     from airflow.models.taskinstance import TaskInstance
     from airflow.sdk.types import RuntimeTaskInstanceProtocol as RuntimeTI
-    from airflow.utils.log.file_task_handler import LogMessages, LogSourceInfo
+    from airflow.utils.log.file_task_handler import (
+        LogMessages,
+        LogResponse,
+        LogSourceInfo,
+        RawLogStream,
+        StreamingLogResponse,
+    )
+
+
+# Mirrors ``airflow.utils.log.file_task_handler.CHUNK_SIZE`` so that remote 
reads are chunked the
+# same way local log reads are. Not imported from core to avoid coupling the 
provider to a private
+# constant that older Airflow versions may not expose.
+CHUNK_SIZE = 1024 * 1024 * 5  # 5MB
 
 
 @attrs.define
@@ -198,22 +211,65 @@ class S3RemoteLogIO(LoggingMixin):  # noqa: D101
                     return False
         return True
 
-    def read(self, relative_path: str, ti: RuntimeTI | None) -> 
tuple[LogSourceInfo, LogMessages | None]:
-        logs: list[str] = []
-        messages = []
+    def read(self, relative_path: str, ti: RuntimeTI | None) -> LogResponse:
+        messages, log_streams = self.stream(relative_path, ti)
+        if not log_streams:
+            return messages, None
+
+        # Exhaust each stream into a single string, for callers that cannot 
consume a stream.
+        # The newline is added back per line so that the string is 
byte-identical to the object.
+        return messages, ["".join(f"{line}\n" for line in log_stream) for 
log_stream in log_streams]
+
+    def stream(self, relative_path: str, ti: RuntimeTI | None) -> 
StreamingLogResponse:
+        messages: list[str] = []
         bucket, prefix = 
self.hook.parse_s3_url(s3url=os.path.join(self.remote_base, relative_path))
         keys = self.hook.list_keys(bucket_name=bucket, prefix=prefix)
-        if keys:
-            keys = sorted(f"s3://{bucket}/{key}" for key in keys)
-            if AIRFLOW_V_3_0_PLUS:
-                messages = keys
-            else:
-                messages.append("Found logs in s3:")
-                messages.extend(f"  * {key}" for key in keys)
-            for key in keys:
-                logs.append(self.s3_read(key, return_error=True))
-            return messages, logs
-        return messages, None
+        if not keys:
+            return messages, []
+
+        keys = sorted(f"s3://{bucket}/{key}" for key in keys)
+        if AIRFLOW_V_3_0_PLUS:
+            messages = keys
+        else:
+            messages.append("Found logs in s3:")
+            messages.extend(f"  * {key}" for key in keys)
+
+        # The objects are not fetched here: each generator issues its 
GetObject on first use.
+        return messages, [self._get_log_stream(key) for key in keys]
+
+    def _get_log_stream(self, remote_log_location: str) -> RawLogStream:
+        r"""
+        Yield the lines of a remote log object without holding the whole 
object in memory.
+
+        Lines are split exactly like 
:func:`~airflow.utils.log.file_task_handler._stream_lines_by_chunk`
+        does for local logs: on ``"\n"`` only, with the trailing newline 
stripped, so the records the
+        log reader sees are the same ones it saw when this method read the 
object as a single string.
+
+        :param remote_log_location: the log's location in remote storage
+        :yield: Lines of the log object.
+        """
+        body = None
+        try:
+            body = self.hook.get_key(remote_log_location).get()["Body"]
+            # An incremental decoder keeps multi-byte characters intact across 
chunk boundaries.
+            decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
+            buffer = ""
+            for chunk in body.iter_chunks(chunk_size=CHUNK_SIZE):
+                buffer += decoder.decode(chunk)
+                *lines, buffer = buffer.split("\n")
+                yield from lines
+            buffer += decoder.decode(b"", final=True)
+            if buffer:
+                yield from buffer.split("\n")
+        except Exception as error:
+            # Surface the failure as a log line instead of raising, so that a 
single unreadable
+            # object does not hide the logs of the other objects of the same 
task instance.
+            msg = f"Could not read logs from {remote_log_location} with error: 
{error}"
+            self.log.exception(msg)
+            yield msg
+        finally:
+            if body is not None:
+                body.close()
 
 
 class S3TaskHandler(FileTaskHandler, LoggingMixin):
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 85ef2325e64..e922400ffd7 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
@@ -289,6 +289,90 @@ class TestS3RemoteLogIO:
         assert rec.levelno == logging.ERROR
         assert rec.exc_info is not None
 
+    @staticmethod
+    def _expected_messages(*locations: str) -> list[str]:
+        """Source messages as ``stream()`` and ``read()`` format them, which 
differs on Airflow 2."""
+        if AIRFLOW_V_3_0_PLUS:
+            return list(locations)
+        return ["Found logs in s3:", *(f"  * {location}" for location in 
locations)]
+
+    def test_stream_returns_no_streams_when_there_are_no_logs(self):
+        messages, log_streams = self.subject.stream("1.log", self.ti)
+
+        assert messages == []
+        assert log_streams == []
+
+    def test_read_returns_none_when_there_are_no_logs(self):
+        messages, logs = self.subject.read("1.log", self.ti)
+
+        assert messages == []
+        assert logs is None
+
+    def test_stream_yields_lines_without_the_trailing_newline(self):
+        self.conn.put_object(Bucket="bucket", Key=self.remote_log_key, 
Body=b"Log line\nLine 2\n")
+
+        messages, log_streams = self.subject.stream("1.log", self.ti)
+
+        assert messages == self._expected_messages(self.remote_log_location)
+        assert list(log_streams[0]) == ["Log line", "Line 2"]
+
+    def test_stream_sorts_the_keys_of_the_task_instance(self):
+        self.conn.put_object(Bucket="bucket", Key=self.remote_log_key + 
".trigger.log", Body=b"Line 3\n")
+        self.conn.put_object(Bucket="bucket", Key=self.remote_log_key, 
Body=b"Line 1\n")
+
+        messages, log_streams = self.subject.stream("1.log", self.ti)
+
+        assert messages == self._expected_messages(
+            self.remote_log_location, self.remote_log_location + ".trigger.log"
+        )
+        assert [list(log_stream) for log_stream in log_streams] == [["Line 
1"], ["Line 3"]]
+
+    def test_stream_does_not_fetch_the_object_until_it_is_consumed(self):
+        self.conn.put_object(Bucket="bucket", Key=self.remote_log_key, 
Body=b"Log line\n")
+
+        with mock.patch.object(S3Hook, "get_key", 
wraps=self.subject.hook.get_key) as get_key:
+            _, log_streams = self.subject.stream("1.log", self.ti)
+            get_key.assert_not_called()
+
+            assert list(log_streams[0]) == ["Log line"]
+            get_key.assert_called_once()
+
+    def test_read_does_not_load_the_object_as_a_single_string(self):
+        self.conn.put_object(Bucket="bucket", Key=self.remote_log_key, 
Body=b"Log line\nLine 2\n")
+
+        with mock.patch.object(S3RemoteLogIO, "s3_read") as s3_read:
+            messages, logs = self.subject.read("1.log", self.ti)
+
+        s3_read.assert_not_called()
+        assert messages == self._expected_messages(self.remote_log_location)
+        assert logs == ["Log line\nLine 2\n"]
+
+    def test_stream_keeps_multibyte_characters_intact_across_chunks(self):
+        self.conn.put_object(
+            Bucket="bucket", Key=self.remote_log_key, Body="첫 번째 줄\n두 번째 
줄\n".encode()
+        )
+
+        # A chunk size that is guaranteed to split multi-byte characters in 
half.
+        with 
mock.patch("airflow.providers.amazon.aws.log.s3_task_handler.CHUNK_SIZE", 3):
+            _, log_streams = self.subject.stream("1.log", self.ti)
+
+            assert list(log_streams[0]) == ["첫 번째 줄", "두 번째 줄"]
+
+    def test_stream_yields_an_error_line_when_the_object_cannot_be_read(self, 
caplog):
+        self.conn.put_object(Bucket="bucket", Key=self.remote_log_key, 
Body=b"Log line\n")
+        error = ClientError({"Error": {"Code": "403", "Message": 
"Forbidden"}}, "GetObject")
+
+        with mock.patch.object(S3Hook, "get_key", side_effect=error):
+            _, log_streams = self.subject.stream("1.log", self.ti)
+            with caplog.at_level(logging.ERROR):
+                lines = list(log_streams[0])
+
+        assert len(lines) == 1
+        assert lines[0].startswith(f"Could not read logs from 
{self.remote_log_location} with error:")
+        errors = [record for record in caplog.records if record.levelno == 
logging.ERROR]
+        assert len(errors) == 1
+        assert errors[0].exc_info is not None
+
     def test_write(self):
         with mock.patch.object(self.subject.log, "error") as mock_error:
             self.subject.write("text", self.remote_log_location)

Reply via email to