Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ func (e *HTTPStatusError) Is(target error) bool {
return e.StatusCode == http.StatusNotModified
case ErrPreconditionFailed:
return e.StatusCode == http.StatusPreconditionFailed
case ErrRangeNotSatisfiable:
return e.StatusCode == http.StatusRequestedRangeNotSatisfiable
default:
return false
}
Expand Down Expand Up @@ -174,7 +176,7 @@ func (c *Client) Open(ctx context.Context, key Key, opts ...RequestOption) (io.R
}

switch resp.StatusCode {
case http.StatusOK:
case http.StatusOK, http.StatusPartialContent:
return resp.Body, filterHeaders(resp.Header, transportHeaders...), nil

case http.StatusNotFound:
Expand All @@ -185,6 +187,10 @@ func (c *Client) Open(ctx context.Context, key Key, opts ...RequestOption) (io.R
_, _ = io.Copy(io.Discard, resp.Body) //nolint:errcheck,gosec
return nil, filterHeaders(resp.Header, transportHeaders...), errors.Join(ErrNotModified, resp.Body.Close())

case http.StatusRequestedRangeNotSatisfiable:
_, _ = io.Copy(io.Discard, resp.Body) //nolint:errcheck,gosec
return nil, filterHeaders(resp.Header, transportHeaders...), errors.Join(ErrRangeNotSatisfiable, resp.Body.Close())

case http.StatusPreconditionFailed:
_, _ = io.Copy(io.Discard, resp.Body) //nolint:errcheck,gosec
return nil, nil, errors.Join(ErrPreconditionFailed, resp.Body.Close())
Expand Down
37 changes: 37 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,43 @@ func TestOpenIfMatch(t *testing.T) {
assert.IsError(t, err, client.ErrPreconditionFailed)
}

func TestOpenRange(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/object/{namespace}/{key}", func(w http.ResponseWriter, r *http.Request) {
switch r.Header.Get("Range") {
case "bytes=0-3":
w.Header().Set("Content-Range", "bytes 0-3/10")
w.Header().Set("Content-Length", "4")
w.WriteHeader(http.StatusPartialContent)
w.Write([]byte("0123")) //nolint:errcheck
case "bytes=50-60":
w.Header().Set("Content-Range", "bytes */10")
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
default:
http.Error(w, "unexpected range", http.StatusBadRequest)
}
})
srv := httptest.NewServer(mux)
defer srv.Close()

c := client.New(srv.URL, nil).Namespace("test")
defer c.Close()
ctx := t.Context()
key := client.NewKey("range-test")

rc, headers, err := c.Open(ctx, key, client.Range(0, 4))
assert.NoError(t, err)
data, readErr := io.ReadAll(rc)
assert.NoError(t, readErr)
assert.NoError(t, rc.Close())
assert.Equal(t, "0123", string(data))
assert.Equal(t, "bytes 0-3/10", headers.Get("Content-Range"))

_, headers, err = c.Open(ctx, key, client.Range(50, 61))
assert.IsError(t, err, client.ErrRangeNotSatisfiable)
assert.Equal(t, "bytes */10", headers.Get("Content-Range"))
}

