Upload S3 file parts from an in-memory copy to make retries safe by groeneai · Pull Request #108573 · ClickHouse/ClickHouse · GitHub
Skip to content

Upload S3 file parts from an in-memory copy to make retries safe#108573

Open
groeneai wants to merge 3 commits into
ClickHouse:masterfrom
groeneai:fix-s3-backup-readbuffer-canceled-on-retry
Open

Upload S3 file parts from an in-memory copy to make retries safe#108573
groeneai wants to merge 3 commits into
ClickHouse:masterfrom
groeneai:fix-s3-backup-readbuffer-canceled-on-retry

Conversation

@groeneai

@groeneai groeneai commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Fixed a ReadBuffer is canceled. Can't read from it. error that could occur when an upload to S3 (for example a backup) is retried after a transient read error.

Description

copyDataToS3File built the PutObject / UploadPart request body as a StdStreamFromReadBuffer over a LimitSeekableReadBuffer over the live source buffer (e.g. ReadBufferFromS3). This coupled one source reader to every write attempt. The AWS SDK and PocoHTTPClient rewind and re-read the same body across retries (GetContentBody()->clear(); seekg(0)), so a transient source read failure that canceled the reader on one attempt made the rewound re-read trip chassert(!isCanceled()) in ReadBuffer::next() on assertion-enabled builds, or reuse exhausted inner state on release builds.

Each part is now read fully into memory and uploaded from that in-memory copy (ReadBufferFromOwnString wrapped in StdStreamFromReadBuffer), mirroring the WriteBufferFromS3 part-upload path. The body has no failable inner buffer, so a source read failure is contained to the in-memory fill and the upload body can be rewound and resent safely. This decouples read retries from write retries at the root.

Memory use matches the existing WriteBufferFromS3 multipart path (bounded by max_upload_part_size and max_inflight_parts_for_one_file). On the single-part path, the PutObject request (and its in-memory body) is released before the EntityTooLarge / InvalidRequest multipart fallback starts, so the single-part body and the multipart per-part bodies are never resident together.

Reader stack of the old PutObject body and the failure path (for reference):

request.GetContentBody()  ->  StdStreamFromReadBuffer        (copyS3File.cpp)
                              -> StdStreamBufFromReadBuffer
                                 -> LimitSeekableReadBuffer   ([offset, offset+size))
                                    -> source SeekableReadBuffer (e.g. ReadBufferFromS3)
  • WHERE/WHAT thrown: during send, the source read fails in ReadBufferFromS3::nextImpl; ReadBuffer::next() calls cancel() (sets canceled=true) and rethrows, leaving the shared body canceled.
  • WHO retries and HOW: the same body object is reused by our NO_SUCH_KEY loop (copyS3File.cpp), Client::doRequestWithRetryNetworkErrors, and the AWS/Poco internal retry, which rewinds via seekg(0) before each send.

CI report with the crash: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=108573&sha=e1f44a745ddfd3190de1d906418e04211da1eb1e&name_0=PR

Regression test: src/IO/S3/tests/gtest_copy_s3_file_body.cpp (CopyS3FileBody).

@groeneai

Copy link
Copy Markdown
Contributor Author

@groeneai

Copy link
Copy Markdown
Contributor Author

cc @CheSema @kssenii — could you review this? It fixes a ReadBuffer is canceled. Can't read from it. abort on the backup-to-S3 upload retry path: when a read fails and the SDK rewinds the request body via seekg(0), LimitSeekableReadBuffer::seek() repositioned the buffer but never cleared the canceled flag, so the re-read tripped the assertion. The fix resets cancellation on reposition (outer buffer in seek(), nested buffer in nextImpl()), mirroring ReadBufferFromFileDescriptor::rewind() and PeekableReadBuffer::rollbackToCheckpoint().

@CheSema

CheSema commented Jun 26, 2026

Copy link
Copy Markdown
Member

@groeneai that looks trashy, once a read buffer throws an exception it should not be reused and should be destroyed.
Explain why you need cancel cancel? Are you sure that LimitSeekableReadBuffer is exception safe and could be used after it thrown? What is the uppper buffer which want to continue? Why upper buffer does not rebuild the buffers for retry?

