Skip to content

[Improvement-18459][Common] Stream task log download in bounded chunks to prevent OOM - #18463

Open
xmg333 wants to merge 1 commit into
apache:devfrom
xmg333:fix/log-download-size-check
Open

[Improvement-18459][Common] Stream task log download in bounded chunks to prevent OOM#18463
xmg333 wants to merge 1 commit into
apache:devfrom
xmg333:fix/log-download-size-check

Conversation

@xmg333

@xmg333 xmg333 commented Aug 4, 2026

Copy link
Copy Markdown

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

getFileContentBytesFromLocal read 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 MB maxFrameSize in TransporterDecoder. 64 MB raw would produce ~86 MB body and be rejected by TooLongFrameException.

close #18459

Brief change log

  • LogUtils: add MAX_LOG_DOWNLOAD_SIZE = 47 MB; getFileContentBytesFromLocal stops reading once the limit is reached.
  • LogServiceImpl: checks file size before reading; returns ERROR with 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 returns ERROR with message containing "exceeds maximum download size".
  • ./mvnw spotless:check passes.

Pull Request Notice

Pull Request Notice

@SbloodyS SbloodyS changed the title [Fix-18459][Common] Cap whole-file log download at 47MB to prevent OOM [Improvement-18459][Common] Cap whole-file log download at 47MB to prevent OOM Aug 5, 2026
@SbloodyS SbloodyS added the improvement make more easy to user or prompt friendly label Aug 5, 2026
@SbloodyS SbloodyS added this to the 3.5.0 milestone Aug 5, 2026
@SbloodyS SbloodyS added the first time contributor First-time contributor label Aug 5, 2026

@SbloodyS SbloodyS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a log larger than 47 MB:

  1. LogServiceImpl#getTaskInstanceWholeLogFileBytes returns ERROR.
  2. LogClientDelegate#getWholeLogBytes treats every local error as a reason to call remoteLogClient.getWholeLog(...).
  3. RemoteLogClient calls getFileContentBytesFromRemote, 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.

@xmg333
xmg333 force-pushed the fix/log-download-size-check branch from 2aa5e28 to f7168cf Compare August 5, 2026 05:44
@xmg333
xmg333 requested a review from caishunfeng as a code owner August 5, 2026 05:44
@xmg333

xmg333 commented Aug 5, 2026

Copy link
Copy Markdown
Author

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 new

New RPC: getTaskInstanceLogFileChunk(path, offset, length) reads an 8 MB range via RandomAccessFile. The API loops this RPC and streams
each chunk to the HTTP response via StreamingResponseBody.

Fallback logic (the important part)

The API server runs a chunked loop with an offset counter tracking bytes already written to the response:

streamWholeLog(taskInstance, outputStream):
    if worker not in registry:
        → remote getWholeLogBytes (legacy, unchanged)

    offset = 0
    loop:
        try:
            chunk = localLogClient.getLogChunk(offset, 8MB)
            if chunk.code != SUCCESS:
                if offset == 0:  ← nothing written yet, safe to fallback
                    → remote getWholeLogBytes; return
                else:            ← bytes already streamed, can't restart
                    → throw IOException
            write chunk.bytes; offset += chunk.bytes.length
            if chunk.eof: return
        catch Exception:          ← old worker (method not found), timeout, etc.
            if offset == 0:      ← still safe
                → remote getWholeLogBytes; return
            else:
                → throw IOException

The core invariant: fallback only happens when offset == 0 (nothing written yet). Once bytes have been streamed (offset > 0), there's no
safe way to restart — falling back to getWholeLogBytes would write the whole file from the beginning, duplicating the prefix that's already in
the response. So mid-stream failures throw instead.

Three concrete scenarios:

Scenario offset Behavior
Old worker, first chunk RPC fails (method not found) 0 → fallback to legacy getWholeLogBytes (rolling upgrade safe)
Worker dies mid-stream after writing 24 MB 24 MB → throw IOException (client sees truncated download, not corrupted)
Worker offline from the start 0 → go directly to remote getWholeLogBytes

Legacy path unchanged: getTaskInstanceWholeLogFileBytes and getFileContentBytesFromLocal are untouched from upstream/dev — no silent
truncation, no size cap added.

readFileRange (the new reader) fails explicitly: missing file → IOException, not empty bytes.

Tests cover all three scenarios in LogClientDelegateTest.

@xmg333
xmg333 force-pushed the fix/log-download-size-check branch 7 times, most recently from 93169b9 to b2731e8 Compare August 6, 2026 17:15
Comment on lines +196 to +200
final byte[] bytes = remoteLogClient.getWholeLog(taskInstance);
if (bytes != null && bytes.length > 0) {
outputStream.write(bytes);
}
outputStream.flush();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@xmg333
xmg333 force-pushed the fix/log-download-size-check branch from b2731e8 to 2191ccc Compare August 7, 2026 15:58

@SbloodyS SbloodyS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 uses getFileContentBytesFromLocal() and a ByteArrayOutputStream.
  • A missing worker calls remoteLogClient.getWholeLog(), which uses getFileContentBytesFromRemote() and then reads the downloaded file into another complete byte[].

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.

