Skip to content

[fix](s3) Keep the response stream usable when an error body overflows the read buffer - #66557

Open
liaoxin01 wants to merge 1 commit into
apache:masterfrom
liaoxin01:fix-s3-response-stream-overflow
Open

[fix](s3) Keep the response stream usable when an error body overflows the read buffer#66557
liaoxin01 wants to merge 1 commit into
apache:masterfrom
liaoxin01:fix-s3-response-stream-overflow

Conversation

@liaoxin01

@liaoxin01 liaoxin01 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Problem Summary:

Reading from object storage fails from time to time with

[INTERNAL_ERROR]failed to read from <key>:  Failed to flush response stream (eof: 0, bad: 1) code=-1 type=1, request_id=failed to read

and succeeds when the same statement is run again. It has been hit by queries
reading a rowset, by compaction, by an outfile export and by the download of an
inverted index, always on an object storage that was answering 429 or 503 at
that moment.

S3ObjStorageClient::get_object() hands the buffer of the caller to the SDK as
the response stream of the request, sized exactly like the requested range. The
SDK writes the body of every response into that stream, the body of an error
response included. The XML document of a 429 SlowDown is a few hundred bytes,
so a small ranged read cannot hold it - the read of the footer of a packed file
asks for 12 bytes. PreallocatedStreamBuf does not implement overflow(), so
the stream turns bad, the write callback of curl reports a short write and curl
aborts the transfer with CURLE_WRITE_ERROR.

The status code of the response is lost from there on:
CurlHttpClient::MakeRequest() reads CURLINFO_RESPONSE_CODE only when curl
succeeded, so the code stays at REQUEST_NOT_MADE (-1), and the flush check at
the end of the same function replaces the retryable NETWORK_CONNECTION
classification with INTERNAL_FAILURE (1). S3CustomRetryStrategy::ShouldRetry()
declines to retry an error classified that way, and so does
S3FileReader::read_at_impl(), which retries on 429 alone. A throttling error
the server asked us to retry cancels the statement of the user instead, which is
why running it again works.

This also means the error carries no evidence of what really happened: the code
of the response, the exception name and the request id of the object storage are
all gone by the time the message is built.

The fix lets the response stream grow: the body is written into the buffer of the
caller as long as it fits, which is the case for every successful ranged read and
keeps that path free of copies, and the remainder spills into a buffer of the
stream itself, truncated at 1MB because only error documents are expected to
overflow. The stream never turns bad, so curl completes the transfer, the SDK
records the real status code and parses the error out of the body, and both the
retry of the SDK and the retry of S3FileReader on 429 work again.

A server or a proxy answering a ranged read with the whole object overflows the
buffer as well. Such a read is still rejected, by the length check that follows
the request, and now with a message that says so.

Two misleading messages are fixed along the way:

  • request_id=failed to read is not a request id of the object storage. It is
    the string S3FileReader appended behind the empty request id of a failure
    raised by the client itself. The append is dropped and an empty request id is
    printed as <empty>.
  • The message of a failed read named neither the bucket nor the offset, leaving
    failed to read from : in the log whenever the key was empty.

Release note

None

Check List (For Author)

  • Test: Unit Test
    • be/test/io/fs/s3_response_stream_test.cpp covers a body that fits, an
      error body overflowing in one write, across writes and character by
      character, the truncation of an oversized body, the rewind the SDK does
      before parsing an error, and an empty body.
    • Not tested end to end against a rate limited object storage.
  • Behavior changed: No
  • Does this need documentation: No

Copilot AI lite review requested due to automatic review settings August 6, 2026 17:43
@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

Copilot AI left a comment

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…s the read buffer

### What problem does this PR solve?

Problem Summary:

Reading from object storage fails from time to time with

```
[INTERNAL_ERROR]failed to read from <key>:  Failed to flush response stream (eof: 0, bad: 1) code=-1 type=1, request_id=failed to read
```

and succeeds when the same statement is run again. It has been hit by queries
reading a rowset, by compaction, by an outfile export and by the download of an
inverted index, always on an object storage that was answering `429` or `503` at
that moment.

`S3ObjStorageClient::get_object()` hands the buffer of the caller to the SDK as
the response stream of the request, sized exactly like the requested range. The
SDK writes the body of every response into that stream, the body of an error
response included. The XML document of a `429 SlowDown` is a few hundred bytes,
so a small ranged read cannot hold it - the read of the footer of a packed file
asks for 12 bytes. `PreallocatedStreamBuf` does not implement `overflow()`, so
the stream turns bad, the write callback of curl reports a short write and curl
aborts the transfer with `CURLE_WRITE_ERROR`.