@CheSema CheSema self-assigned this Jun 26, 2026
@groeneai

Copy link
Copy Markdown
Contributor Author

Thanks, fair questions. Answering each against the actual paths.

Why reset cancellation at all? The AWS SDK reuses and rewinds the same request body across attempts; it does not rebuild it. PocoHTTPClient rewinds the body via GetContentBody()->clear(); seekg(0) before every send (PocoHTTPClient.cpp:635-636), with the explicit comment that we must do this always "because the same request can be retried also by AWS". seekg(0) lands in LimitSeekableReadBuffer::seek(). So rewind-then-reread is the SDK's contract, not our choice.

Which upper buffer wants to continue, and why not rebuild it? The body is StdStreamFromReadBuffer over StdStreamBufFromReadBuffer over LimitSeekableReadBuffer over the source (ReadBufferFromS3). It is built once in fillPutRequest (copyS3File.cpp:461-466) and handed to the SDK via request.SetBody. Every retry loop sits below that hand-off: our NO_SUCH_KEY loop (copyS3File.cpp:484), our network-error loop in doRequestWithRetryNetworkErrors (Client.cpp:800), and the AWS-internal retry all re-send the same request/body. There is no attempt boundary above the SDK call where we could rebuild. Rebuilding per attempt would mean wrapping our own retry+backoff loop around body construction and re-invoking PutObject, duplicating the SDK's retry logic.

Is LimitSeekableReadBuffer safe to use after it threw? Yes, but only after the seek, because seek tears down the failed-transfer state rather than reusing it. On a failed read, ReadBuffer::next() calls cancel() and rethrows, leaving both the LimitSeekableReadBuffer and its source canceled. The following seek(0) resets the working buffer and sets need_seek; in nextImpl() the inner in->seek() (ReadBufferFromS3::seek) does resetWorkingBuffer(); impl.reset(), i.e. it drops the dead connection and re-opens lazily from the new offset. The only state that survives the reposition and is not already cleared is the canceled tripwire. That is all this resets. The gtest exercises exactly that: fail mid-read, seek(0), clean reread.

This matches the existing precedent that reposition is where canceled is cleared: ReadBufferFromFileDescriptor::rewind() and AsynchronousReadBufferFromFileDescriptor::rewind() (commit 292e58d, "reset buffer cancellation on rewind"), and PeekableReadBuffer::rollbackToCheckpoint(). LimitSeekableReadBuffer is the one buffer in the S3 retry/rewind path that was missing it.

You wrote this machinery, so the design call is yours: (A) keep the localized reset-on-reposition in this PR, consistent with the rewind-reset precedent, or (B) rebuild the body per attempt, which is the larger change above. @CheSema which do you prefer?

@CheSema

CheSema commented Jun 26, 2026

Copy link
Copy Markdown
Member

@groeneai rollbackToCheckpoint should not clear cancel.
Write as an example the stack of the readers, show where and what error is thrown, name who make a call to retry this error and how it does it.

@CheSema

CheSema commented Jun 26, 2026

Copy link
Copy Markdown
Member

@groeneai fillPutRequest does the wrong thing.
Look it connects read buffer with write buffers, that make effective retries impossible: one error on read triggers abortion on write, and error on write triggers abortion on read. Recovery here is to create the reader again and redo the write. Ant it better to do explicitly, do not bury it deep to the unrelated limited reader buffer code.

The correct and effective approach is to read a portion of file in memory and write it as multy part upload piece. That way handles retries better. if read fails, only read is retries, if write fails only write is retries. Even more read could read in advance and write could write pieces in parallel. But this is bigger rework.

@clickhouse-gh

clickhouse-gh Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [2a25fa6]

Summary:


AI Review

Summary