@xmg333 xmg333 changed the title [Improvement-18459][Common] Cap whole-file log download at 47MB to prevent OOM [Improvement-18459][Common] Stream task log download in bounded chunks to prevent OOM Aug 12, 2026
@xmg333
xmg333 force-pushed the fix/log-download-size-check branch from 2191ccc to 2ecb4eb Compare August 12, 2026 09:52
@xmg333

xmg333 commented Aug 12, 2026

Copy link
Copy Markdown
Author

Thanks for the three points — all have been addressed.

1. "Keep every fallback path memory-bounded"

All three paths are now memory-bounded:

  • Chunk path: Didn't change.

  • Legacy whole-file path: TransporterDecoder now validates bodyLength against maxFrameSize (64 MB by default, configurable through NettyServerConfig / NettyClientConfig) and throws TooLongFrameException before allocating new byte[bodyLength].

    I chose to reject oversized legacy downloads explicitly when this limit is exceeded. writeLocalLegacy detects the condition through isFrameTooLarge(), which walks the full cause chain, and throws IOException("exceeds the maximum legacy download size"). This avoids both silent truncation and falling back to remote storage, which could potentially serve stale data.

    Logs under approximately 47 MB continue to work normally on old workers. The rejection only affects oversized logs on workers that have not yet been upgraded to the chunked RPC.

  • Remote path: RemoteLogClient.streamWholeLog downloads the archive to a local file using streaming I/O and then pipes it to the output in 8 KB chunks. There is no whole-file byte[] allocation.

Additionally, NettyClientHandler.exceptionCaught now completes the pending ResponseFuture immediately with the original cause, tracked through a channel AttributeKey. This ensures that TooLongFrameException propagates to the caller immediately instead of waiting for the RPC timeout.

Without this fix, the original exception cause could be lost and replaced by a RemoteTimeoutException with a null cause, making the isFrameTooLarge() check unreachable.

Test coverage:

  • testStreamWholeLogLegacyTooLargePropagatesExplicitly
  • testStreamWholeLogRemoteFallbackIsChunked
  • TransporterDecoderTest — frame-size rejection
  • NettyClientHandlerTest — exception propagation

2. Do not execute the whole-file fallback twice after an error

streamWholeLog now uses a needFallback flag that is set inside the try/catch. The fallback call (writeLocalLegacy) has been moved outside both blocks.

This ensures that:

  • The fallback is executed at most once.
  • Exceptions from the fallback propagate directly.
  • The fallback exception cannot re-enter the original catch block and trigger a second execution.

This is covered by:

testStreamWholeLogRpcThrowsFallsBackToLegacyThenRemote

The test explicitly verifies:

verify(localLogClient, times(1)).getWholeLog(...)


3. PR title and description

Updated to:

Stream task log download in bounded chunks to prevent OOM

@SbloodyS SbloodyS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Request A stores opaque A.
  2. Request B stores opaque B on the same channel.
  3. A frame/decoder error occurs.
  4. exceptionCaught() only completes opaque B.
  5. 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>
@xmg333
xmg333 force-pushed the fix/log-download-size-check branch from 2ecb4eb to 5ac4d26 Compare August 15, 2026 14:45
@xmg333

xmg333 commented Aug 15, 2026

Copy link
Copy Markdown
Author

Thanks for the review. I spent quite a bit of time debugging the previous OPAQUE_KEY fix. In practice, there were more race conditions around the per-channel opaque set and its lifecycle than I initially expected, especially around channel failures and concurrent request completion. Rather than adding more bookkeeping to handle each case, I ended up replacing that approach entirely.

The new approach is simpler: each ResponseFuture records the channel it was sent on, and FUTURE_TABLE is the only place tracking in-flight requests. When a channel fails or closes, we scan the table and fail the futures belonging to that channel. Futures on other channels are left alone.

The lifecycle is now:

A future stays in FUTURE_TABLE until it completes, and completion always removes it.

This also fixes a few cases that were easy to miss with the previous approach:

  • A deserialization/response-processing error can no longer leave a request stuck in the table.
  • Timeout, interrupt, and write-failure paths don't need separate cleanup logic.
  • There is no longer any channel-attribute initialization or swap handling.

The requested regression test is included: concurrentRequestsSharingChannel_decoderExceptionFailsAll

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 ResponseFutureTest.failAllForChannel_* for channel isolation and already-completed futures.

Other fixes

While testing this, I found a few highly related issues and fixed them as well:

  • TransporterDecoder: maxFrameSize now covers the entire message (header + body), using long arithmetic. The default value is shared by the client and server configs.
  • Log rotation during download no longer silently truncates. If offset > fileLength, the worker reports the LOG_TRUNCATED. If the file disappears during the read, the existing FileNotFoundException is propagated directly.
  • Empty and missing logs are now handled differently. A 0-byte log is a valid empty file, while a missing file fails explicitly. The HTTP response head is written lazily so an early failure can still return JSON.
  • Concurrent downloads of the same archive use striped per-path locks, and the read is bounded by the size observed at the start. If the file is replaced during the transfer, the download fails instead of returning potentially inconsistent data.
  • The download endpoint now has a request-scoped async timeout. This avoids the servlet's default 30s timeout truncating longer downloads without changing the global timeout.

Verification

There are now 80 tests across the 4 modules, including regression tests for the cases above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend first time contributor First-time contributor improvement make more easy to user or prompt friendly test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Improvement] [API] Apiserver OOM when downloading large task log

3 participants