fix: stream blob bodies with per-chunk deadlines#4149
Conversation
There was a problem hiding this comment.
Pull request overview
This PR changes blob upload/download streaming to use http.ResponseController to extend connection read/write deadlines during transfers, so the configured http.Server ReadTimeout/WriteTimeout behave like idle timeouts for long-running blob I/O rather than absolute end-to-end caps.
Changes:
- Add
statusWriter.Unwrap()sohttp.ResponseControllercan reach the underlyingResponseWriter/connection through the session-logging wrapper. - Update blob GET (including multipart ranges) and blob upload handlers to stream with per-transfer deadline extensions derived from
readTimeout/writeTimeout. - Extend
TestWriteDataFromReaderto cover deadline-support, non-support, and zero-timeout behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| pkg/api/session.go | Adds Unwrap() to the session logging ResponseWriter wrapper to enable http.ResponseController deadline support through middleware. |
| pkg/api/routes.go | Implements deadline-aware streaming helpers and wires them into blob serve/upload paths using configured HTTP timeouts. |
| pkg/api/routes_test.go | Adds unit tests validating streaming behavior with/without deadline support and when timeout is zero. |
| pkg/api/config/config.go | Updates timeout field comments to document idle-timeout behavior for blob transfers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
0e8771a to
ebfdc46
Compare
|
Rebased onto main (resolved the conflict with the webhook-events change in the blob upload handlers) and addressed the review feedback. On the deadline granularity: switched the write path from a 10MiB On |
ebfdc46 to
ef86423
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4149 +/- ##
=======================================
Coverage 91.67% 91.68%
=======================================
Files 203 203
Lines 29879 29904 +25
=======================================
+ Hits 27391 27416 +25
+ Misses 1604 1603 -1
- Partials 884 885 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| if _, err := io.Copy(newStreamDeadlineWriter(response, writeTimeout, logger), pipeReader); err != nil { | ||
| logger.Error().Err(err).Msg("failed to copy multipart range data into http response") |
|
As far as I remember the timeout is supposed to be a fix in case an attacker deliberately sends slow traffic to hold goroutines indefinitely. We're supposed to cut loose those clients as far as I understand. |
|
In writeMultipartRanges, use io.CopyN(newStreamDeadlineWriter(...), pipeReader, contentLength) instead of plain io.Copy |
|
Also, possible loss of sendfile / optimized copy behavior on filesystem-backed blob downloads, at least for local storage based deployments. |
|
On the security intent (@andaaron): the #3984 timeout is there to cut clients that hold a connection with slow traffic. A per-read/write deadline still cuts a client that stalls (no bytes within the window), which covers the usual slow-loris case, but a client that trickles just enough to keep resetting it would survive, so it does not bound total connection lifetime the way a fixed deadline does. If bounding the worst case is the goal, the options are roughly:
Which model would you like? I'll adapt accordingly. On sendfile (@rchincha): the local driver returns an On the multipart copy (@rchincha): switching it back to |
|
| If bounding the worst case is the goal, the options are roughly: Idle deadline only: fixes the large-transfer failure and cuts stalled clients, but a steady trickle lives on. Unfortunately, none of these are great options! |
|
After thinking about this some more, this is sounding more like a keepalive mechanism for long running connections and re-purposing the read/write timeouts for this. |
|
The keepalive framing fits the problem better than a request deadline. TCP keepalive works at the transport layer: it probes idle connections and drops peers that stop responding (dead or abandoned), while a connection that is actively transferring, even slowly, stays up. That is the distinction the absolute read/write deadline cannot make, since it fires on wall-clock regardless of whether bytes are still moving. That would mean:
The case this does not cover is a client that stays responsive but deliberately trickles bytes to hold a goroutine. Keepalive will not drop it (it answers the probes), and no per-request deadline stops it without also cutting legitimate slow transfers, which is the bug being fixed. The options for that case, none of them free:
I'd probably go for keepalive for dead connections plus a concurrency cap for blast radius, adding the throughput floor only if we want to actively cut trickle clients. If that direction works I can rework the PR around it: clear the deadline on the streaming handlers, make keepalive explicit on the listener, and drop the deadline wrappers. The trickle case is the bit worth agreeing on first, since it's a genuine tradeoff. |
http.Server ReadTimeout and WriteTimeout are deadlines on the whole request and response, set when it starts, so a large or slow blob upload or download is cut off mid-stream once it passes the timeout even while data is still flowing. This surfaces as a TCP i/o timeout (routes.go WriteDataFromReader) and a truncated body for the client, and as an occasional failure in the GC-on-S3 stress job. Extend the connection deadline as data flows, via http.NewResponseController, so the timeout behaves as an idle deadline: a transfer that keeps making progress completes, a stalled one still times out. Reads and writes are wrapped so the deadline is refreshed on each io.Copy chunk (~32KiB) regardless of link speed. Covers blob serve (WriteDataFromReader and the multipart-range path) and blob receive (FullBlobUpload, PutBlobChunk, PutBlobChunkStreamed, FinishBlobUpload). A zero timeout keeps deadlines off. statusWriter now implements Unwrap so the ResponseController can reach the underlying connection through the session-logging middleware; without it the deadline calls return http.ErrNotSupported and do nothing. Relates to project-zot#4140. Signed-off-by: Paul Kennedy <9027062+datadot@users.noreply.github.com>
Clarify on the ReadTimeout/WriteTimeout config fields that blob transfers extend the deadline as data flows, so it behaves as an idle timeout for them rather than a cap on total transfer time. Signed-off-by: Paul Kennedy <9027062+datadot@users.noreply.github.com>
Add a unit test for streamDeadlineReader (the upload read side), covering the deadline being set per read, the set-deadline error branch, and the zero-timeout unwrapped case. Closes the patch-coverage gap codecov flagged on routes.go. Signed-off-by: Paul Kennedy <9027062+datadot@users.noreply.github.com>
2ddf8a4 to
fa85478
Compare
|
Rebased onto latest @rchincha, friendly nudge on this one. Going off your keepalive framing, the direction I sketched above was: clear the per-request read/write deadline on the streaming blob handlers, make TCP keepalive explicit/tunable on the listener via If that direction works for you I'll rework the PR around it. Just need your read on the trickle tradeoff before I do. |
What type of PR is this?
bug
Which issue does this PR fix: Fixes #4140
What does this PR do / Why do we need it:
http.Server'sReadTimeoutandWriteTimeout(added in #3984) are deadlines on the whole request and response, set when it starts. A blob upload or download that runs longer than the timeout is therefore cut off mid-stream once it passes the deadline, even while data is still flowing. It shows up as a TCPi/o timeout(WriteDataFromReaderinpkg/api/routes.go) and a truncated body for the client, and as an occasional failure in theGC-on-S3stress job.This resets the deadline per chunk via
http.NewResponseController, so the timeout behaves as an idle deadline: a transfer that keeps making progress completes, a stalled one still times out. It covers the blob serve paths (WriteDataFromReaderand the multipart-range path) and the blob receive paths (FullBlobUpload,PutBlobChunk,PutBlobChunkStreamed,FinishBlobUpload). A zero timeout leaves deadlines off, soreadTimeout/writeTimeoutset to0still disables them.statusWriter(the session-logging middleware wrapper) now implementsUnwrap, otherwise theResponseControllerstops at the wrapper and the deadline calls returnhttp.ErrNotSupportedand do nothing.If an issue # is not available please add repro steps and logs showing the issue:
Triggered by uploading or downloading a blob that takes longer than
writeTimeout/readTimeout(default 60s), e.g. a multi-GB layer over a slow link, or a blob read under load in the GC-on-S3 stress test.Testing done on this change:
Extended
TestWriteDataFromReaderto cover streaming with a deadline set, thehttp.ErrNotSupportedpath (recorder without deadline support), and the zero-timeout case. Existing blob GET, multipart-range and upload handler tests pass. Built and linted withgolangci-lint v2.12.2.Automation added to e2e:
No new e2e. The existing
GC-on-S3stress job exercises the serve path under load and acts as a regression check.Will this break upgrades or downgrades?
No.
Does this PR introduce any user-facing change?:
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.