Skip to content

grab: transfer every download as a series of byte ranges - #114

Open
cavaliercoder wants to merge 9 commits into
mainfrom
range-requests
Open

grab: transfer every download as a series of byte ranges#114
cavaliercoder wants to merge 9 commits into
mainfrom
range-requests

Conversation

@cavaliercoder

Copy link
Copy Markdown
Collaborator

Implements HTTP Range support, redesigned from #102 by @justinfx. Refs #86.

The ranged path is the only path: an unsplit download is the degenerate case
of one range covering the file, owned by one goroutine. That removes branching
rather than adding it.

API

Three fields on Request:

Field Meaning
RangeSize int64 Split the transfer into ranges of this size. Unset means one range covering the file.
Concurrency int Maximum ranges in flight. Default 1.
Durable bool Flush to stable storage as the transfer proceeds, so Response.Err does not report success until the data is on the disk. Where the transfer is split, also record which ranges are complete so an interruption resumes.

Durable is the axis that decides what a split transfer costs. Without it the
transfer never flushes and an interruption loses everything it wrote; with it
the file is flushed about once a second, which holds the transfer to the rate
the device accepts data rather than the rate the network delivers it. Flushing
throughout rather than once at the end keeps the reported rate honest — a
single flush on completion would run at the speed of memory and then stall at
100% while the device caught up.

A split transfer always writes its checkpoint file once, before any of the
destination. Its presence rather than its contents is what marks the file as
written out of order; without that marker nothing distinguishes a file
abandoned mid-split from a resumable or a finished one.

Commits

60fa7f5 the feature
24ca663 an unsplit transfer must not resume by length from a file a split one may have holed
8fc5e86 Request.Durable
9aa5afe flush unsplit transfers too, not only split ones
a856c5e don't mistake .grab in the working directory for a checkpoint
a6cb85f remove a checkpoint that already accounts for the whole file
b1545fd fsync the directory so the first checkpoint survives a crash
97fcd0d reject a range size too large for the unit it is given in

The last four came out of a review pass over the first four; each has a
regression test verified to fail against the unfixed code.

Measured

Real downloads from public mirrors, checksums verified:

  • Home gigabit: HTTP/1.1 reached 62.8 MB/s at 4 workers (1.48×); HTTP/2 declined to 41.3 (0.95×).
  • EC2 t4g.xlarge: HTTP/1.1 scaled 43.5 → 73.6 → 101.0 → 179.0 MB/s at 64 workers. HTTP/2 declined 49.0 → 37.5.

Over HTTP/2 the ranges multiplex onto one connection and share its congestion
window, so concurrency does not multiply bandwidth — RFC 9113 §9.1 asks clients
not to open a second connection. Past a point concurrency is all downside and a
little bit rude: 64 workers earned a 503 from one mirror.

Checkpointed throughput tracks the disk's sync rate about 1:1 with no library
overhead — gp3 at 125 MB/s provisioned (135 MB/s measured by dd) gave
132–177 MB/s; at 1000 (378 MB/s) it gave 347–399.

docs/range-requests.md carries the reasoning and the full results.