func TestParseKey(t *testing.T) {
tests := []struct {
name string
Expand Down
129 changes: 129 additions & 0 deletions client/preconditions.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package client

import (
"fmt"
"net/http"
"strconv"
"strings"

"github.com/alecthomas/errors"
Expand All @@ -16,6 +18,11 @@ var ErrNotModified = errors.New("not modified")
// Over HTTP this corresponds to 412 Precondition Failed.
var ErrPreconditionFailed = errors.New("precondition failed")

// ErrRangeNotSatisfiable is returned when a Range precondition cannot be
// satisfied against the stored object. Over HTTP this corresponds to 416 Range
// Not Satisfiable.
var ErrRangeNotSatisfiable = errors.New("range not satisfiable")

// RequestOptions holds conditional-request parameters. It is the single
// representation shared by the client wire protocol, the cache backends, and
// the server handlers.
Expand All @@ -26,6 +33,14 @@ type RequestOptions struct {
// IfNoneMatch is the If-None-Match precondition. Evaluation reports
// ErrNotModified when the stored ETag matches.
IfNoneMatch string
// Range is a raw HTTP Range header value (e.g. "bytes=0-499"). Only a
// single byte range is supported; multi-range or invalid specifiers are
// ignored and the full representation is served.
Range string
// IfRange gates Range on the stored ETag: the range is only applied when
// IfRange matches the stored ETag, otherwise the full representation is
// served. Only the entity-tag form is supported.
IfRange string
}

// RequestOption configures conditional request parameters.
Expand All @@ -41,6 +56,31 @@ func IfNoneMatch(etag string) RequestOption {
return func(o *RequestOptions) { o.IfNoneMatch = etag }
}

// Range requests a single half-open byte range [start, end) from Open. A
// negative end means "to the end of the object" (its Content-Length). For
// example Range(0, 500) requests the first 500 bytes and Range(0, -1) the whole
// object. Open returns the matching bytes with a Content-Range header, or
// ErrRangeNotSatisfiable if the range lies outside the object.
func Range(start, end int64) RequestOption {
spec := formatByteRange(start, end)
return func(o *RequestOptions) { o.Range = spec }
}

// formatByteRange renders a half-open [start, end) range as an HTTP byte-range
// specifier. A negative end yields an open-ended range to the end of the object.
func formatByteRange(start, end int64) string {
if end < 0 {
return fmt.Sprintf("bytes=%d-", start)
}
return fmt.Sprintf("bytes=%d-%d", start, end-1)
}

// IfRange sets the If-Range precondition: the Range is only honoured when etag
// matches the stored ETag, otherwise the full representation is served.
func IfRange(etag string) RequestOption {
return func(o *RequestOptions) { o.IfRange = etag }
}

// NewRequestOptions applies opts and returns the resulting RequestOptions.
func NewRequestOptions(opts ...RequestOption) RequestOptions {
var o RequestOptions
Expand Down Expand Up @@ -71,6 +111,95 @@ func (o RequestOptions) applyToRequest(req *http.Request) {
if o.IfNoneMatch != "" {
req.Header.Set("If-None-Match", o.IfNoneMatch)
}
if o.Range != "" {
req.Header.Set("Range", o.Range)
}
if o.IfRange != "" {
req.Header.Set("If-Range", o.IfRange)
}
}

// RangeOutcome classifies how a Range request should be answered.
type RangeOutcome int

const (
// RangeFull indicates the full representation should be served (no Range,
// an unmatched If-Range, or an unsupported/invalid specifier).
RangeFull RangeOutcome = iota
// RangePartial indicates a single satisfiable byte range.
RangePartial
// RangeNotSatisfiable indicates the range lies outside the object.
RangeNotSatisfiable
)

// ResolveRange evaluates the Range/If-Range options against an object of the
// given size and ETag. On RangePartial it returns the [start, start+length)
// window to serve.
func (o RequestOptions) ResolveRange(size int64, etag string) (start, length int64, outcome RangeOutcome) {
if o.Range == "" {
return 0, size, RangeFull
}
// If-Range only applies the range when its validator matches the stored
// ETag; otherwise the client is told to serve the full representation.
if o.IfRange != "" && o.IfRange != etag {
return 0, size, RangeFull
}
return resolveByteRange(o.Range, size)
}

// resolveByteRange parses a single HTTP byte-range specifier against size.
// Multi-range and syntactically invalid specifiers yield RangeFull so the
// caller serves the full representation.
func resolveByteRange(spec string, size int64) (start, length int64, outcome RangeOutcome) {
const prefix = "bytes="
if !strings.HasPrefix(spec, prefix) {
return 0, size, RangeFull
}
spec = strings.TrimSpace(spec[len(prefix):])
if spec == "" || strings.ContainsRune(spec, ',') {
return 0, size, RangeFull
}
startStr, endStr, ok := strings.Cut(spec, "-")
if !ok {
return 0, size, RangeFull
}
startStr = strings.TrimSpace(startStr)
endStr = strings.TrimSpace(endStr)

if startStr == "" {
// Suffix range "-N": the final N bytes.
n, err := strconv.ParseInt(endStr, 10, 64)
if err != nil {
return 0, size, RangeFull
}
if n <= 0 || size == 0 {
return 0, size, RangeNotSatisfiable
}
if n > size {
n = size
}
return size - n, n, RangePartial
}

start, err := strconv.ParseInt(startStr, 10, 64)
if err != nil || start < 0 {
return 0, size, RangeFull
}
if start >= size {
return 0, size, RangeNotSatisfiable
}
if endStr == "" {
// Open range "START-": to the end of the object.
return start, size - start, RangePartial
}
end, err := strconv.ParseInt(endStr, 10, 64)
if err != nil || end < start {
return 0, size, RangeFull
}
if end >= size {
end = size - 1
}
return start, end - start + 1, RangePartial
}

// etagListMatches reports whether etag matches an If-Match / If-None-Match
Expand Down
73 changes: 73 additions & 0 deletions client/preconditions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package client_test

import (
"testing"

"github.com/alecthomas/assert/v2"

"github.com/block/cachew/client"
)

func TestRangeFormat(t *testing.T) {
tests := []struct {
name string
start, end int64
want string
}{
{name: "FirstN", start: 0, end: 500, want: "bytes=0-499"},
{name: "Middle", start: 100, end: 200, want: "bytes=100-199"},
{name: "ToEnd", start: 0, end: -1, want: "bytes=0-"},
{name: "FromOffsetToEnd", start: 100, end: -1, want: "bytes=100-"},
{name: "Single", start: 5, end: 6, want: "bytes=5-5"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := client.NewRequestOptions(client.Range(tt.start, tt.end))
assert.Equal(t, tt.want, o.Range)
})
}
}

func TestResolveRange(t *testing.T) {
const etag = `"e"`
tests := []struct {
name string
spec string
ifRange string
size int64
wantStart int64
wantLength int64
wantOutcome client.RangeOutcome
}{
{name: "NoRange", spec: "", size: 10, wantStart: 0, wantLength: 10, wantOutcome: client.RangeFull},
{name: "FirstBytes", spec: "bytes=0-4", size: 10, wantStart: 0, wantLength: 5, wantOutcome: client.RangePartial},
{name: "Middle", spec: "bytes=2-5", size: 10, wantStart: 2, wantLength: 4, wantOutcome: client.RangePartial},
{name: "OpenEnded", spec: "bytes=3-", size: 10, wantStart: 3, wantLength: 7, wantOutcome: client.RangePartial},
{name: "Suffix", spec: "bytes=-3", size: 10, wantStart: 7, wantLength: 3, wantOutcome: client.RangePartial},
{name: "SuffixLargerThanSize", spec: "bytes=-20", size: 10, wantStart: 0, wantLength: 10, wantOutcome: client.RangePartial},
{name: "EndBeyondSize", spec: "bytes=5-100", size: 10, wantStart: 5, wantLength: 5, wantOutcome: client.RangePartial},
{name: "StartAtSize", spec: "bytes=10-20", size: 10, wantOutcome: client.RangeNotSatisfiable},
{name: "StartBeyondSize", spec: "bytes=20-", size: 10, wantOutcome: client.RangeNotSatisfiable},
{name: "SuffixZero", spec: "bytes=-0", size: 10, wantOutcome: client.RangeNotSatisfiable},
{name: "ZeroSizeSuffix", spec: "bytes=-1", size: 0, wantOutcome: client.RangeNotSatisfiable},
{name: "ZeroSizeRange", spec: "bytes=0-0", size: 0, wantOutcome: client.RangeNotSatisfiable},
{name: "Multi", spec: "bytes=0-1,3-4", size: 10, wantLength: 10, wantOutcome: client.RangeFull},
{name: "MissingPrefix", spec: "0-4", size: 10, wantLength: 10, wantOutcome: client.RangeFull},
{name: "StartGreaterThanEnd", spec: "bytes=5-2", size: 10, wantLength: 10, wantOutcome: client.RangeFull},
{name: "NonNumeric", spec: "bytes=a-b", size: 10, wantLength: 10, wantOutcome: client.RangeFull},
{name: "IfRangeMatch", spec: "bytes=0-4", ifRange: etag, size: 10, wantStart: 0, wantLength: 5, wantOutcome: client.RangePartial},
{name: "IfRangeMismatch", spec: "bytes=0-4", ifRange: `"other"`, size: 10, wantLength: 10, wantOutcome: client.RangeFull},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := client.RequestOptions{Range: tt.spec, IfRange: tt.ifRange}
start, length, outcome := o.ResolveRange(tt.size, etag)
assert.Equal(t, tt.wantOutcome, outcome)
if outcome == client.RangeNotSatisfiable {
return
}
assert.Equal(t, tt.wantStart, start)
assert.Equal(t, tt.wantLength, length)
})
}
}
39 changes: 39 additions & 0 deletions internal/cache/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@ var ErrNotFound = errors.New("cache backend not found")
// Option configures conditional parameters on a cache Open or Stat.
type Option = client.RequestOption

// RequestOptions is the resolved set of conditional and range parameters for an
// Open or Stat.
type RequestOptions = client.RequestOptions

// NewRequestOptions applies opts and returns the resulting RequestOptions.
func NewRequestOptions(opts ...Option) RequestOptions { return client.NewRequestOptions(opts...) }

// RangeOutcome classifies how a Range request should be answered, as returned by
// RequestOptions.ResolveRange.
type RangeOutcome = client.RangeOutcome

const (
// RangeFull indicates the full object should be served.
RangeFull = client.RangeFull
// RangePartial indicates a single satisfiable byte range.
RangePartial = client.RangePartial
// RangeNotSatisfiable indicates the range lies outside the object.
RangeNotSatisfiable = client.RangeNotSatisfiable
)

// IfMatch sets the If-Match precondition. Open/Stat return ErrPreconditionFailed
// if the stored ETag does not match.
func IfMatch(etag string) Option { return client.IfMatch(etag) }
Expand All @@ -42,6 +62,16 @@ func IfMatch(etag string) Option { return client.IfMatch(etag) }
// ErrNotModified when the stored ETag matches.
func IfNoneMatch(etag string) Option { return client.IfNoneMatch(etag) }

// Range requests a single half-open byte range [start, end) from Open. A
// negative end means "to the end of the object". The returned headers carry a
// Content-Range; Open returns ErrRangeNotSatisfiable if the range lies outside
// the object. Stat ignores Range.
func Range(start, end int64) Option { return client.Range(start, end) }

// IfRange gates Range on the stored ETag: the range is only applied when etag
// matches, otherwise the full object is returned.
func IfRange(etag string) Option { return client.IfRange(etag) }

// ErrNotModified is returned by Open/Stat when an If-None-Match precondition is
// satisfied.
var ErrNotModified = client.ErrNotModified
Expand All @@ -50,6 +80,10 @@ var ErrNotModified = client.ErrNotModified
// is not met.
var ErrPreconditionFailed = client.ErrPreconditionFailed

// ErrRangeNotSatisfiable is returned by Open when a requested Range lies outside
// the object.
var ErrRangeNotSatisfiable = client.ErrRangeNotSatisfiable

// ErrStatsUnavailable is returned when a cache backend cannot provide statistics.
var ErrStatsUnavailable = client.ErrStatsUnavailable

Expand Down Expand Up @@ -166,6 +200,11 @@ type Cache interface {
// Conditional opts are evaluated against the stored ETag: a satisfied
// If-None-Match returns ErrNotModified (with headers, no body); a failed
// If-Match returns ErrPreconditionFailed.
//
// A Range opt requests a single byte range: on success the returned
// headers carry Content-Range and a Content-Length of the range, and the
// reader yields only those bytes. A range outside the object returns
// ErrRangeNotSatisfiable (with headers carrying Content-Range: bytes */N).
Open(ctx context.Context, key Key, opts ...Option) (io.ReadCloser, http.Header, error)
// Create a new file in the cache.
//
Expand Down
Loading