Implement optional concurrent "Range" requests (refs #86) - #102
Conversation
…se a "Range" request
|
I've realised that I have left the returned |
|
If multiple goroutines are writing to the output in parallel, will the output file have gaps in between partially written chunks? If so, how would resuming a partial download work? |
|
@ananthb I think it is possible in the current implementation for the resume to not be correct, given a situation where a later range concurrently finishes sooner than an earlier range and then the transfer is stopped. The reason would be that as each concurrent range completes writing, it atomically adds to the total bytes written. So if 10,20,40 finish, but 30 does not, it would report 30 total written but there would be a gap, and the file itself would look like it had |
|
@ananthb I've just pushed 8b4f8d2 to address the support for resume with ranged requests. It will now truncate the file to the end of the lowest successful range offset before a failure to avoid any gaps. This means you may lose progress on some chunks that concurrently finished just after the failed range. |
|
Yep that makes sense @justinfx. |
|
What happens if grab crashes before it can truncate the file? If you only wrote completed chunks to the file, then you wouldn't need to truncate it in the first place. |
|
@ananthb yea if the process crashed before it could truncate, it would leave a file that could be corrupt and then used for resume. I definitely don't think we should buffer in memory because that could have surprising resource usage implications on large files.
|
|
Yeah in-memory buffers alone won't be enough to cover, say large chunk sizes or many parallel chunks. I've been toying with the idea of an in-memory buffer that spills over onto disk. My basic idea to make resume work is that the output file should always be consistent and not have any "holes". Basically write only completed chunks in order to the output file. I could use anonymous chunks to buffer in-progress chunks and too. |
|
The current implementation splits large chunks by the number of parallel workers, so I wonder if your idea could manage to avoid large memory usage. You might have to buffer alot before a gap closed. |
|
@justinfx I wrote a library called chonker that does Range GETs transparently. |
|
@ananthb nice one. It's been a while and I have moved on from this issue, having made use of it in a fork with this feature |
|
I really like this idea and appreciate the work that went into it. I'm tinkering with this project again after a few years of focussing elsewhere. One goal stated in the README is to keep Grab stateless, but I'm happy to revisit this for the benefit of multipart transfers. I think a way forward here is multiple steps:
The downside is the potential proliferation of snapshots in the filesystem, but one could argue it's no worse than a proliferation of corrupted, unresumable downloads. I'm going to pick up your branch and build on it. I'd like change some things, but would really like to see you contribution land in the project history. |
|
I appreciate you picking up the work I had done! It seemed to work well within the internal project using this library. But I haven't worked on that particular project for years now. Either way, it's always good to be able to contribute a starting point! |
Adds Request.RangeSize and Request.Concurrency. Setting RangeSize splits
a download into ranges of that size, fetched with separate Range
requests; Concurrency bounds how many are in flight.
There is one transfer implementation rather than two. A download that is
not split is the single range covering whatever remains, and a fresh
download of a file of unknown length is that range requested with no
Range header at all - byte for byte the request grab has always made.
The tests pin those headers, because building every transfer out of
ranges must not change what an unsplit one puts on the wire.
Ranges are dispatched in ascending order so the destination fills from
the front, and each is written at its offset with WriteAt. The
destination is therefore never opened O_APPEND, which on some systems
forces every write to the end of the file whatever offset it was given.
Request.NoStore writes to an in-memory WriterAt so it takes the same
path as everything else.
A split transfer writes its progress to a checkpoint file beside the
destination, so an interrupted one resumes without refetching. The
record carries the URL, size, range size and the remote file's
validators, and is discarded unless they all still match - a stronger
guarantee than an unsplit resume can make, since that has no choice but
to assume the remote file is unchanged. Workers report partial progress
as they write, so what an interruption costs is bounded by the
checkpoint interval rather than by RangeSize. The destination is flushed
before the checkpoint naming it is renamed into place, so a checkpoint
can never claim data the filesystem has not committed.
The size of the remote file is read from Content-Range where the server
offers it rather than inferred, which also lets a range be checked
against the file it came from: a server reporting a different total part
way through has given us a different file, and one answering a Range
request with the whole file has given us a response starting somewhere
other than where we asked.
Supporting changes:
- grabtest serves byte ranges, Content-Range and validators, records
the ranges it served, and rejects the forms grab never sends
- benchmarks for what a transfer costs and what tuning it is worth,
split into `make bench` for regressions and `make bench-network` for
the shape of the trade-offs
- the default client is built on http.DefaultTransport, which it was
not before, so it now has connection, TLS handshake and idle
timeouts at all; MaxIdleConnsPerHost is raised since a split
transfer makes many requests to one host
- cmd/grab gains -range-size, -concurrency, -http1, -o and -batch, and
reports each file's rate and negotiated protocol
- docs/ describes the architecture and its invariants, how to reason
about range size and concurrency, and how to test and benchmark,
with the numbers measured against real mirrors from two clients
Based on the design and prototype in #102, which implemented this as a
fixed count of chunks rather than a fixed chunk size, and which had no
way to resume a transfer it interrupted.
refs #86
Co-authored-by: Justin Israel <justinisrael@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Request.RangeSize and Request.Concurrency. Setting RangeSize splits
a download into ranges of that size, fetched with separate Range
requests; Concurrency bounds how many are in flight.
There is one transfer implementation rather than two. A download that is
not split is the single range covering whatever remains, and a fresh
download of a file of unknown length is that range requested with no
Range header at all - byte for byte the request grab has always made.
The tests pin those headers, because building every transfer out of
ranges must not change what an unsplit one puts on the wire.
Ranges are dispatched in ascending order so the destination fills from
the front, and each is written at its offset with WriteAt. The
destination is therefore never opened O_APPEND, which on some systems
forces every write to the end of the file whatever offset it was given.
Request.NoStore writes to an in-memory WriterAt so it takes the same
path as everything else.
A split transfer writes its progress to a checkpoint file beside the
destination, so an interrupted one resumes without refetching. The
record carries the URL, size, range size and the remote file's
validators, and is discarded unless they all still match - a stronger
guarantee than an unsplit resume can make, since that has no choice but
to assume the remote file is unchanged. Workers report partial progress
as they write, so what an interruption costs is bounded by the
checkpoint interval rather than by RangeSize. The destination is flushed
before the checkpoint naming it is renamed into place, so a checkpoint
can never claim data the filesystem has not committed.
The size of the remote file is read from Content-Range where the server
offers it rather than inferred, which also lets a range be checked
against the file it came from: a server reporting a different total part
way through has given us a different file, and one answering a Range
request with the whole file has given us a response starting somewhere
other than where we asked.
Supporting changes:
- grabtest serves byte ranges, Content-Range and validators, records
the ranges it served, and rejects the forms grab never sends
- benchmarks for what a transfer costs and what tuning it is worth,
split into `make bench` for regressions and `make bench-network` for
the shape of the trade-offs
- the default client is built on http.DefaultTransport, which it was
not before, so it now has connection, TLS handshake and idle
timeouts at all; MaxIdleConnsPerHost is raised since a split
transfer makes many requests to one host
- cmd/grab gains -range-size, -concurrency, -http1, -o and -batch, and
reports each file's rate and negotiated protocol
- docs/ describes the architecture and its invariants, how to reason
about range size and concurrency, and how to test and benchmark,
with the numbers measured against real mirrors from two clients
Based on the design and prototype in #102, which implemented this as a
fixed count of chunks rather than a fixed chunk size, and which had no
way to resume a transfer it interrupted.
refs #86
Co-authored-by: Justin Israel <justinisrael@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Request.RangeSize and Request.Concurrency. Setting RangeSize splits
a download into ranges of that size, fetched with separate Range
requests; Concurrency bounds how many are in flight.
There is one transfer implementation rather than two. A download that is
not split is the single range covering whatever remains, and a fresh
download of a file of unknown length is that range requested with no
Range header at all - byte for byte the request grab has always made.
The tests pin those headers, because building every transfer out of
ranges must not change what an unsplit one puts on the wire.
Ranges are dispatched in ascending order so the destination fills from
the front, and each is written at its offset with WriteAt. The
destination is therefore never opened O_APPEND, which on some systems
forces every write to the end of the file whatever offset it was given.
Request.NoStore writes to an in-memory WriterAt so it takes the same
path as everything else.
A split transfer writes its progress to a checkpoint file beside the
destination, so an interrupted one resumes without refetching. The
record carries the URL, size, range size and the remote file's
validators, and is discarded unless they all still match - a stronger
guarantee than an unsplit resume can make, since that has no choice but
to assume the remote file is unchanged. Workers report partial progress
as they write, so what an interruption costs is bounded by the
checkpoint interval rather than by RangeSize. The destination is flushed
before the checkpoint naming it is renamed into place, so a checkpoint
can never claim data the filesystem has not committed.
The size of the remote file is read from Content-Range where the server
offers it rather than inferred, which also lets a range be checked
against the file it came from: a server reporting a different total part
way through has given us a different file, and one answering a Range
request with the whole file has given us a response starting somewhere
other than where we asked.
Supporting changes:
- grabtest serves byte ranges, Content-Range and validators, records
the ranges it served, and rejects the forms grab never sends
- benchmarks for what a transfer costs and what tuning it is worth,
split into `make bench` for regressions and `make bench-network` for
the shape of the trade-offs
- the default client is built on http.DefaultTransport, which it was
not before, so it now has connection, TLS handshake and idle
timeouts at all; MaxIdleConnsPerHost is raised since a split
transfer makes many requests to one host
- cmd/grab gains -range-size, -concurrency, -http1, -o and -batch, and
reports each file's rate and negotiated protocol
- docs/ describes the architecture and its invariants, how to reason
about range size and concurrency, and how to test and benchmark,
with the numbers measured against real mirrors from two clients
Based on the design and prototype in #102, which implemented this as a
fixed count of chunks rather than a fixed chunk size, and which had no
way to resume a transfer it interrupted.
refs #86
Co-authored-by: Justin Israel <justinisrael@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Request.RangeSize and Request.Concurrency. Setting RangeSize splits
a download into ranges of that size, fetched with separate Range
requests; Concurrency bounds how many are in flight.
There is one transfer implementation rather than two. A download that is
not split is the single range covering whatever remains, and a fresh
download of a file of unknown length is that range requested with no
Range header at all - byte for byte the request grab has always made.
The tests pin those headers, because building every transfer out of
ranges must not change what an unsplit one puts on the wire.
Ranges are dispatched in ascending order so the destination fills from
the front, and each is written at its offset with WriteAt. The
destination is therefore never opened O_APPEND, which on some systems
forces every write to the end of the file whatever offset it was given.
Request.NoStore writes to an in-memory WriterAt so it takes the same
path as everything else.
A split transfer writes its progress to a checkpoint file beside the
destination, so an interrupted one resumes without refetching. The
record carries the URL, size, range size and the remote file's
validators, and is discarded unless they all still match - a stronger
guarantee than an unsplit resume can make, since that has no choice but
to assume the remote file is unchanged. Workers report partial progress
as they write, so what an interruption costs is bounded by the
checkpoint interval rather than by RangeSize. The destination is flushed
before the checkpoint naming it is renamed into place, so a checkpoint
can never claim data the filesystem has not committed.
The size of the remote file is read from Content-Range where the server
offers it rather than inferred, which also lets a range be checked
against the file it came from: a server reporting a different total part
way through has given us a different file, and one answering a Range
request with the whole file has given us a response starting somewhere
other than where we asked.
Supporting changes:
- grabtest serves byte ranges, Content-Range and validators, records
the ranges it served, and rejects the forms grab never sends
- benchmarks for what a transfer costs and what tuning it is worth,
split into `make bench` for regressions and `make bench-network` for
the shape of the trade-offs
- the default client is built on http.DefaultTransport, which it was
not before, so it now has connection, TLS handshake and idle
timeouts at all; MaxIdleConnsPerHost is raised since a split
transfer makes many requests to one host
- cmd/grab gains -range-size, -concurrency, -http1, -o and -batch, and
reports each file's rate and negotiated protocol
- docs/ describes the architecture and its invariants, how to reason
about range size and concurrency, and how to test and benchmark,
with the numbers measured against real mirrors from two clients
Based on the design and prototype in #102, which implemented this as a
fixed count of chunks rather than a fixed chunk size, and which had no
way to resume a transfer it interrupted.
refs #86
Co-authored-by: Justin Israel <justinisrael@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Request.RangeSize and Request.Concurrency. Setting RangeSize splits
a download into ranges of that size, fetched with separate Range
requests; Concurrency bounds how many are in flight.
There is one transfer implementation rather than two. A download that is
not split is the single range covering whatever remains, and a fresh
download of a file of unknown length is that range requested with no
Range header at all - byte for byte the request grab has always made.
The tests pin those headers, because building every transfer out of
ranges must not change what an unsplit one puts on the wire.
Ranges are dispatched in ascending order so the destination fills from
the front, and each is written at its offset with WriteAt. The
destination is therefore never opened O_APPEND, which on some systems
forces every write to the end of the file whatever offset it was given.
Request.NoStore writes to an in-memory WriterAt so it takes the same
path as everything else.
A split transfer writes its progress to a checkpoint file beside the
destination, so an interrupted one resumes without refetching. The
record carries the URL, size, range size and the remote file's
validators, and is discarded unless they all still match - a stronger
guarantee than an unsplit resume can make, since that has no choice but
to assume the remote file is unchanged. Workers report partial progress
as they write, so what an interruption costs is bounded by the
checkpoint interval rather than by RangeSize. The destination is flushed
before the checkpoint naming it is renamed into place, so a checkpoint
can never claim data the filesystem has not committed.
The size of the remote file is read from Content-Range where the server
offers it rather than inferred, which also lets a range be checked
against the file it came from: a server reporting a different total part
way through has given us a different file, and one answering a Range
request with the whole file has given us a response starting somewhere
other than where we asked.
Supporting changes:
- grabtest serves byte ranges, Content-Range and validators, records
the ranges it served, and rejects the forms grab never sends
- benchmarks for what a transfer costs and what tuning it is worth,
split into `make bench` for regressions and `make bench-network` for
the shape of the trade-offs
- the default client is built on http.DefaultTransport, which it was
not before, so it now has connection, TLS handshake and idle
timeouts at all; MaxIdleConnsPerHost is raised since a split
transfer makes many requests to one host
- cmd/grab gains -range-size, -concurrency, -http1, -o and -batch, and
reports each file's rate and negotiated protocol
- docs/ describes the architecture and its invariants, how to reason
about range size and concurrency, and how to test and benchmark,
with the numbers measured against real mirrors from two clients
Based on the design and prototype in #102, which implemented this as a
fixed count of chunks rather than a fixed chunk size, and which had no
way to resume a transfer it interrupted.
refs #86
Co-authored-by: Justin Israel <justinisrael@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Request.RangeSize and Request.Concurrency. Setting RangeSize splits
a download into ranges of that size, fetched with separate Range
requests; Concurrency bounds how many are in flight.
There is one transfer implementation rather than two. A download that is
not split is the single range covering whatever remains, and a fresh
download of a file of unknown length is that range requested with no
Range header at all - byte for byte the request grab has always made.
The tests pin those headers, because building every transfer out of
ranges must not change what an unsplit one puts on the wire.
Ranges are dispatched in ascending order so the destination fills from
the front, and each is written at its offset with WriteAt. The
destination is therefore never opened O_APPEND, which on some systems
forces every write to the end of the file whatever offset it was given.
Request.NoStore writes to an in-memory WriterAt so it takes the same
path as everything else.
A split transfer writes its progress to a checkpoint file beside the
destination, so an interrupted one resumes without refetching. The
record carries the URL, size, range size and the remote file's
validators, and is discarded unless they all still match - a stronger
guarantee than an unsplit resume can make, since that has no choice but
to assume the remote file is unchanged. Workers report partial progress
as they write, so what an interruption costs is bounded by the
checkpoint interval rather than by RangeSize. The destination is flushed
before the checkpoint naming it is renamed into place, so a checkpoint
can never claim data the filesystem has not committed.
The size of the remote file is read from Content-Range where the server
offers it rather than inferred, which also lets a range be checked
against the file it came from: a server reporting a different total part
way through has given us a different file, and one answering a Range
request with the whole file has given us a response starting somewhere
other than where we asked.
Supporting changes:
- grabtest serves byte ranges, Content-Range and validators, records
the ranges it served, and rejects the forms grab never sends
- benchmarks for what a transfer costs and what tuning it is worth,
split into `make bench` for regressions and `make bench-network` for
the shape of the trade-offs
- the default client is built on http.DefaultTransport, which it was
not before, so it now has connection, TLS handshake and idle
timeouts at all; MaxIdleConnsPerHost is raised since a split
transfer makes many requests to one host
- cmd/grab gains -range-size, -concurrency, -http1, -o and -batch, and
reports each file's rate and negotiated protocol
- docs/ describes the architecture and its invariants, how to reason
about range size and concurrency, and how to test and benchmark,
with the numbers measured against real mirrors from two clients
Based on the design and prototype in #102, which implemented this as a
fixed count of chunks rather than a fixed chunk size, and which had no
way to resume a transfer it interrupted.
refs #86
Co-authored-by: Justin Israel <justinisrael@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RangeSize and Concurrency both have guards for values that make no sense - a Concurrency below one runs a single worker, and a RangeSize that is zero, negative or no smaller than the file leaves the transfer unsplit - but nothing held them in place. Cover them, adapting the negative chunk count from #102. Its separate RangeRequestMinSize has no equivalent here and needs none: RangeSize is itself the floor, as a file no larger than one range is never split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is an implementation of an optional feature to have a
Requestdownload the payload in multiple chunks, using a "Range" request, if supported by the server.API updates
The
Requeststruct gains a new field calledRangeRequestMax, which when set to > 0 controls how many chunks to download in parallel using a "Range" request, instead of a single request reading the full body.Implementation details
High level steps:
RangeRequestMax> 0transferimplementation, calledtransferRangescopyFileworks the same as beforeGiven the way the state machine works, it seemed easier to launch the concurrent range requests during the copy phase, instead of in the synchronous
getRequeststate and have to monitor a list of requests.A new
transfererinterface has been introduced, to have a second implementation calledtransferRangestransferRangesimplementationThis alternate implementation handles launching the concurrent range requests, doing the copy of the data, and tracking the metrics.
It seemed easier to just pass the HEAD
Responseto the private constructor, as most of the needed details are present on that struct.transferRanges.Copy()will start a number of Range requests with anoffset-limitin goroutines, per the value ofRangeRequestMaxpassed in. Each goroutines writes directly to the open file writer usingWriteAt(with underlyingpwrite()syscall) to write chunks of data at offsets to the same file descriptor in parallel. Metrics are atomically updated to keepN()andBPS()working.Other details
I did also update the default setting in the
Clientwhen it comes to setting "TCP_NODELAY". In Go standard lib, this is set totruein thenet.TCPConnto target rpc workloads. But it would make more sense, in theory, to disable Nagles Algorithm by default in a library specific to downloading files over HTTP. It can still be controlled by the user supplying a customHTTPClient