This PR replaces live source-backed S3 upload bodies with in-memory bodies built by createS3UploadBody, so SDK/Poco retries can rewind and resend without touching a canceled source buffer. The follow-up change that scopes PutObjectRequest before multipart fallback also fixes the peak-memory lifetime issue raised in review. After re-checking the current diff, the surrounding retry paths, and all prior discussion, I did not find unresolved correctness, safety, or evidence gaps in the current head.

Final Verdict

Status: ✅ Approve

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Jun 26, 2026
@groeneai

Copy link
Copy Markdown
Contributor Author

@CheSema you are right on both points. Worked example first, then the design.

Reader stack of the PutObject body (top = what the SDK sends from, bottom = source):

request.GetContentBody()  ->  StdStreamFromReadBuffer          (copyS3File.cpp:466)
                              -> StdStreamBufFromReadBuffer
                                 -> LimitSeekableReadBuffer      (copyS3File.cpp:461, [offset, offset+size))
                                    -> source SeekableReadBuffer from create_read_buffer()
                                       (ReadBufferFromS3 over the source object, for backup-to-S3)

Built once in fillPutRequest (copyS3File.cpp:455-466) and handed to the SDK via request.SetBody.

Where and what is thrown: during send, Poco::StreamCopier::copyStream(*request.GetContentBody(), request_body_stream) (PocoHTTPClient.cpp:639) pulls bytes through the streambuf -> LimitSeekableReadBuffer::nextImpl() -> in->next(). The source read fails (transient network error in ReadBufferFromS3::nextImpl). ReadBuffer::next() (ReadBuffer.cpp:103) catches it, calls cancel() setting canceled=true (ReadBuffer.cpp:100), and rethrows. The shared body is now canceled.

Who retries and how: three nested layers, all reusing the same body object:

  1. Our NO_SUCH_KEY loop: processPutRequest (copyS3File.cpp:484) re-calls client_ptr->PutObject(request) with the same request. We control this one.
  2. CH network-error retry: Client::doRequestWithRetryNetworkErrors (Client.cpp:759, loop at :800) re-invokes with the same request on retryable errors.
  3. AWS/Poco internal retry: before every send, PocoHTTPClient rewinds the body, GetContentBody()->clear(); seekg(0) (PocoHTTPClient.cpp:635-636, comment "always ... because the same request can be retried also by AWS"). seekg(0) -> LimitSeekableReadBuffer::seek(0) (LimitSeekableReadBuffer.cpp:118). Pre-fix the seek repositioned but left canceled=true, so the next next() tripped chassert(!isCanceled()) (ReadBuffer.cpp:107).

On rollbackToCheckpoint: dropping it as justification. It does currently clear canceled (PeekableReadBuffer.cpp:105), but that was added for schema detection (try a format, roll back, try another), which is a different scenario from a thrown-and-canceled IO buffer. Not a precedent for this.

On the design: agreed. fillPutRequest couples one reader to every write attempt, and resetting cancellation inside LimitSeekableReadBuffer masks that coupling instead of fixing it. The worked example also shows why "rebuild the reader per attempt" only half-solves it: we can rebuild in layer 1 (our loop), but layers 2 and 3 rewind and reuse the same body with no rebuild hook we control. Reading a part into memory and uploading that buffer removes the coupling at the root: an in-memory body never cancels, so all three layers can rewind and resend safely, and a read failure is isolated to the in-memory fill where it can be retried on its own.

I will implement that decoupling. One scoping question before I start: do you want the full rework now (buffer each part in memory for both single-part and multipart upload, leaving room for read-ahead and parallel part writes), or a minimal first step that just buffers the part in memory to break the read/write coupling, with prefetch and parallelism as a follow-up?

@nikitamikhaylov nikitamikhaylov added the can be tested Allows running workflows for external contributors label Jun 26, 2026
Comment thread src/IO/LimitSeekableReadBuffer.cpp Outdated
@groeneai

Copy link
Copy Markdown
Contributor Author

CI finish ledger — e1f44a7

CI fully finished: Finish Workflow and Mergeable Check pass; 0 test-level FAIL/ERROR in CIDB on this commit. The only red check is the ClickHouse Inc private sync, which is not actionable from the public fork.

