httpdl: add segment work-stealing and --auto-split
an idle worker now steals the back half of the busiest in-flight segment instead of exiting, so one slow segment no longer drains alone over a single connection. control file persists stolen tails; resume re-tiles. add --auto-split (opt-in): derive the connection count from file size (one per min-split-size, up to 16), overriding -x/-s. default stays at 1 connection for a single server.
This commit is contained in:
@@ -57,7 +57,8 @@ type Config struct {
|
||||
Split int // --split: total connections across all mirrors
|
||||
MaxConnPerServer int // --max-connection-per-server: per-host connection cap
|
||||
MinSplit int64
|
||||
Tries int // 0 = unlimited
|
||||
AutoSplit bool // --auto-split (got-only): pick the connection count from file size
|
||||
Tries int // 0 = unlimited
|
||||
Timeout time.Duration
|
||||
FileAlloc string // none | prealloc | trunc | falloc
|
||||
Continue bool
|
||||
@@ -119,7 +120,7 @@ type Config struct {
|
||||
// connections.
|
||||
type Download struct {
|
||||
uris []string // mirror list; uris[0] is the primary (naming + resume key)
|
||||
maxConns int // segment workers = min(split, len(uris)*max-connection-per-server)
|
||||
maxConns int // manual segment-worker cap = min(split, len(uris)*max-connection-per-server); --auto-split derives its own count per file in segmented()
|
||||
cfg Config
|
||||
client *http.Client
|
||||
limit []*rate.Limiter
|
||||
@@ -214,10 +215,17 @@ func New(uris []string, cfg Config) *Download {
|
||||
if perHost < 1 {
|
||||
perHost = 1
|
||||
}
|
||||
// The transport must allow as many concurrent connections per host as we may
|
||||
// actually open. --auto-split scales up to maxAutoConns per host once the file
|
||||
// size is known (in segmented), so raise the cap to that ceiling when it is on.
|
||||
connCap := perHost
|
||||
if cfg.AutoSplit {
|
||||
connCap = maxAutoConns
|
||||
}
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxConnsPerHost: perHost,
|
||||
MaxIdleConnsPerHost: perHost,
|
||||
MaxConnsPerHost: connCap,
|
||||
MaxIdleConnsPerHost: connCap,
|
||||
ResponseHeaderTimeout: cfg.Timeout,
|
||||
TLSHandshakeTimeout: connectTimeout,
|
||||
DialContext: dialContext,
|
||||
@@ -610,7 +618,13 @@ func (d *Download) probe(ctx context.Context, uri string) (probeResult, error) {
|
||||
// segmented downloads total bytes over d.maxConns workers, with per-segment
|
||||
// resume from the control file.
|
||||
func (d *Download) segmented(ctx context.Context, out string, total int64, etag, lastmod string) error {
|
||||
segs := makeSegments(total, d.cfg.MinSplit, d.maxConns)
|
||||
// --auto-split picks the connection count from the file size now that total is
|
||||
// known; otherwise use the manual cap fixed at construction.
|
||||
conns := d.maxConns
|
||||
if d.cfg.AutoSplit {
|
||||
conns = autoConns(total, d.cfg.MinSplit)
|
||||
}
|
||||
segs := makeSegments(total, d.cfg.MinSplit, conns)
|
||||
if c := d.resumeControl(out, etag, lastmod); c != nil {
|
||||
segs = segsFromControl(c)
|
||||
} else if d.cfg.Continue && !fileExists(controlPath(out)) {
|
||||
@@ -648,36 +662,31 @@ func (d *Download) segmented(ctx context.Context, out string, total int64, etag,
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
p := newPool(segs, d.cfg.MinSplit)
|
||||
|
||||
// Periodically persist progress so a crash can resume.
|
||||
saveDone := make(chan struct{})
|
||||
go d.saveLoop(ctx, out, total, etag, lastmod, segs, saveDone)
|
||||
|
||||
jobs := make(chan *seg)
|
||||
go func() {
|
||||
defer close(jobs)
|
||||
for i := range segs {
|
||||
if segs[i].done() {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case jobs <- &segs[i]:
|
||||
case <-ctx.Done(): // stop feeding once we're tearing down
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go d.saveLoop(ctx, out, total, etag, lastmod, p, saveDone)
|
||||
|
||||
// One worker per connection. A worker fetches segments until acquire runs
|
||||
// dry, which happens only once every remaining byte is owned; a worker that
|
||||
// finishes early steals the tail of a slower segment rather than sitting idle
|
||||
// while the last segment drains over a single connection.
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
errOnce sync.Once
|
||||
runErr error
|
||||
)
|
||||
for w := 0; w < d.maxConns; w++ {
|
||||
for w := 0; w < conns; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for s := range jobs {
|
||||
if err := d.fetchSeg(ctx, f, s, total); err != nil {
|
||||
for {
|
||||
s := p.acquire()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if err := d.fetchSeg(ctx, f, p, s, total); err != nil {
|
||||
errOnce.Do(func() { runErr = err; cancel() })
|
||||
return
|
||||
}
|
||||
@@ -691,7 +700,7 @@ func (d *Download) segmented(ctx context.Context, out string, total int64, etag,
|
||||
if runErr != nil {
|
||||
return runErr // keep the control file for a later -c
|
||||
}
|
||||
snapshot(d.primary(), total, etag, lastmod, segs).save(out)
|
||||
p.snapshot(d.primary(), total, etag, lastmod).save(out)
|
||||
removeControl(out)
|
||||
return nil
|
||||
}
|
||||
@@ -699,7 +708,7 @@ func (d *Download) segmented(ctx context.Context, out string, total int64, etag,
|
||||
// fetchSeg downloads one segment, retrying from its resume point on error.
|
||||
// Each attempt re-reads s.offset(), so a retry continues from the bytes already
|
||||
// written rather than restarting the range.
|
||||
func (d *Download) fetchSeg(ctx context.Context, f *os.File, s *seg, total int64) error {
|
||||
func (d *Download) fetchSeg(ctx context.Context, f *os.File, p *pool, s *seg, total int64) error {
|
||||
// Try each mirror in turn for this segment: a transient error retries the
|
||||
// same mirror under withRetries; an exhausted budget or a per-mirror permanent
|
||||
// error (a 404, a non-206, a length mismatch) falls over to the next mirror.
|
||||
@@ -716,7 +725,7 @@ func (d *Download) fetchSeg(ctx context.Context, f *os.File, s *seg, total int64
|
||||
if s.done() {
|
||||
return nil
|
||||
}
|
||||
return d.fetchOnce(ctx, f, s, uri, total)
|
||||
return d.fetchOnce(ctx, f, p, s, uri, total)
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -726,10 +735,10 @@ func (d *Download) fetchSeg(ctx context.Context, f *os.File, s *seg, total int64
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (d *Download) fetchOnce(ctx context.Context, f *os.File, s *seg, uri string, total int64) error {
|
||||
func (d *Download) fetchOnce(ctx context.Context, f *os.File, p *pool, s *seg, uri string, total int64) error {
|
||||
reqCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
req, err := d.request(reqCtx, uri, fmt.Sprintf("bytes=%d-%d", s.offset(), s.end))
|
||||
req, err := d.request(reqCtx, uri, fmt.Sprintf("bytes=%d-%d", s.offset(), s.endOff()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -755,7 +764,7 @@ func (d *Download) fetchOnce(ctx context.Context, f *os.File, s *seg, uri string
|
||||
}
|
||||
body, stop := d.idleGuard(resp.Body, cancel)
|
||||
defer stop()
|
||||
return d.pump(ctx, f, s, body)
|
||||
return d.pump(ctx, f, p, s, body)
|
||||
}
|
||||
|
||||
// errIdleTimeout marks a read that stalled past the idle window, so callers can
|
||||
@@ -901,8 +910,10 @@ func statusError(ctxMsg string, code int) error {
|
||||
}
|
||||
|
||||
// pump copies the response body into the file at the segment's running offset,
|
||||
// stopping at the segment end and respecting rate limits.
|
||||
func (d *Download) pump(ctx context.Context, f *os.File, s *seg, body io.Reader) error {
|
||||
// stopping at the segment end and respecting rate limits. The loop reads
|
||||
// remaining() afresh each turn, so a steal that shrinks s mid-transfer simply
|
||||
// ends the loop early at the new end, leaving the stolen tail to its new worker.
|
||||
func (d *Download) pump(ctx context.Context, f *os.File, p *pool, s *seg, body io.Reader) error {
|
||||
buf := make([]byte, readBuf)
|
||||
for s.remaining() > 0 {
|
||||
n := int64(len(buf))
|
||||
@@ -914,7 +925,7 @@ func (d *Download) pump(ctx context.Context, f *os.File, s *seg, body io.Reader)
|
||||
if _, werr := f.WriteAt(buf[:rd], s.offset()); werr != nil {
|
||||
return werr
|
||||
}
|
||||
s.advance(int64(rd))
|
||||
p.advance(s, int64(rd))
|
||||
atomic.AddInt64(&d.completed, int64(rd))
|
||||
d.throttle(ctx, rd)
|
||||
}
|
||||
@@ -1066,7 +1077,7 @@ func (d *Download) singleOnce(ctx context.Context, out, uri string, resumeFromDi
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Download) saveLoop(ctx context.Context, out string, total int64, etag, lastmod string, segs []seg, done chan<- struct{}) {
|
||||
func (d *Download) saveLoop(ctx context.Context, out string, total int64, etag, lastmod string, p *pool, done chan<- struct{}) {
|
||||
defer close(done)
|
||||
interval := d.cfg.AutoSaveInterval
|
||||
if interval <= 0 {
|
||||
@@ -1077,10 +1088,10 @@ func (d *Download) saveLoop(ctx context.Context, out string, total int64, etag,
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
snapshot(d.primary(), total, etag, lastmod, segs).save(out)
|
||||
p.snapshot(d.primary(), total, etag, lastmod).save(out)
|
||||
return
|
||||
case <-t.C:
|
||||
snapshot(d.primary(), total, etag, lastmod, segs).save(out)
|
||||
p.snapshot(d.primary(), total, etag, lastmod).save(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user