The status code of the response is lost from there on:
`CurlHttpClient::MakeRequest()` reads `CURLINFO_RESPONSE_CODE` only when curl
succeeded, so the code stays at `REQUEST_NOT_MADE` (-1), and the flush check at
the end of the same function replaces the retryable `NETWORK_CONNECTION`
classification with `INTERNAL_FAILURE` (1). `S3CustomRetryStrategy::ShouldRetry()`
declines to retry an error classified that way, and so does
`S3FileReader::read_at_impl()`, which retries on `429` alone. A throttling error
the server asked us to retry cancels the statement of the user instead, which is
why running it again works.

This also means the error carries no evidence of what really happened: the code
of the response, the exception name and the request id of the object storage are
all gone by the time the message is built.

The fix lets the response stream grow: the body is written into the buffer of the
caller as long as it fits, which is the case for every successful ranged read and
keeps that path free of copies, and the remainder spills into a buffer of the
stream itself, truncated at 1MB because only error documents are expected to
overflow. The stream never turns bad, so curl completes the transfer, the SDK
records the real status code and parses the error out of the body, and both the
retry of the SDK and the retry of `S3FileReader` on `429` work again.

A server or a proxy answering a ranged read with the whole object overflows the
buffer as well. Such a read is still rejected, by the length check that follows
the request, and now with a message that says so.

Two misleading messages are fixed along the way:

- `request_id=failed to read` is not a request id of the object storage. It is
  the string `S3FileReader` appended behind the empty request id of a failure
  raised by the client itself. The append is dropped and an empty request id is
  printed as `<empty>`.
- The message of a failed read named neither the bucket nor the offset, leaving
  `failed to read from :` in the log whenever the key was empty.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - `be/test/io/fs/s3_response_stream_test.cpp` covers a body that fits, an
      error body overflowing in one write, across writes and character by
      character, the truncation of an oversized body, the rewind the SDK does
      before parsing an error, and an empty body.
    - Not tested end to end against a rate limited object storage.
- Behavior changed: No
- Does this need documentation: No
@liaoxin01
liaoxin01 force-pushed the fix-s3-response-stream-overflow branch from aa1dfd1 to 2c2f27e Compare August 7, 2026 00:47
@liaoxin01

Copy link
Copy Markdown
Contributor Author

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 29028 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit 2c2f27e782e95bf13d6a8effef6779c3f5c8ac6d, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17699	4090	4080	4080
q2	2026	339	193	193
q3	10293	1350	788	788
q4	4682	472	336	336
q5	7532	841	564	564
q6	184	175	139	139
q7	762	790	595	595
q8	9343	1665	1743	1665
q9	5273	4067	4052	4052
q10	6706	1640	1336	1336
q11	518	349	324	324
q12	721	592	462	462
q13	18062	3259	2743	2743
q14	265	261	236	236
q15	q16	740	736	655	655
q17	1013	905	966	905
q18	6552	5598	5556	5556
q19	1303	1291	1095	1095
q20	790	694	592	592
q21	5873	2585	2417	2417
q22	436	356	295	295
Total cold run time: 100773 ms
Total hot run time: 29028 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	4363	4248	4254	4248
q2	282	325	213	213
q3	4513	4963	4463	4463
q4	2174	2226	1432	1432
q5	4220	4100	4103	4100
q6	229	173	128	128
q7	1696	1603	1425	1425
q8	2860	2331	2087	2087
q9	7369	7453	7563	7453
q10	4270	4261	3851	3851
q11	568	413	398	398
q12	720	723	506	506
q13	3144	3503	2850	2850
q14	297	301	300	300
q15	q16	704	725	632	632
q17	1329	1298	1325	1298
q18	12162	11042	11945	11042
q19	1180	1167	1237	1167
q20	2252	2223	1934	1934
q21	5665	4919	4935	4919
q22	559	497	461	461
Total cold run time: 60556 ms
Total hot run time: 54907 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 166759 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit 2c2f27e782e95bf13d6a8effef6779c3f5c8ac6d, data reload: false