Check / test Reason Owner / fixing PR
CH Inc sync private fork mirror CH Inc sync (private, not actionable by us)

Session id: cron:our-pr-ci-monitor:20260626-183000

@groeneai groeneai force-pushed the fix-s3-backup-readbuffer-canceled-on-retry branch from e1f44a7 to b96df79 Compare June 26, 2026 18:58
@groeneai groeneai changed the title Reset ReadBuffer cancellation when rewinding LimitSeekableReadBuffer Upload S3 file parts from an in-memory copy to make retries safe Jun 26, 2026
@groeneai

Copy link
Copy Markdown
Contributor Author

@CheSema done. Reworked along your suggestion: copyDataToS3File now reads each part fully into memory and uploads from that in-memory copy (ReadBufferFromOwnString wrapped in StdStreamFromReadBuffer), the same shape as the WriteBufferFromS3 part-upload path. The body has no failable inner buffer, so a source read failure is contained to the in-memory fill and the request body can be rewound and resent by any retry layer safely. The LimitSeekableReadBuffer cancellation reset is dropped.

This also subsumes the in-buffer-fast-path review comment: the body is a single materialized buffer, there is no source refill to cancel, so the StdStreamBufFromReadBuffer seek fast path is no longer a concern.

I kept it to the minimal decoupling (single-part and multipart). Read-ahead and parallel part writes are a larger change; happy to do them as a follow-up if you want.

Regression test in src/IO/S3/tests/gtest_copy_s3_file_body.cpp.

@groeneai

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. CopyS3FileBody.OldStreamingBodyStaysCanceledAfterSeek shows a failed read leaves the streaming LimitSeekableReadBuffer body canceled after a rewind seek (the exact state that tripped chassert(!isCanceled()) in ReadBuffer::next()).
b Root cause explained? fillPutRequest/makeUploadPartRequest streamed the request body straight from the live source over LimitSeekableReadBuffer. The SDK/PocoHTTPClient rewind+re-read the same body on retry; a source read that failed once canceled the chain, so the rewound re-read hit the assertion (or reused exhausted inner state in release).
c Fix matches root cause? Yes. The body is now an in-memory copy of the part (ReadBufferFromOwnString), so it has no failable inner buffer to cancel; read failures are contained to the in-memory fill. Decouples read retries from write retries at the source, not a guard on the symptom.
d Test intent preserved / new tests added? New gtest gtest_copy_s3_file_body.cpp (3 cases). The reverted approach's tests in gtest_limit_seekable_read_buffer.cpp are restored to their pre-PR state (pre-existing UBSAN tests kept).
e Both directions demonstrated? Yes. OldStreamingBodyStaysCanceledAfterSeek shows the old body stays canceled after seek (would abort); InMemoryBodySurvivesRewindReread shows the new in-memory body survives repeated seekg(0)+reread.
f Fix is general across code paths? Yes. Both upload bodies built from a source reader go through the new in-memory path: single-part (fillPutRequest) and multipart (makeUploadPartRequest) in CopyDataToFileHelper. CopyFileHelper uses server-side CopyObject/UploadPartCopy with no read body, so it is unaffected.
g Fix generalizes across inputs? Yes. The body is in-memory regardless of the source buffer type behind create_read_buffer (ReadBufferFromS3, encrypted, file, etc.); covers small (single-part) and large (multipart) objects.
h Backward compatible? Yes. No settings/format/protocol change; SettingsChangesHistory untouched. Memory use matches the existing WriteBufferFromS3 multipart path (bounded by max_upload_part_size x max_inflight_parts_for_one_file).
i Invariants and contracts preserved? Yes. Part contents and ContentLength are unchanged; each part is read once via readStrict (exact size). No new locks/concurrency; reverts leave LimitSeekableReadBuffer/ReadBuffer at their pre-PR contracts.

Session id: cron:clickhouse-worker-slot-4:20260626-181300

