Skip to content

Commit 93d399c

Browse files
alecthomasTest
andauthored
feat(git): add parallel range download for snapshot restore (#361)
Add a --download-concurrency flag (default 1) to `cachew git restore` that fetches the snapshot with bounded concurrent range requests via client.ParallelGet, downloading into a temp file and extracting from it. A --download-chunk-size-mb flag (default 8) tunes the chunk size. A concurrency of 1, an old server, or a missing ETag transparently falls back to today's single streaming download. ParallelGet drives the object-key API, but the snapshot lives behind the /git endpoint, so add a RangeReader adapter that issues ranged GETs to the snapshot URL and captures the freshen metadata (commit / bundle URL) from the discovery response. Route the cold-start serve paths through ServeCacheHit + ConditionalOptions so cold serves advertise an ETag and Accept-Ranges and honour Range, letting clients parallelise during mirror warm-up too. Co-authored-by: Test <test@test.com>
1 parent c2bb197 commit 93d399c

4 files changed

Lines changed: 332 additions & 77 deletions

File tree

client/git.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/url"
1111
"os"
1212
"strings"
13+
"sync"
1314

1415
"github.com/alecthomas/errors"
1516
)
@@ -183,6 +184,94 @@ func (c *Client) openGitArtifact(ctx context.Context, repoURL, suffix string) (*
183184
}, nil
184185
}
185186