query5	4314	586	446	446
query6	464	214	203	203
query7	4853	608	340	340
query8	318	192	146	146
query9	8782	4003	4014	4003
query10	469	372	310	310
query11	5824	2155	1989	1989
query12	163	98	94	94
query13	1270	620	436	436
query14	6063	4219	3927	3927
query14_1	3759	3750	3735	3735
query15	202	201	181	181
query16	990	483	431	431
query17	885	662	529	529
query18	2417	455	336	336
query19	198	184	142	142
query20	101	105	102	102
query21	233	155	136	136
query22	12917	13089	12860	12860
query23	15776	15012	14508	14508
query23_1	15344	15019	15148	15019
query24	8123	1760	1218	1218
query24_1	1240	1232	1239	1232
query25	523	418	339	339
query26	1305	380	217	217
query27	2573	557	373	373
query28	4530	2057	2026	2026
query29	1054	595	462	462
query30	343	260	218	218
query31	1165	1102	1035	1035
query32	121	60	54	54
query33	529	306	244	244
query34	1201	1111	621	621
query35	724	732	626	626
query36	755	777	701	701
query37	163	114	93	93
query38	1828	1782	1702	1702
query39	872	821	825	821
query39_1	785	778	794	778
query40	250	180	147	147
query41	68	66	65	65
query42	93	94	93	93
query43	326	315	283	283
query44	1453	764	773	764
query45	184	177	181	177
query46	1038	1224	737	737
query47	1531	1565	1463	1463
query48	409	396	314	314
query49	599	401	298	298
query50	1065	416	346	346
query51	10445	10708	10399	10399
query52	88	91	82	82
query53	263	281	203	203
query54	295	245	231	231
query55	75	72	67	67
query56	316	322	298	298
query57	1042	1043	934	934
query58	266	262	252	252
query59	1502	1583	1402	1402
query60	313	278	260	260
query61	145	149	142	142
query62	391	332	266	266
query63	233	192	195	192
query64	2853	1015	823	823
query65	3831	3830	3800	3800
query66	1849	475	346	346
query67	28013	28144	28006	28006
query68	3338	1506	1041	1041
query69	390	300	258	258
query70	873	797	802	797
query71	370	353	355	353
query72	3013	2629	2300	2300
query73	812	822	432	432
query74	4635	4495	4303	4303
query75	2331	2343	1985	1985
query76	2347	1142	760	760
query77	335	360	271	271
query78	11294	11160	10617	10617
query79	1211	1198	752	752
query80	605	559	470	470
query81	454	327	286	286
query82	287	177	133	133
query83	405	340	300	300
query84	330	180	134	134
query85	904	600	544	544
query86	285	230	220	220
query87	1962	1964	1822	1822
query88	3762	2839	2785	2785
query89	366	328	282	282
query90	2095	204	194	194
query91	204	186	171	171
query92	62	63	59	59
query93	1482	1635	993	993
query94	530	326	316	316
query95	797	523	470	470
query96	995	835	355	355
query97	2491	2442	2373	2373
query98	196	184	179	179
query99	715	724	641	641
Total cold run time: 252283 ms
Total hot run time: 166759 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 23.86 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit 2c2f27e782e95bf13d6a8effef6779c3f5c8ac6d, data reload: false

query1	0.00	0.00	0.00
query2	0.09	0.04	0.04
query3	0.25	0.13	0.14
query4	1.61	0.14	0.14
query5	0.23	0.22	0.23
query6	1.16	0.81	0.83
query7	0.04	0.00	0.00
query8	0.06	0.04	0.04
query9	0.36	0.30	0.30
query10	0.55	0.56	0.56
query11	0.19	0.13	0.14
query12	0.18	0.14	0.14
query13	0.46	0.47	0.45
query14	1.01	0.99	0.99
query15	0.60	0.58	0.60
query16	0.32	0.31	0.30
query17	1.07	1.08	1.11
query18	0.21	0.20	0.20
query19	2.08	1.92	1.92
query20	0.02	0.01	0.01
query21	15.42	0.24	0.12
query22	4.70	0.05	0.04
query23	16.15	0.32	0.12
query24	3.06	0.45	0.31
query25	0.12	0.04	0.03
query26	0.74	0.21	0.16
query27	0.04	0.04	0.04
query28	3.53	0.71	0.37
query29	12.49	4.09	3.28
query30	0.27	0.16	0.15
query31	2.77	0.56	0.32
query32	3.23	0.58	0.48
query33	3.09	3.35	3.17
query34	15.76	3.95	3.25
query35	3.22	3.20	3.21
query36	0.58	0.44	0.43
query37	0.09	0.06	0.07
query38	0.05	0.04	0.04
query39	0.04	0.03	0.03
query40	0.18	0.15	0.13
query41	0.10	0.03	0.03
query42	0.04	0.03	0.03
query43	0.04	0.04	0.03
Total cold run time: 96.2 s
Total hot run time: 23.86 s

@hello-stephen

Copy link
Copy Markdown
Contributor

BE UT Coverage Report

Increment line coverage 78.48% (62/79) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 59.82% (26107/43640)
Line Coverage 44.13% (264739/599875)
Region Coverage 39.89% (210892/528688)
Branch Coverage 41.30% (96573/233814)

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 81.01% (64/79) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 75.86% (32280/42553)
Line Coverage 60.70% (361667/595814)
Region Coverage 57.27% (303767/530375)
Branch Coverage 58.76% (137394/233806)

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants