Upload S3 file parts from an in-memory copy to make retries safe#108573
Upload S3 file parts from an in-memory copy to make retries safe#108573groeneai wants to merge 3 commits into
Conversation
|
cc @CheSema @kssenii — could you review this? It fixes a |
|
@groeneai that looks trashy, once a read buffer throws an exception it should not be reused and should be destroyed. |
|
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. Which upper buffer wants to continue, and why not rebuild it? The body is Is This matches the existing precedent that reposition is where 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? |
|
@groeneai rollbackToCheckpoint should not clear cancel. |
|
@groeneai fillPutRequest does the wrong thing. 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. |
|
Workflow [PR], commit [2a25fa6] Summary: ✅
AI ReviewSummaryThis PR replaces live source-backed S3 upload bodies with in-memory bodies built by Final VerdictStatus: ✅ Approve |
|
@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): Built once in Where and what is thrown: during send, Who retries and how: three nested layers, all reusing the same body object:
On rollbackToCheckpoint: dropping it as justification. It does currently clear On the design: agreed. 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? |
CI finish ledger — e1f44a7CI 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.
Session id: cron:our-pr-ci-monitor:20260626-183000 |
e1f44a7 to
b96df79
Compare
|
@CheSema done. Reworked along your suggestion: 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 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 |
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-worker-slot-4:20260626-181300 |
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>
b96df79 to
1766fdb
Compare
|
The regression test now drives the production request-building path instead of rebuilding the body in the test. The body construction is factored into If I went with factoring the body-builder rather than a fake |
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-worker-slot-1:20260626-193700 |
CI finish ledger - 1766fdbCI 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).
Session id: cron:our-pr-ci-monitor:20260627-003000 |
|
Fixing PR for the |
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>
|
@groeneai what is the size of the captured data by |
|
@CheSema It captures exactly one upload part (
So peak memory is at most |
Head branch was pushed to by a user without write access
f400865 to
63cf7de
Compare
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>
|
@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 |
Pre-PR validation gate (click to expand) - follow-up commit 2a25fa6 (multipart-fallback memory)
Session id: cron:clickhouse-worker-slot-2:20260703-170800 |
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 73/91 (80.22%) · Uncovered code |
CI finish ledger — 2a25fa6CI is fully finished on this head (Finish Workflow: pass, Mergeable Check: pass). No test failed and no check errored. The multipart-fallback OOM CR and the f400865 read-retry question were both addressed on this head ( Session id: cron:our-pr-ci-monitor:20260703-220000 |

Changelog category (leave one):
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
copyDataToS3Filebuilt thePutObject/UploadPartrequest body as aStdStreamFromReadBufferover aLimitSeekableReadBufferover the live source buffer (e.g.ReadBufferFromS3). This coupled one source reader to every write attempt. The AWS SDK andPocoHTTPClientrewind 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 tripchassert(!isCanceled())inReadBuffer::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 (
ReadBufferFromOwnStringwrapped inStdStreamFromReadBuffer), mirroring theWriteBufferFromS3part-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
WriteBufferFromS3multipart path (bounded bymax_upload_part_sizeandmax_inflight_parts_for_one_file). On the single-part path, thePutObjectrequest (and its in-memory body) is released before theEntityTooLarge/InvalidRequestmultipart 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):
ReadBufferFromS3::nextImpl;ReadBuffer::next()callscancel()(setscanceled=true) and rethrows, leaving the shared body canceled.NO_SUCH_KEYloop (copyS3File.cpp),Client::doRequestWithRetryNetworkErrors, and the AWS/Poco internal retry, which rewinds viaseekg(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).