187+
// GitSnapshotMetadata carries the freshen metadata returned alongside a
188+
// parallel snapshot download. Commit is the mirror's HEAD SHA at snapshot time
189+
// (empty for cold serves); BundleURL, when non-empty, points at a delta bundle
190+
// that brings the snapshot up to the mirror's current HEAD.
191+
type GitSnapshotMetadata struct {
192+
Commit string
193+
BundleURL string
194+
}
195+
196+
// DownloadGitSnapshot fetches the working-tree snapshot for repoURL into dst,
197+
// using up to concurrency concurrent range requests of chunkSize bytes each.
198+
// When concurrency is 1, or the server does not support ranges, it transparently
199+
// falls back to a single full download. dst is written at non-overlapping
200+
// offsets via WriteAt (e.g. an *os.File) and the caller owns its lifecycle. It
201+
// returns the snapshot's freshen metadata, read from the discovery response.
202+
// Returns os.ErrNotExist when the server has no snapshot available.
203+
func (c *Client) DownloadGitSnapshot(ctx context.Context, repoURL string, dst io.WriterAt, chunkSize int64, concurrency int) (GitSnapshotMetadata, error) {
204+
endpoint, err := gitEndpointURL(c.baseURL, repoURL, "snapshot.tar.zst")
205+
if err != nil {
206+
return GitSnapshotMetadata{}, err
207+
}
208+
reader := &gitArtifactRangeReader{client: c, endpoint: endpoint}
209+
if err := ParallelGet(ctx, reader, NewKey(repoURL), dst, chunkSize, concurrency); err != nil {
210+
return GitSnapshotMetadata{}, errors.Wrap(err, "download snapshot")
211+
}
212+
return reader.metadata(), nil
213+
}
214+
215+
// gitArtifactRangeReader adapts a git artifact endpoint to the RangeReader
216+
// interface so ParallelGet can fetch it with concurrent range requests. The
217+
// object's identity is the endpoint URL, so the Key argument is ignored. It
218+
// records the first response's headers, which carry the snapshot's freshen
219+
// metadata (delivered on the discovery chunk) that ParallelGet does not surface.
220+
type gitArtifactRangeReader struct {
221+
client *Client
222+
endpoint string
223+
224+
mu sync.Mutex
225+
discovery http.Header
226+
}
227+
228+
func (g *gitArtifactRangeReader) Open(ctx context.Context, _ Key, opts ...RequestOption) (io.ReadCloser, http.Header, error) {
229+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.endpoint, nil)
230+
if err != nil {
231+
return nil, nil, errors.Wrap(err, "create request")
232+
}
233+
NewRequestOptions(opts...).applyToRequest(req)
234+
235+
resp, err := g.client.http.Do(req)
236+
if err != nil {
237+
return nil, nil, errors.Wrap(err, "execute request")
238+
}
239+
switch resp.StatusCode {
240+
case http.StatusOK, http.StatusPartialContent:
241+
g.recordDiscovery(resp.Header)
242+
return resp.Body, resp.Header, nil
243+
case http.StatusNotFound:
244+
_, _ = io.Copy(io.Discard, resp.Body) //nolint:errcheck,gosec
245+
return nil, nil, errors.Join(os.ErrNotExist, resp.Body.Close())
246+
case http.StatusRequestedRangeNotSatisfiable:
247+
_, _ = io.Copy(io.Discard, resp.Body) //nolint:errcheck,gosec
248+
g.recordDiscovery(resp.Header)
249+
return nil, resp.Header, errors.Join(ErrRangeNotSatisfiable, resp.Body.Close())
250+
default:
251+
_, _ = io.Copy(io.Discard, resp.Body) //nolint:errcheck,gosec
252+
return nil, nil, errors.Join(errors.WithStack(&HTTPStatusError{StatusCode: resp.StatusCode}), resp.Body.Close())
253+
}
254+
}
255+
256+
// recordDiscovery stores the first response's headers so the freshen metadata
257+
// they carry survives after the bodies are consumed.
258+
func (g *gitArtifactRangeReader) recordDiscovery(h http.Header) {
259+
g.mu.Lock()
260+
defer g.mu.Unlock()
261+
if g.discovery == nil {
262+
g.discovery = h.Clone()
263+
}
264+
}
265+
266+
func (g *gitArtifactRangeReader) metadata() GitSnapshotMetadata {
267+
g.mu.Lock()
268+
defer g.mu.Unlock()
269+
return GitSnapshotMetadata{
270+
Commit: g.discovery.Get(SnapshotCommitHeader),
271+
BundleURL: g.discovery.Get(BundleURLHeader),
272+
}
273+
}
274+
186275
// gitEndpointURL builds a /git/{host}/{repoPath}/{suffix} URL from a cachew
187276
// base URL and an upstream repository URL (e.g. https://github.com/org/repo).
188277
func gitEndpointURL(baseURL, repoURL, suffix string) (string, error) {

client/git_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
package client_test
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"io"
78
"net/http"
89
"net/http/httptest"
910
"os"
1011
"strings"
12+
"sync/atomic"
1113
"testing"
14+
"time"
1215

1316
"github.com/alecthomas/assert/v2"
1417
"github.com/alecthomas/errors"
@@ -171,3 +174,67 @@ func TestOpenGitBundleNotFound(t *testing.T) {
171174
_, err := api.OpenGitBundle(context.Background(), "/git/x/y/snapshot.bundle")
172175
assert.True(t, errors.Is(err, os.ErrNotExist))
173176
}
177+
178+
func TestDownloadGitSnapshotParallel(t *testing.T) {
179+
body := make([]byte, 1000)
180+
for i := range body {
181+
body[i] = byte(i % 251)
182+
}
183+
const etag = `"snap-v1"`
184+
185+
var requests atomic.Int64
186+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
187+
assert.Equal(t, "/git/github.com/org/repo/snapshot.tar.zst", r.URL.Path)
188+
requests.Add(1)
189+
w.Header().Set("Content-Type", "application/zstd")
190+
w.Header().Set("ETag", etag)
191+
w.Header().Set(client.SnapshotCommitHeader, "deadbeef")
192+
w.Header().Set(client.BundleURLHeader, "/git/github.com/org/repo/snapshot.bundle?base=deadbeef")
193+
// ServeContent honours Range/If-Range against the ETag set above, so it
194+
// returns 206 + Content-Range for the chunked requests ParallelGet makes.
195+
http.ServeContent(w, r, "snapshot.tar.zst", time.Time{}, bytes.NewReader(body))
196+
}))
197+
defer srv.Close()
198+
199+
api := client.NewWithHTTPClient(srv.URL, srv.Client())
200+
var dst bufferAt
201+
// A 128-byte chunk over a 1000-byte body forces multiple chunks, exercising
202+
// concurrent range reassembly.
203+
meta, err := api.DownloadGitSnapshot(context.Background(), "https://github.com/org/repo", &dst, 128, 4)
204+
assert.NoError(t, err)
205+
assert.Equal(t, body, dst.buf)
206+
assert.Equal(t, "deadbeef", meta.Commit)
207+
assert.Equal(t, "/git/github.com/org/repo/snapshot.bundle?base=deadbeef", meta.BundleURL)
208+
assert.True(t, requests.Load() > 1, "expected multiple range requests, got %d", requests.Load())
209+
}
210+
211+
func TestDownloadGitSnapshotFallsBackWithoutRange(t *testing.T) {
212+
body := []byte("full body, server ignores ranges")
213+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
214+
// No ETag and no range handling: always answer the full object with 200,
215+
// mimicking an older server. ParallelGet must fall back to a single read.
216+
w.Header().Set("Content-Type", "application/zstd")
217+
w.Header().Set(client.SnapshotCommitHeader, "cafe")
218+
_, _ = w.Write(body) //nolint:errcheck
219+
}))
220+
defer srv.Close()
221+
222+
api := client.NewWithHTTPClient(srv.URL, srv.Client())
223+
var dst bufferAt
224+
meta, err := api.DownloadGitSnapshot(context.Background(), "https://github.com/org/repo", &dst, 8, 4)
225+
assert.NoError(t, err)
226+
assert.Equal(t, body, dst.buf)
227+
assert.Equal(t, "cafe", meta.Commit)
228+
}
229+
230+
func TestDownloadGitSnapshotNotFound(t *testing.T) {
231+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
232+
http.NotFound(w, r)
233+
}))
234+
defer srv.Close()
235+
236+
api := client.NewWithHTTPClient(srv.URL, srv.Client())
237+
var dst bufferAt
238+
_, err := api.DownloadGitSnapshot(context.Background(), "https://github.com/org/repo", &dst, 8, 4)
239+
assert.True(t, errors.Is(err, os.ErrNotExist))
240+
}

