[Improvement-18459][Common] Stream task log download in bounded chunks to prevent OOM - #18463
[Improvement-18459][Common] Stream task log download in bounded chunks to prevent OOM#18463xmg333 wants to merge 1 commit into
Conversation
SbloodyS
left a comment
There was a problem hiding this comment.
For a log larger than 47 MB:
LogServiceImpl#getTaskInstanceWholeLogFileBytesreturnsERROR.LogClientDelegate#getWholeLogBytestreats every local error as a reason to callremoteLogClient.getWholeLog(...).RemoteLogClientcallsgetFileContentBytesFromRemote, which now uses the same capped reader and silently returns only the first 47 MB.
With remote logging enabled, the API can therefore return a successfully downloaded but truncated log. With remote logging disabled or unavailable, it may return an empty log or a generic download error instead of the clear size-limit message.
Please distinguish “local log unavailable” from “log exceeds the supported size,” propagate the latter to the API, and make the reader fail explicitly rather than silently truncating. The regression test should cover the complete LogClientDelegate/API path, not only LogServiceImpl.
Additionally, the linked issue expects large logs to remain downloadable through chunked streaming. This PR rejects them entirely.
2aa5e28 to
f7168cf
Compare
|
Thanks for the review @SbloodyS . I've reworked the approach based on your feedback. New ** chunked streaming log ** is now completed. Could you confirm if this scope is what you had in mind? What's newNew RPC: Fallback logic (the important part)The API server runs a chunked loop with an The core invariant: fallback only happens when Three concrete scenarios:
Legacy path unchanged:
Tests cover all three scenarios in |
93169b9 to
b2731e8
Compare
| final byte[] bytes = remoteLogClient.getWholeLog(taskInstance); | ||
| if (bytes != null && bytes.length > 0) { | ||
| outputStream.write(bytes); | ||
| } | ||
| outputStream.flush(); |
There was a problem hiding this comment.
If remote returns null/empty (archive missing), this still flush()es and the download ends as HTTP 200 with only the log header. Please throw when bytes are absent so a missing remote log is not reported as a successful download.
There was a problem hiding this comment.
Thanks for catching this. Fixed in LogClientDelegate.writeRemoteLegacy: when remoteLogClient.getWholeLog(...) returns null/empty (remote archive missing), it now throws IOException instead of flushing an empty body. The exception propagates through streamWholeLog → StreamingResponseBody and aborts the response, so a missing log is no longer reported as a successful HTTP 200 download.
While reviewing the fix I also found and fixed a related gap: LoggerServiceImpl.checkDownloadLogAuth validated host but not logPath. It's now fixed and would return a clear error before streaming starts.
b2731e8 to
2191ccc
Compare
SbloodyS
left a comment
There was a problem hiding this comment.
Keep every fallback path memory-bounded
LogClientDelegate.java:132-202
The chunked path is bounded, but both fallbacks still load the complete log into memory:
- An old worker or first-chunk failure calls
localLogClient.getWholeLog(), whose worker-side implementation usesgetFileContentBytesFromLocal()and aByteArrayOutputStream. - A missing worker calls
remoteLogClient.getWholeLog(), which usesgetFileContentBytesFromRemote()and then reads the downloaded file into another completebyte[].
Therefore, downloading a large log from an old worker or remote storage can still reproduce the original OOM. Please stream the remote file from disk and either reject the unsupported old-worker path explicitly or otherwise make it enforceably bounded. Add regression coverage for large fallback logs.
Do not execute the whole-file fallback twice after an error
LogClientDelegate.java:141-164
When the first chunk returns a non-success response, writeLocalLegacy() is called inside the outer try. If that fallback throws—for example, the legacy RPC fails and the remote archive is missing—the outer catch still sees offset == 0 and calls writeLocalLegacy() again.
This repeats the legacy and remote requests and can also retry after a fallback has partially written to the response. Please limit the catch to the chunk RPC itself or otherwise let fallback failures propagate without re-entering the fallback.
BTW, the PR title and description still describe a 47 MB cap, while the implementation now uses chunked streaming. Please update them to match the current approach.
2191ccc to
2ecb4eb
Compare
|
Thanks for the three points — all have been addressed. 1. "Keep every fallback path memory-bounded"All three paths are now memory-bounded:
Additionally, Without this fix, the original exception cause could be lost and replaced by a Test coverage:
2. Do not execute the whole-file fallback twice after an error
This ensures that:
This is covered by:
The test explicitly verifies:
3. PR title and descriptionUpdated to: Stream task log download in bounded chunks to prevent OOM |
SbloodyS
left a comment
There was a problem hiding this comment.
NettyClientHandler stores only one opaque request ID in the channel attribute OPAQUE_KEY, while NettyRemotingClient reuses the same channel for concurrent RPC requests.
This creates a race:
- Request A stores opaque A.
- Request B stores opaque B on the same channel.
- A frame/decoder error occurs.
exceptionCaught()only completes opaque B.- Request A remains pending until its timeout.
There is also a second race: when any request completes successfully, doSendSync() unconditionally clears OPAQUE_KEY, which can erase the opaque ID of another request that is still in flight.
Please avoid using a single channel attribute for pending request tracking. On channel failure, all pending ResponseFutures associated with that channel should be completed with the original exception, or the channel should maintain a proper set/map of in-flight opaque IDs. Please also add a regression test with at least two concurrent requests sharing one channel and a decoder exception.
Replace whole-file log download with chunked streaming: - Add ILogService#getTaskInstanceLogFileChunk RPC to read [offset, offset+length) ranges from the worker, clamped to 8 MB per chunk. - API streams chunks via StreamingResponseBody; auth is checked synchronously before the HTTP response is committed so @ApiException still returns JSON errors. - On chunked RPC failure at offset==0, fall back to the legacy whole-file worker RPC (bounded by the TransporterDecoder maxFrameSize guard, 64 MB), then to remote log storage streamed in bounded chunks. Mid-stream failure throws IOException to avoid a corrupted download; the fallback is gated by a flag and called outside the try block so it cannot re-enter or double-execute. RPC failure propagation (reworked after review of the channel-attribute race): - Each ResponseFuture knows the channel it was sent on; FUTURE_TABLE is the single source of truth for in-flight requests. A channel death (pipeline exception or close) fails every in-flight future of THAT channel by identity match. One invariant replaces the track/untrack lifecycle whose every exit path (send failure, timeout, interrupt, retry, response-processing error) was a leak or a race of its own. - TransporterDecoder: maxFrameSize bounds the WHOLE message (header + body, long arithmetic), not each field separately — per-field checks allowed a 2x maxFrameSize total allocation. The default lives in ONE constant shared by the client/server configs. Correctness fixes found while auditing: - Log rotation mid-download reports LOG_TRUNCATED (offset > fileLength, single stat) instead of silently handing back a truncated download; a vanished file is reported by the typed FileNotFoundException instead of a racy re-stat. - Missing vs empty logs are distinct: a 0-byte log is a legal empty (head-only body); a missing file fails explicitly; the HTTP head is written lazily so startup failures return JSON, not a broken download. - Concurrent downloads of the same archive are serialized per path and the read is bounded by the observed size (fails explicitly if replaced mid-read). - readFileRange honors a single-stat contract: rotation cannot split the observation from the read. - Endpoint-scoped async timeout on the download endpoint: the servlet default (30s) silently truncated any longer download; scoped via request-local WebAsyncManager, no global config. Tests cover the full path: worker chunk RPC (range/EOF/not-found/truncated/ clamp), LogClientDelegate (chunk loop + all fallbacks + mid-stream + TooLongFrame + rotation + missing/empty + chunked remote), ResponseFuture (drain isolation, set-once cause, identity removal, fail/cancel), NettyClientHandler (shared-channel concurrent drain regression, deserialize failure, timeout/interrupt leak guards), TransporterDecoder (per-field and combined frame limits), RemoteLogClient (bounded stream, empty-vs-missing), real-RPC integration tests (multi-chunk download + deterministic rotation), and controller MockMvc (auth failure JSON, success streams octet-stream). Verified end-to-end in standalone (embedded Jetty + real Netty RPC): a 1 GB log downloads completely (byte-identical md5) with stable heap in a 1 GB JVM hosting api+master+worker together. Co-Authored-By: Claude <noreply@anthropic.com>
2ecb4eb to
5ac4d26
Compare
|
Thanks for the review. I spent quite a bit of time debugging the previous The new approach is simpler: each The lifecycle is now:
This also fixes a few cases that were easy to miss with the previous approach:
The requested regression test is included: It sends two concurrent requests over the same real Netty channel, triggers a malformed frame, and verifies that both callers receive the decoder error promptly. I also added Other fixesWhile testing this, I found a few highly related issues and fixed them as well:
VerificationThere are now 80 tests across the 4 modules, including regression tests for the cases above. |
Was this PR generated or assisted by AI?
YES. Implementation and tests drafted with assistance from Claude (Anthropic); reviewed by human.
Purpose of the pull request
getFileContentBytesFromLocalread entire files into memory with no size limit. Downloading a large task log caused OOM on the worker.This PR caps the read at 47 MB and returns a clear error for oversized logs.
Why 47 MB, not 64 MB? The
byte[]is JSON-serialized as base64 (~1.33× expansion) before RPC transmission. 47 MB raw → ~63 MB JSON body, staying under the 64 MBmaxFrameSizeinTransporterDecoder. 64 MB raw would produce ~86 MB body and be rejected byTooLongFrameException.close #18459
Brief change log
LogUtils: addMAX_LOG_DOWNLOAD_SIZE = 47 MB;getFileContentBytesFromLocalstops reading once the limit is reached.LogServiceImpl: checks file size before reading; returnsERRORwith a clear message for oversized logs instead of silently truncating.Verify this pull request
This change added tests and can be verified as follows:
LogServiceImplTest: a 48 MB file returnsERRORwith message containing "exceeds maximum download size"../mvnw spotless:checkpasses.Pull Request Notice
Pull Request Notice