Skip to content

[Improvement-18459][Common] Cap whole-file log download at 47MB to prevent OOM - #18463

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

[Improvement-18459][Common] Cap whole-file log download at 47MB to prevent OOM#18463
xmg333 wants to merge 2 commits 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.

getFileContentBytesFromLocal reads entire files into memory with no limit,
causing OOM when downloading large task logs. This PR adds a chunked RPC
(getTaskInstanceLogFileChunk) that reads 8MB at a time via RandomAccessFile,
and streams the result to the HTTP response via StreamingResponseBody.

Architecture: try chunk -> catch -> fallback whole
- New worker: chunked RPC succeeds, any size log is downloadable
- Old worker: chunk RPC fails, falls back to legacy getWholeLogBytes
- Mid-stream failure (bytes already written): throws IOException (no corruption)
- First-chunk failure (nothing written): safe fallback to remote legacy

The legacy getTaskInstanceWholeLogFileBytes is unchanged (backward compatible).
readFileRange fails explicitly (IOException) on missing files rather than
silently returning empty bytes.

Co-Authored-By: Claude <noreply@anthropic.com>
@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.

…tion tests

Address code review feedback:
- Split streamLogBytes into checkDownloadLogAuth (sync, before response committed)
  + streamLogBytes(TaskInstance, OutputStream) — auth failures now return JSON
  error via @ApiException instead of a fake .log file
- Add testStreamWholeLogRpcThrowsFallsBackToRemote: old worker (method-not-found)
  triggers RPC exception, safely falls back to remote (offset==0)
- Add testStreamWholeLogRpcThrowsMidStreamThrows: mid-stream RPC exception
  throws IOException (offset>0, no corruption)
- Fix getBytes() to use StandardCharsets.UTF_8

Co-Authored-By: Claude <noreply@anthropic.com>
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

2 participants