cmd/cachew/git.go

Lines changed: 117 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ type GitRestoreCmd struct {
5252
Commit []string `help:"Required commit SHAs that must exist on the server, regardless of which ref points at them. May be repeated."`
5353
NoBundle bool `help:"Skip applying delta bundle."`
5454
ZstdThreads int `help:"Threads for zstd decompression (0 = all CPU cores)." default:"0"`
55+
// DownloadConcurrency > 1 fetches the snapshot with that many concurrent
56+
// range requests (requires server range support; falls back to a single
57+
// request otherwise). 1 keeps the streaming single-request download.
58+
DownloadConcurrency int `help:"Concurrent range requests for the snapshot download (1 = single streaming request)." default:"1"`
59+
DownloadChunkSizeMB int `help:"Chunk size in MiB for parallel snapshot downloads." default:"8"`
5560
}
5661

5762
func (c *GitRestoreCmd) Run(ctx context.Context, api *client.Client) error {
@@ -72,56 +77,22 @@ func (c *GitRestoreCmd) Run(ctx context.Context, api *client.Client) error {
7277

7378
fmt.Fprintf(os.Stderr, "Fetching snapshot for %s\n", c.RepoURL) //nolint:forbidigo
7479

75-
var snap *client.GitSnapshot
76-
if err := inSpan(ctx, "cachew.download_snapshot",
77-
[]attribute.KeyValue{attribute.String("cachew.repo_url", c.RepoURL)},
78-
func(ctx context.Context) error {
79-
downloadStart := time.Now()
80-
s, err := api.OpenGitSnapshot(ctx, c.RepoURL)
81-
if err != nil {
82-
return err //nolint:wrapcheck // wrapped by caller
83-
}
84-
snap = s
85-
trace.SpanFromContext(ctx).SetAttributes(
86-
attribute.String("cachew.snapshot_commit", s.Commit),
87-
attribute.String("cachew.bundle_url", s.BundleURL),
88-
attribute.Float64("cachew.elapsed_seconds", time.Since(downloadStart).Seconds()),
89-
)
90-
return nil
91-
}); err != nil {
80+
commit, bundleURL, err := c.fetchAndExtractSnapshot(ctx, api)
81+
if err != nil {
9282
if errors.Is(err, os.ErrNotExist) {
9383
return errors.Errorf("no snapshot available for %s", c.RepoURL)
9484
}
9585
span.RecordError(err)
9686
span.SetStatus(codes.Error, err.Error())
97-
return errors.Wrap(err, "fetch snapshot")
98-
}
99-
defer snap.Close()
100-
span.SetAttributes(attribute.String("cachew.snapshot_commit", snap.Commit))
101-
102-
fmt.Fprintf(os.Stderr, "Extracting to %s...\n", c.Directory) //nolint:forbidigo
103-
if err := inSpan(ctx, "cachew.extract",
104-
[]attribute.KeyValue{attribute.String("cachew.directory", c.Directory)},
105-
func(ctx context.Context) error {
106-
extractStart := time.Now()
107-
if err := snapshot.Extract(ctx, snap.Body, c.Directory, c.ZstdThreads); err != nil {
108-
return err //nolint:wrapcheck // wrapped by caller
109-
}
110-
elapsed := time.Since(extractStart)
111-
trace.SpanFromContext(ctx).SetAttributes(attribute.Float64("cachew.elapsed_seconds", elapsed.Seconds()))
112-
fmt.Fprintf(os.Stderr, "Snapshot extracted in %s\n", elapsed) //nolint:forbidigo
113-
return nil
114-
}); err != nil {
115-
span.RecordError(err)
116-
span.SetStatus(codes.Error, err.Error())
117-
return errors.Wrap(err, "extract snapshot")
87+
return errors.Wrap(err, "restore snapshot")
11888
}
89+
span.SetAttributes(attribute.String("cachew.snapshot_commit", commit))
11990
fmt.Fprintf(os.Stderr, "Snapshot restored to %s\n", c.Directory) //nolint:forbidigo
12091

121-
if snap.BundleURL != "" && !c.NoBundle {
92+
if bundleURL != "" && !c.NoBundle {
12293
fmt.Fprintf(os.Stderr, "Applying delta bundle...\n") //nolint:forbidigo
12394
bundleStart := time.Now()
124-
if err := applyBundle(ctx, api, snap.BundleURL, c.Directory); err != nil {
95+
if err := applyBundle(ctx, api, bundleURL, c.Directory); err != nil {
12596
fmt.Fprintf(os.Stderr, "Warning: failed to apply delta bundle: %v\n", err) //nolint:forbidigo
12697
span.RecordError(err)
12798
} else {
@@ -144,6 +115,112 @@ func (c *GitRestoreCmd) Run(ctx context.Context, api *client.Client) error {
144115
return nil
145116
}
146117

118+
// fetchAndExtractSnapshot downloads the snapshot and extracts it into the target
119+
// directory, returning its freshen metadata (commit and bundle URL). With a
120+
// download concurrency above 1 it downloads in parallel into a temp file, since
121+
// ParallelGet needs a WriterAt; otherwise it streams the single response
122+
// directly into extraction.
123+
func (c *GitRestoreCmd) fetchAndExtractSnapshot(ctx context.Context, api *client.Client) (commit, bundleURL string, err error) {
124+
if c.DownloadConcurrency > 1 {
125+
return c.parallelFetchAndExtract(ctx, api)
126+
}
127+
return c.streamFetchAndExtract(ctx, api)
128+
}
129+
130+
// streamFetchAndExtract downloads the snapshot in a single request and pipes the
131+
// response body straight into extraction, overlapping download and extraction.
132+
func (c *GitRestoreCmd) streamFetchAndExtract(ctx context.Context, api *client.Client) (string, string, error) {
133+
var snap *client.GitSnapshot
134+
if err := inSpan(ctx, "cachew.download_snapshot",
135+
[]attribute.KeyValue{attribute.String("cachew.repo_url", c.RepoURL)},
136+
func(ctx context.Context) error {
137+
downloadStart := time.Now()
138+
s, err := api.OpenGitSnapshot(ctx, c.RepoURL)
139+
if err != nil {
140+
return err //nolint:wrapcheck // wrapped by caller
141+
}
142+
snap = s
143+
trace.SpanFromContext(ctx).SetAttributes(
144+
attribute.String("cachew.snapshot_commit", s.Commit),
145+
attribute.String("cachew.bundle_url", s.BundleURL),
146+
attribute.Float64("cachew.elapsed_seconds", time.Since(downloadStart).Seconds()),
147+
)
148+
return nil
149+
}); err != nil {
150+
return "", "", err
151+
}
152+
defer snap.Close()
153+
154+
if err := c.extract(ctx, snap.Body); err != nil {
155+
return "", "", err
156+
}
157+
return snap.Commit, snap.BundleURL, nil
158+
}
159+
160+
// parallelFetchAndExtract downloads the snapshot into a temp file using bounded
161+
// concurrent range requests, then extracts from the file. ParallelGet writes via
162+
// WriteAt so it cannot stream into extraction; the temp file is removed on
163+
// return.
164+
func (c *GitRestoreCmd) parallelFetchAndExtract(ctx context.Context, api *client.Client) (string, string, error) {
165+
tmp, err := os.CreateTemp("", "cachew-snapshot-*.tar.zst")
166+
if err != nil {
167+
return "", "", errors.Wrap(err, "create snapshot temp file")
168+
}
169+
defer func() {
170+
_ = tmp.Close()
171+
_ = os.Remove(tmp.Name()) //nolint:gosec // name is from os.CreateTemp, not external input
172+
}()
173+
174+
var meta client.GitSnapshotMetadata
175+
if err := inSpan(ctx, "cachew.download_snapshot",
176+
[]attribute.KeyValue{
177+
attribute.String("cachew.repo_url", c.RepoURL),
178+
attribute.Int("cachew.download_concurrency", c.DownloadConcurrency),
179+
attribute.Int("cachew.download_chunk_size_mb", c.DownloadChunkSizeMB),
180+
},
181+
func(ctx context.Context) error {
182+
downloadStart := time.Now()
183+
m, err := api.DownloadGitSnapshot(ctx, c.RepoURL, tmp, int64(c.DownloadChunkSizeMB)<<20, c.DownloadConcurrency)
184+
if err != nil {
185+
return err //nolint:wrapcheck // wrapped by caller
186+
}
187+
meta = m
188+
trace.SpanFromContext(ctx).SetAttributes(
189+
attribute.String("cachew.snapshot_commit", m.Commit),
190+
attribute.String("cachew.bundle_url", m.BundleURL),
191+
attribute.Float64("cachew.elapsed_seconds", time.Since(downloadStart).Seconds()),
192+
)
193+
return nil
194+
}); err != nil {
195+
return "", "", err
196+
}
197+
198+
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
199+
return "", "", errors.Wrap(err, "rewind snapshot temp file")
200+
}
201+
if err := c.extract(ctx, tmp); err != nil {
202+
return "", "", err
203+
}
204+
return meta.Commit, meta.BundleURL, nil
205+
}
206+
207+
// extract decompresses and unpacks the snapshot body into the target directory.
208+
func (c *GitRestoreCmd) extract(ctx context.Context, body io.Reader) error {
209+
fmt.Fprintf(os.Stderr, "Extracting to %s...\n", c.Directory) //nolint:forbidigo,gosec // c.Directory is an operator-supplied CLI path
210+
return inSpan(ctx, "cachew.extract",
211+
[]attribute.KeyValue{attribute.String("cachew.directory", c.Directory)},
212+
func(ctx context.Context) error {
213+
extractStart := time.Now()
214+
if err := snapshot.Extract(ctx, body, c.Directory, c.ZstdThreads); err != nil {
215+
return err //nolint:wrapcheck // wrapped by caller
216+
}
217+
elapsed := time.Since(extractStart)
218+
trace.SpanFromContext(ctx).SetAttributes(attribute.Float64("cachew.elapsed_seconds", elapsed.Seconds()))
219+
fmt.Fprintf(os.Stderr, "Snapshot extracted in %s\n", elapsed) //nolint:forbidigo
220+
return nil
221+
})
222+
}
223+
147224
// satisfyRefs ensures the working tree contains every requested ref and
148225
// commit. It short-circuits whenever the local clone already has what was
149226
// asked for, avoiding both /ensure-refs and git pull when the snapshot+bundle

0 commit comments

Comments
 (0)