@@ -320,7 +320,24 @@ func (s *Strategy) handleSnapshotRequest(w http.ResponseWriter, r *http.Request,
320320 }
321321 s .maybeBackgroundFetch (repo )
322322
323- reader , headers , err := s .cache .Open (ctx , cacheKey )
323+ // Forward only Range/If-Range here; If-Match/If-None-Match are evaluated
324+ // against the served body below via CheckConditionals.
325+ reader , headers , err := s .cache .Open (ctx , cacheKey , httputil .RangeOptions (r )... )
326+ if errors .Is (err , cache .ErrRangeNotSatisfiable ) {
327+ // A failed If-Match (412) or satisfied If-None-Match (304) takes
328+ // precedence over an unsatisfiable range (RFC 7232 §3, RFC 7233 §3.1).
329+ if status := httputil .CheckConditionals (r , headers .Get (cache .ETagKey )); status != 0 {
330+ w .WriteHeader (status )
331+ return
332+ }
333+ w .Header ().Set ("Content-Type" , "application/zstd" )
334+ w .Header ().Set ("Accept-Ranges" , "bytes" )
335+ if cr := headers .Get ("Content-Range" ); cr != "" {
336+ w .Header ().Set ("Content-Range" , cr )
337+ }
338+ w .WriteHeader (http .StatusRequestedRangeNotSatisfiable )
339+ return
340+ }
324341 if err != nil && ! errors .Is (err , os .ErrNotExist ) {
325342 logger .ErrorContext (ctx , "Failed to open snapshot from cache" , "upstream" , upstreamURL , "error" , err )
326343 http .Error (w , "Internal server error" , http .StatusInternalServerError )
@@ -517,23 +534,42 @@ func (s *Strategy) handleBundleRequest(w http.ResponseWriter, r *http.Request, h
517534}
518535
519536func (s * Strategy ) serveSnapshotWithBundle (ctx context.Context , w http.ResponseWriter , r * http.Request , reader io.ReadCloser , headers http.Header , repo * gitclone.Repository , upstreamURL , repoName string , start time.Time ) error {
520- snapshotCommit := headers .Get ("X-Cachew-Snapshot-Commit" )
521- mirrorHead := s .getMirrorHead (ctx , repo )
522-
523- // Forward the snapshot commit to the client so it knows whether the
524- // snapshot is fresh (no bundle URL = already at HEAD, skip freshen).
525- if snapshotCommit != "" {
526- w .Header ().Set ("X-Cachew-Snapshot-Commit" , snapshotCommit )
537+ // If-Match/If-None-Match are evaluated before serving any body: a satisfied
538+ // If-None-Match (304) or a failed If-Match (412) takes precedence over a
539+ // range response, so revalidating clients are not handed a 206 they would
540+ // have to discard (RFC 7232 §3, RFC 7233 §3.1).
541+ if status := httputil .CheckConditionals (r , headers .Get (cache .ETagKey )); status != 0 {
542+ w .WriteHeader (status )
543+ s .metrics .recordSnapshotServe (ctx , "cache" , repoName , 0 , time .Since (start ))
544+ if span := trace .SpanFromContext (ctx ); span .SpanContext ().IsValid () {
545+ span .SetAttributes (attribute .String ("cachew.source" , "cache" ), attribute .Int64 ("cachew.bytes" , 0 ))
546+ }
547+ return nil
527548 }
528549
529- if snapshotCommit != "" && mirrorHead != "" && snapshotCommit != mirrorHead {
530- repoPath , err := gitclone .RepoPathFromURL (upstreamURL )
531- if err == nil {
532- bundleURL := fmt .Sprintf ("/git/%s/snapshot.bundle?base=%s" , repoPath , snapshotCommit )
533- w .Header ().Set ("X-Cachew-Bundle-Url" , bundleURL )
550+ // A satisfied byte range is served as-is. Bundle negotiation applies only to
551+ // whole-snapshot downloads, so a partial read (e.g. a client.ParallelGet
552+ // chunk) skips it and returns 206 directly.
553+ if cr := headers .Get ("Content-Range" ); cr != "" {
554+ applySnapshotCacheHeaders (w , headers )
555+ w .Header ().Set ("Accept-Ranges" , "bytes" )
556+ w .Header ().Set ("Content-Range" , cr )
557+ // Ranged clients (client.ParallelGet) read the freshen metadata from the
558+ // discovery chunk, so it must be present on partial responses too.
559+ s .setSnapshotMetadataHeaders (ctx , w , headers , repo , upstreamURL )
560+ w .WriteHeader (http .StatusPartialContent )
561+ n , err := io .Copy (w , reader )
562+ s .metrics .recordSnapshotServe (ctx , "cache_range" , repoName , n , time .Since (start ))
563+ if span := trace .SpanFromContext (ctx ); span .SpanContext ().IsValid () {
564+ span .SetAttributes (attribute .String ("cachew.source" , "cache_range" ), attribute .Int64 ("cachew.bytes" , n ))
534565 }
566+ return errors .Wrap (err , "stream snapshot range" )
567+ }
568+
569+ snapshotCommit , bundleURL := s .setSnapshotMetadataHeaders (ctx , w , headers , repo , upstreamURL )
535570
536- // Proactively generate and cache the bundle so any pod can serve it.
571+ // Proactively generate and cache the advertised bundle so any pod can serve it.
572+ if bundleURL != "" {
537573 go func () {
538574 bgCtx := context .WithoutCancel (ctx )
539575 logger := logging .FromContext (bgCtx )
@@ -550,25 +586,43 @@ func (s *Strategy) serveSnapshotWithBundle(ctx context.Context, w http.ResponseW
550586 }
551587
552588 applySnapshotCacheHeaders (w , headers )
589+ w .Header ().Set ("Accept-Ranges" , "bytes" )
553590
554- // Honour conditional GETs against the advertised ETag. ServeContent does this
555- // natively for *os.File readers, but cache backends returning non-file readers
556- // (S3, memory, remote) fall through to io.Copy, so revalidate explicitly to
557- // avoid streaming the full snapshot when the client already has it.
558- var n int64
559- var err error
560- if status := httputil .CheckConditionals (r , headers .Get (cache .ETagKey )); status != 0 {
561- w .WriteHeader (status )
562- } else {
563- n , err = serveReaderFast (w , r , reader )
564- }
591+ n , err := serveReaderFast (w , r , reader )
565592 s .metrics .recordSnapshotServe (ctx , "cache" , repoName , n , time .Since (start ))
566593 if span := trace .SpanFromContext (ctx ); span .SpanContext ().IsValid () {
567594 span .SetAttributes (attribute .String ("cachew.source" , "cache" ), attribute .Int64 ("cachew.bytes" , n ))
568595 }
569596 return errors .Wrap (err , "stream snapshot" )
570597}
571598
599+ // setSnapshotMetadataHeaders advertises the snapshot's commit and, when the
600+ // snapshot trails the mirror's HEAD, the delta-bundle URL clients use to
601+ // fast-forward. Shared by the full and ranged serve paths so ranged clients
602+ // (client.ParallelGet) receive the same freshen metadata on the discovery
603+ // chunk. It returns the snapshot commit and the bundle URL it set (empty when
604+ // the snapshot is already at HEAD), so callers can decide whether to
605+ // pre-generate the bundle.
606+ func (s * Strategy ) setSnapshotMetadataHeaders (ctx context.Context , w http.ResponseWriter , headers http.Header , repo * gitclone.Repository , upstreamURL string ) (snapshotCommit , bundleURL string ) {
607+ snapshotCommit = headers .Get ("X-Cachew-Snapshot-Commit" )
608+ if snapshotCommit == "" {
609+ return "" , ""
610+ }
611+ w .Header ().Set ("X-Cachew-Snapshot-Commit" , snapshotCommit )
612+
613+ mirrorHead := s .getMirrorHead (ctx , repo )
614+ if mirrorHead == "" || snapshotCommit == mirrorHead {
615+ return snapshotCommit , ""
616+ }
617+ repoPath , err := gitclone .RepoPathFromURL (upstreamURL )
618+ if err != nil {
619+ return snapshotCommit , ""
620+ }
621+ bundleURL = fmt .Sprintf ("/git/%s/snapshot.bundle?base=%s" , repoPath , snapshotCommit )
622+ w .Header ().Set ("X-Cachew-Bundle-Url" , bundleURL )
623+ return snapshotCommit , bundleURL
624+ }
625+
572626// applySnapshotCacheHeaders forwards the cached snapshot's validators so clients
573627// can revalidate (ETag) and size the transfer (Content-Length). Content-Type is
574628// fixed for snapshots regardless of what the cache backend recorded.
0 commit comments