Notes

  • NewClient now sets MaxIdleConnsPerHost = MaxIdleConns. At the default of two, concurrent ranges overflow the idle pool and perfectly good connections get closed and re-handshaked.
  • PR Implement optional concurrent "Range" requests (refs #86) #102's TCP_NODELAY change is not carried over: grab is almost purely a receiver, and its conn.(*net.TCPConn) assertion panics on any non-TCP network.

🤖 Generated with Claude Code

cavaliercoder and others added 9 commits August 22, 2026 23:11
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>
A split transfer writes ranges at their offset, so an interrupted one
can leave a file that is already full length with parts of it never
written. validateLocal only accounted for this when the transfer that
found the file would itself split, so a later transfer with no RangeSize
resumed from the file's length - and where the file was already full
length, skipped the transfer altogether and reported success on a file
with a hole in it.

The checkpoint beside the file is what marks it as written out of order,
whatever the transfer that finds it is configured to do. Treat its
presence as authoritative in both paths: an unsplit transfer starts over
rather than resuming by length, and discards the checkpoint it will not
maintain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Setting RangeSize made every split transfer record its progress, and
flushing the destination before each record is what holds a split
transfer to the rate the device accepts data rather than the rate the
network delivers it. Not every caller wants to pay that: a download that
is cheap to repeat would rather have the bandwidth.

Durable makes it a choice. Set it and the transfer records what it has
written about once a second, as before. Leave it unset and the transfer
never flushes, and an interruption costs everything it had written.

The checkpoint file itself is still written for every split transfer,
once, before any of the destination is. Its presence rather than its
contents is what marks the file as written out of order, and without
that marker nothing distinguishes a file abandoned mid-split from a
resumable or a finished one - a later transfer would resume from a
length that means nothing, or take a file that reached full length with
holes in it for a complete one. So recording no progress costs a single
write at the start of the transfer, while recording it costs a flush per
second.

BenchmarkTransfer gains durable as an axis, since the gap between the
two is now the thing worth watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Durable promised that Response.Err waits for the data, but only a split
transfer flushed. An unsplit one had no checkpointer to do it, and a
split one skipped its final flush on success, since the checkpoint it
would have written was about to be deleted anyway. Both left the last of
the transfer for the operating system to write out afterwards.

Give a transfer that is not split a flusher, which does on the same
interval what the checkpointer does around each record. Flushing
throughout rather than once at the end is what keeps the reported rate
honest: a single flush on completion would let a transfer run at the
speed of memory and then stall, at 100% complete, for as long as the
device needed to catch up. And have the checkpointer flush whenever it
stops, whether or not it writes a final record, so a durable transfer
that finished has its last second of data on the disk before Err
returns.

The checksum needs no change. It is computed by re-reading the
destination rather than from the bytes as they passed through, so under
Durable it already reads what has been flushed, and a write that failed
is caught before the checksum rather than after it. That read may still
be served from the page cache, which the docs now say rather than claim
the medium was verified.

BenchmarkTransfer now covers unsplit transfers with and without Durable:
on an SSD with an 8 MiB file, 2505 against 1004 MB/s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hasCheckpoint appended the checkpoint suffix to the destination file name
without checking there was one, so a transfer whose destination is not
yet known asked about ".grab" relative to the process working directory.
planTransfer acts on the answer by removing that file, so an unrelated
file of that name was deleted.

Reaching it needs nothing unusual: a destination directory leaves
Response.Filename empty until the name is resolved from the response,
and a server that rejects HEAD - the case the HEAD fallback exists for -
reaches planTransfer before that happens.

A transfer with no destination has no checkpoint. Say so in
hasCheckpoint, where the empty name becomes a path, so that every caller
that reads or removes one is covered rather than just this caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
planSplitTransfer returned checksumFile as soon as the checkpoint left
nothing missing, three lines before it assigns Response.checkpoint. The
checkpoint is removed by copyFile, which that path never reaches, so a
transfer that found its work already done left the record behind for
good.

The stale file then marks a finished download as written out of order:
every later transfer of that path takes the checkpoint branch, and an
unsplit one discards the record and downloads the complete file again.

Reaching it takes a durable split transfer cancelled as its last range
lands, which records every range as complete before it stops.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
store flushed the destination and the temporary checkpoint, then renamed
the checkpoint into place. The rename is atomic, but the directory entry
it creates is not durable until the directory itself is flushed, so a
crash could leave a destination whose data reached the disk beside a
checkpoint that no longer existed.

That is the one state the checkpoint is there to prevent. Its presence,
not its contents, is what marks a file as written out of order; without
it a later transfer resumes from a length that means nothing, or takes a
file that reached full length with holes in it for a complete one.

Only the first store needs the flush. Losing a later checkpoint reverts
to an earlier one, which understates what was written and costs a
refetch, so the once-per-second store is unchanged and a transfer pays
one extra flush in total.

Windows has no equivalent: FlushFileBuffers rejects the handle that
opening a directory yields, so syncDir is a documented no-op there and
the durability of a rename is left to the file system.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
byteSize.Set multiplied the parsed number by its unit without checking
the result fits an int64, so -range-size 10000000000GB wrapped to a
negative size. The check for a negative value runs before the multiply
and does not catch it.

A negative RangeSize does not split the transfer, so the download ran as
a single request while reporting no error and appearing to be configured
for ranges.

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>
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.

1 participant