Comment thread src/IO/S3/tests/gtest_copy_s3_file_body.cpp Outdated
When uploading data to S3 (e.g. a backup), copyDataToS3File built the
PutObject / UploadPart request body as a StdStreamFromReadBuffer over a
LimitSeekableReadBuffer over the live source buffer (e.g. ReadBufferFromS3).
This couples one source reader to every write attempt: the AWS SDK and
PocoHTTPClient rewind and re-read the same body across retries
(GetContentBody()->clear(); seekg(0)), so a transient source read failure
that canceled the reader on one attempt made the rewound re-read trip
chassert(!isCanceled()) in ReadBuffer::next() ("ReadBuffer is canceled.
Can't read from it.") on assertion-enabled builds, or reuse exhausted inner
state in release builds.

Read each part fully into memory and upload from that in-memory copy
(ReadBufferFromOwnString wrapped in StdStreamFromReadBuffer), mirroring the
WriteBufferFromS3 part-upload path. The body then has no failable inner
buffer: a source read failure is contained to the in-memory fill and the
upload body can be rewound and resent any number of times. This decouples
read retries from write retries at the root rather than masking the coupling
in the unrelated LimitSeekableReadBuffer.

The request-body construction is a named function createS3UploadBody used by
both fillPutRequest and makeUploadPartRequest, so the regression test drives
the real production path: it builds the body via createS3UploadBody and reads
it, rewinds it and rereads it (as a retrying SDK does) over a source that
cannot be reread, asserting the source is read exactly once. The test fails
if the body regresses to a live LimitSeekableReadBuffer over the source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@groeneai groeneai force-pushed the fix-s3-backup-readbuffer-canceled-on-retry branch from b96df79 to 1766fdb Compare June 26, 2026 20:12
@groeneai

Copy link
Copy Markdown
Contributor Author

The regression test now drives the production request-building path instead of rebuilding the body in the test.

The body construction is factored into createS3UploadBody(create_read_buffer, offset, size) (declared in copyS3File.h), called by both fillPutRequest and makeUploadPartRequest. gtest_copy_s3_file_body.cpp calls that exact function and then reads the returned body, rewinds it and rereads it (clear(); seekg(0), as a retrying SDK does) over a source that refuses to be reread once consumed, asserting the source is read exactly once.

If createS3UploadBody regressed to attaching a LimitSeekableReadBuffer straight over the source, the SDK rewind would re-read the source and the test fails. Verified locally: passes with the in-memory body, fails with a LimitSeekableReadBuffer body ("Source cannot be rewound after it was read").

I went with factoring the body-builder rather than a fake S3::Client: the existing MockS3::Client harness in gtest_writebuffer_s3.cpp aborts on chassert(client_configuration.retryStrategy) (it bypasses the factory that installs the retry strategy), so an end-to-end copyDataToS3File mock is not buildable in-tree without unrelated harness fixes.

@groeneai

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. Unit test CopyS3FileBody.* drives the production createS3UploadBody and reads/rewinds/rereads the body over a source that throws on reread; the old streaming body fails deterministically ("Source cannot be rewound after it was read").
b Root cause explained? The S3 upload body was a StdStreamFromReadBuffer over a LimitSeekableReadBuffer over the live source. The SDK rewinds and rereads the same body across retries (clear(); seekg(0)), so a source read failure that cancel()ed the reader made the rewound reread trip chassert(!isCanceled()) in ReadBuffer::next(). The fix reads each part into memory so the body has no failable inner source buffer.
c Fix matches root cause? Yes. Reading the part into an owned string removes the live-source reader from the body, so retries rewind a memory buffer with no cancel/reread coupling. Not a band-aid (no widened assertion, no tag).
d Test intent preserved / new tests added? New regression test drives the real request-body builder createS3UploadBody (used by both fillPutRequest and makeUploadPartRequest) and would fail if it regressed to a live LimitSeekableReadBuffer body. This commit reworks the test per the bot CR so it exercises the production path, not a hand-built copy.
e Both directions demonstrated? Yes. With the in-memory body: 2/2 PASS. With createS3UploadBody temporarily reverted to a LimitSeekableReadBuffer body: 2/2 FAIL. Pre-existing LimitSeekableReadBuffer.* gtests still pass.
f Fix is general across code paths? Yes. Both upload paths (single-part fillPutRequest, multipart makeUploadPartRequest) build the body through the one factored createS3UploadBody. CopyFileHelper (server-side CopyObject / UploadPartCopy) has no read body and is unaffected.
g Fix generalizes across inputs? N/A for datatypes/wrappers (this is an IO body-construction fix, not a typed-value code path). Covered both offset=0 and offset>0 part cases in tests.
h Backward compatible? Yes. No setting, on-disk, wire, or protocol change. Upload semantics unchanged; only how the request body is materialized.
i Invariants and contracts preserved? Yes. SetContentLength(size) still matches the body length; the body still yields exactly the part bytes. The in-memory body upholds the SDK's requirement that the body be re-readable across retries (which the live-source body violated). Per-part memory matches the existing WriteBufferFromS3 path (bounded by max_upload_part_size x max_inflight_parts_for_one_file).

Session id: cron:clickhouse-worker-slot-1:20260626-193700

@groeneai

groeneai commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

CI finish ledger - 1766fdb

CI fully finished (Finish Workflow + Style check passed; no checks queued or in progress; >20 min since the last check completed at 2026-06-27T00:25Z). This supersedes the earlier ledger for head e1f44a7. Every failure below has an owner; none is caused by this PR's diff (S3 upload-body materialization in copyS3File.cpp).

Check / test Reason Owner / fixing PR
BuzzHouse (arm_asan_ubsan) / Logical error: Distributed task iterator is not initialized (STID 4662-4342) crash (chronic trunk bug; 30d = 25+ unrelated PRs + master; no link to S3 upload code) #107107 (ours, open)
Docker keeper image / clickhouse-keeper:head-distroless-amd64 infra (transient S3 503 fetching the keeper .deb during the distroless build; 21 hits / 15 PRs + 2 master in 30d) a fix task is created (investigating the transient-artifact-fetch retry at full effort; fixing PR link to follow here)
Stress test (arm_tsan) hung-check shutdown deadlock, NOT infra: the job.log shows the hung check found 10 stuck 01730_distributed_group_by_no_merge_order_by_long distributed-aggregation legs (cancelled but spinning in ThreadPoolImpl::wait() under PipelineExecutor); the server was then unresponsive to shutdown, lldb could not attach to the replica pid, and the 3h job wall SIGTERM'd it - the Code 209 socket timeout is the last symptom, not the cause. Chronic GlobalThreadPool-shutdown family, no link to S3 upload code. #101680 / #105905 (ours, open)
CH Inc sync private sync CH Inc sync (private, not actionable)

Session id: cron:our-pr-ci-monitor:20260627-003000

@groeneai

Copy link
Copy Markdown
Contributor Author

Fixing PR for the Docker keeper image transient-S3 503 failure: #108675 (adds wget_with_retry to the Keeper Dockerfiles, mirroring the server Dockerfiles). The failure here was the clickhouse-keeper .deb getting a 503 from clickhouse-builds.s3.amazonaws.com and the single-shot wget ... || exit 1 aborting the build — not caused by this PR's changes.

pull Bot pushed a commit to Spencerx/ClickHouse that referenced this pull request Jun 28, 2026
The `Docker keeper image` job intermittently fails while fetching the
prebuilt packages from `clickhouse-builds.s3.amazonaws.com`. Example
from PR ClickHouse#108573 (`docker/keeper/Dockerfile.distroless`, amd64): the
`clickhouse-common-static` deb downloads fine, then the
`clickhouse-keeper` deb gets

    HTTP request sent, awaiting response... 503 Service Unavailable
    ERROR 503: Service Unavailable.

and the single-shot `wget ... || exit 1` kills the whole build, so the
downstream `docker library image test` then reports `image does not
exist!`.

The server Dockerfiles already wrap `wget` with a retry helper:
`Dockerfile.alpine` since ClickHouse#100380 (tgz path) and `Dockerfile.ubuntu` /
`Dockerfile.distroless` since ClickHouse#105139 (deb path). The Keeper
Dockerfiles were left with the brittle first-attempt `wget`, so a
single 500/503 from S3 fails the build.

This ports the same `wget_with_retry` wrapper to both Keeper files:
- `docker/keeper/Dockerfile.distroless` deb path (`DIRECT_DOWNLOAD_URLS`),
  mirroring `docker/server/Dockerfile.distroless`.
- `docker/keeper/Dockerfile` tgz path (`DIRECT_DOWNLOAD_URLS` and the
  repository fallback), mirroring `docker/server/Dockerfile.alpine`.

`WGET_RETRIES` (default 5) and `WGET_RETRY_DELAY` (default 1s) match the
server Dockerfiles and stay overridable via `--build-arg`. A persistent
error still fails the build after the retries are exhausted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@CheSema

CheSema commented Jun 30, 2026

Copy link
Copy Markdown
Member

@groeneai what is the size of the captured data by createS3UploadBody?
Is it limited somehow?

@groeneai

Copy link
Copy Markdown
Contributor Author

@CheSema It captures exactly one upload part (size bytes), never the whole object, and it is bounded on both paths:

  • Single-part (fillPutRequest): performCopy only takes this path when size <= max_single_part_upload_size (copyS3File.cpp:439), so the PutObject body is capped at max_single_part_upload_size (default 32 MiB).
  • Multipart (makeUploadPartRequest): part_size is capped at max_upload_part_size in calculatePartSize (copyS3File.cpp:349-353, default 5 GiB), and the number of parts held in memory at once is capped at max_inflight_parts_for_one_file (the TaskTracker limit, copyS3File.cpp:274, default 20).

So peak memory is at most max_upload_part_size * max_inflight_parts_for_one_file, the same bound the existing WriteBufferFromS3 write path already holds (its PartData::createAwsBuffer reads each part into memory the same way). With defaults a part is 16 MiB (min) and only grows for very large objects (about 105 MiB per part at a 1 TiB object), so peak is roughly 320 MiB up to ~2 GiB.

CheSema
CheSema previously approved these changes Jul 3, 2026
@CheSema CheSema enabled auto-merge July 3, 2026 14:58
Comment thread src/IO/S3/copyS3File.cpp
auto-merge was automatically disabled July 3, 2026 16:37

Head branch was pushed to by a user without write access

@CheSema

CheSema commented Jul 3, 2026

Copy link
Copy Markdown
Member

@groeneai what are you doing with it?
f400865

@groeneai groeneai force-pushed the fix-s3-backup-readbuffer-canceled-on-retry branch from f400865 to 63cf7de Compare July 3, 2026 16:40
@CheSema CheSema dismissed their stale review July 3, 2026 16:42

Do not like previous patch

Comment thread src/IO/S3/copyS3File.cpp
performSinglepartUpload built the PutObject request (now carrying a full
size-byte in-memory body) and, on an EntityTooLarge / InvalidRequest error,
called performMultipartUpload() from inside processPutRequest while that
request was still in scope. The single-part in-memory copy stayed resident
while multipart began allocating its per-part bodies, so the fallback path
could hold single_part_body + multipart_inflight_parts at once and use about
double the peak memory. The previous streaming body did not, because it kept
only a small window.

processPutRequest now returns whether a multipart fallback is needed instead
of performing it, and performSinglepartUpload destroys the request (and its
body) before calling performMultipartUpload(). performMultipartUpload uses
only the member offset and size, not the request, so releasing it first is
safe. Peak memory on the fallback path is back to just the multipart bound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@groeneai

groeneai commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@CheSema That link (f400865) was the outer materialization read-retry loop the bot asked for. You are right that it is the wrong layer (read retries are owned by ReadBufferFromS3), so I reverted it; the branch is back to the in-memory-body change alone plus one follow-up (2a25fa6) that releases the single-part PutObject body before the EntityTooLarge/InvalidRequest multipart fallback, so the two bodies are never resident together. Current head is 2a25fa6; f400865 is gone.

@groeneai

groeneai commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (click to expand) - follow-up commit 2a25fa6 (multipart-fallback memory)
# Question Answer
a Deterministic repro? The concern is a peak-memory doubling, not a crash. The trigger is deterministic: a single-part upload that gets EntityTooLarge / InvalidRequest (raised max_single_part_upload_size, or an S3-compatible backend that rejects the single-part size) falls back to multipart. Before the fix the single-part in-memory body stayed resident during the fallback; provable by reading performSinglepartUpload / processPutRequest.
b Root cause explained? performSinglepartUpload held S3::PutObjectRequest request (now carrying a full size-byte in-memory body via createS3UploadBody) in scope while processPutRequest called performMultipartUpload() inline on EntityTooLarge/InvalidRequest. So single_part_body + multipart_inflight_parts were resident at once. The old streaming LimitSeekableReadBuffer body held only a small window, so this doubling is new to the in-memory-body rework.
c Fix matches root cause? Yes. processPutRequest now returns whether a multipart fallback is needed instead of doing it; performSinglepartUpload scopes request in a block so it (and its body) is destroyed before performMultipartUpload(). Peak on the fallback path is back to the multipart bound alone.
d Test intent preserved / new tests? Existing CopyS3FileBody (2) + LimitSeekableReadBuffer (3) gtests still pass unchanged. No test weakened. See (e) on why a new unit test is not feasible here.
e Demonstrated both directions? The guarantee is a memory-lifetime ordering (single-part body freed before the multipart allocation), enforced structurally by RAII scoping. processPutRequest is a private method of an anonymous-namespace helper, unreachable from a unit test; an end-to-end EntityTooLarge->multipart test needs a mock S3::Client, and the in-tree MockS3 harness (gtest_writebuffer_s3.cpp) is broken (chassert(client_configuration.retryStrategy) fires because MockS3::Client bypasses the factory). The ordering is verifiable by code review; the 5 existing gtests confirm no behavioral regression.
f Fix general, not narrow? Checked all body-carrying paths. SetBody(createS3UploadBody(...)) exists only at the two CopyDataToFileHelper sites (fillPutRequest single-part, makeUploadPartRequest multipart). CopyFileHelper has the same singlepart->multipart fallback shape but uses a bodyless CopyObjectRequest (server-side copy, SetCopySource, no SetBody), so it holds nothing large during its fallback. The Azure sibling is not touched by this PR. The single resident-body-during-fallback site is the one fixed.
g Generalizes across inputs? The fix is input-independent: any size on the single-part path frees the body before fallback. The bound is unchanged for the success and NO_SUCH_KEY-retry paths (no fallback).
h Backward compatible? Yes. No setting, format, or protocol change. Upload behavior (single-part success, NO_SUCH_KEY retry, multipart fallback) is byte-identical; only the request object's lifetime is shortened.
i Invariants preserved? Control flow of processPutRequest is unchanged: success -> return false (was break), fallback error -> return true (was performMultipartUpload(); break), NO_SUCH_KEY -> continue, other -> throw. performMultipartUpload() reads only members offset/size, never request, so moving it after the request's scope is safe.

Session id: cron:clickhouse-worker-slot-2:20260703-170800

@clickhouse-gh

clickhouse-gh Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 85.60% 85.60% +0.00%
Functions 92.60% 92.70% +0.10%
Branches 77.80% 77.80% +0.00%

Changed lines: Changed C/C++ lines covered: 73/91 (80.22%) · Uncovered code

Full report · Diff report

@groeneai

groeneai commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

CI finish ledger — 2a25fa6

CI is fully finished on this head (Finish Workflow: pass, Mergeable Check: pass). No test failed and no check errored.

Check / test Reason Owner / fixing PR
(no failures)
CH Inc sync CH Inc sync (private, not actionable)

The multipart-fallback OOM CR and the f400865 read-retry question were both addressed on this head (copyS3File.cpp scopes the single-part request/body so it is released before the multipart fallback). Ready for re-review.

Session id: cron:our-pr-ci-monitor:20260703-220000

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

Labels

can be tested Allows running workflows for external contributors pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants