Repository navigation
Stream remote task logs from S3 instead of reading whole objects - #73517
Merged
Merged
Conversation
``S3RemoteLogIO.read`` loads each remote log object into a single string via ``S3Hook.read_key`` (``Body.read().decode()``). Core then wraps that string in an ``io.StringIO`` (``_get_compatible_log_stream``), so viewing a task log costs the api-server at least twice the size of the log in memory, and a large log can OOM it. Where the api-server runs with ``--apps all``, the UI and the Task Execution API share a process, so an OOM kill there also drops the endpoint that running tasks heartbeat to. Core has supported a streaming read path since 3.2.0: ``FileTaskHandler._read_remote_logs`` prefers ``remote_io.stream()`` when the provider implements it and falls back to ``read()`` otherwise. Google (gs) and Amazon CloudWatch already implement it; S3 was the remaining legacy reader. Add ``S3RemoteLogIO.stream()`` following the same shape as those two, and let ``read()`` delegate to it: * ``stream()`` returns one generator per object; the GetObject is issued when the generator is first consumed, so listing keys no longer implies downloading them. * ``_get_log_stream()`` iterates ``Body.iter_chunks()`` and splits on ``"\n"`` only, with the trailing newline stripped, matching what ``file_task_handler._stream_lines_by_chunk`` produces for local logs. An incremental UTF-8 decoder keeps multi-byte characters intact across chunk boundaries. * A failure to read one object is yielded as a log line rather than raised, so one unreadable object does not hide the other objects of the same task instance. This matches the previous ``s3_read(..., return_error=True)`` behaviour. * ``read()`` re-joins the lines with their newline, so the string it returns is byte-identical to the object for callers that cannot consume a stream. Peak memory for reading a log becomes a function of the chunk size rather than of the log size. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Congratulations on your first Pull Request and welcome to the Apache Airflow community! If you have any issues or are unsure about any anything please check our Contributors' Guide
|
Contributor
|
LGTM! |
1 task done
o-nikolas
approved these changes
Oct 22, 2026
o-nikolas
left a comment
Contributor
There was a problem hiding this comment.
Thanks, looks reasonable to me, but there are conflicts that need resolving and I just staretd the tests.
# Conflicts: # providers/amazon/src/airflow/providers/amazon/aws/log/s3_task_handler.py
On Airflow 2 the source messages are formatted as "Found logs in s3:" followed by " * <key>" lines, so the new tests failed the 2.11.1 compatibility run by asserting the Airflow 3 shape only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
o-nikolas
enabled auto-merge (squash)
November 6, 2026 16:38
|
Awesome work, congrats on your first merged pull request! You are invited to check our Issue Tracker for additional contributions. |
1 task done
o-nikolas
pushed a commit
that referenced
this pull request
Nov 6, 2026
After #73517 added S3RemoteLogIO.stream(), FileTaskHandler._read_remote_logs() returns a generator of log lines for S3 logs. The integration test still compares that generator directly with a string, causing consistent core CI failures. Consume the stream within the existing mock_aws() context and compare its lines with the expected content.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
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 letsread()delegate to it.Why
S3RemoteLogIO.read()loads every remote log object into a single string:Core then wraps that string in an
io.StringIO(_get_compatible_log_stream), so opening a task log in the UI costs the api-server at least twice the size of the log, and a large enough log OOM-kills it.That failure is worse than a failed log view. When the api-server runs with the default
--apps all, the UI and the Task Execution API share a process, so the OOM kill also drops the endpoint that running tasks heartbeat to — viewing a log can take down tasks that had nothing to do with it.Everything downstream of the provider is already bounded:
LogStreamAccumulatorspills to disk everyHEAP_DUMP_SIZElines and the K-way merge heap is capped by the same value. The provider read is the only unbounded step left.Why now
Core has had the streaming path since 3.2.0 —
FileTaskHandler._read_remote_logsprefersremote_io.stream()when the provider implements it and falls back toread()otherwise:gs(google) andcloudwatch(amazon) already implementstream(). S3 was the remaining legacy reader, so it is the one backend where this still costs the whole object in memory.How
stream()follows the same shape as the GCS and CloudWatch implementations:GetObjectis issued when the generator is first consumed, so listing the keys of a task instance no longer implies downloading them._get_log_stream()iteratesBody.iter_chunks()and splits on"\n"only, with the trailing newline stripped — the same recordsfile_task_handler._stream_lines_by_chunkproduces for local logs, so the log reader sees exactly what it saw before. An incremental UTF-8 decoder keeps multi-byte characters intact across chunk boundaries.s3_read(..., return_error=True)behaviour.read()re-joins the lines with their newline, so the string it returns is byte-identical to the object for callers that cannot consume a stream (Airflow < 3.2, andS3TaskHandler._read_remote_logs, which is left callingio.read()exactly like the GCS and CloudWatch handlers do).Peak memory for a log read becomes a function of the chunk size instead of the log size.
Tests
providers/amazon/tests/unit/amazon/aws/log/test_s3_task_handler.py— 43 passed (8 new).The new tests pin the two properties that matter:
test_read_does_not_load_the_object_as_a_single_stringfails ifs3_readis ever called again from this path — a regression back to the whole-object read is caught by the suite, not by review.test_stream_does_not_fetch_the_object_until_it_is_consumedpins the laziness.Plus: line splitting without the trailing newline, key ordering, multi-byte characters split across chunk boundaries (with
CHUNK_SIZEpatched to 3 bytes), the empty case, and the unreadable-object case.Verification against this patch
Measured on
breeze start-airflow --integration localstack --backend postgresfrom this branch (Airflow 3.4.0.dev0, the default S3 remote logging config,delete_local_logs = Trueso that the log can only come back from S3). A task printed 2,000,000 lines, each with multi-byte characters, into a 406.3 MiB object. The log was then read throughTaskLogReader.read_log_stream, the call behind theapplication/x-ndjsonlog endpoint the UI uses. The baseline is the same checkout with onlys3_task_handler.pyswapped back tomain, reading the same object.mainS3RemoteLogIO.read()S3RemoteLogIO.stream()tracemalloc)tracemalloc¹ The
tracemallocrun of this PR stopped at line 886,112.tracemallocslows the consumer down enough that LocalStack resets the GetObject connection; a plain boto3 reader at 2 MiB/s is reset the same way after 79 s, while a full-speed read of the object takes 1 s. The read surfaced the reset as aCould not read logs from ...line instead of raising, as intended. Real S3 does not do this: reading a 1.4 GB public object at a steady 0.5 MiB/s ran for 10 minutes without a reset. The RSS row comes from complete runs of both versions.In production
An equivalent implementation (same
iter_chunks+ incremental-decoder read path, installed via[logging] logging_config_class) has been running on Airflow 3.1.8 with S3 remote logging in our dev, staging and production clusters since 2026-09-10.tracemalloc, same input).TaskLogReader.read_log_streamstreams 11,326 lines at a 61.9 MiB heap peak.Notes
Found logs in s3:/* <key>) is preserved as-is.CHUNK_SIZEis defined in the provider rather than imported from core, so the provider keeps working against Airflow versions that do not expose it.LogStreamAccumulator(Skip log accumulator disk spill when reading finished task logs #70671), so the GetObject is read as fast as the client consumes the NDJSON response and stays open that long. Against real S3, a body read slowly but steadily was not reset (0.5 MiB/s for 10 minutes); one left unread for 400 s was (60 s and 180 s were fine). In that case the rest of that object is replaced by aCould not read logs from ...line. The numbers above were measured before Skip log accumulator disk spill when reading finished task logs #70671, when the accumulator still drained the stream.