Stream metadata cache hits for ProxyCached
With cache_metadata on, every metadata cache hit read the whole stored
document into its own buffer before any coalescing, since
coalesceMetadata only covers upstream misses. metadata_max_size bounds
one request, not the sum: 30 concurrent hits on a 20 MB document held
about 820 MB.
The handlers that go through ProxyCached serve the stored document
unchanged, so a hit there no longer needs the bytes in memory.
serveCachedMetadata streams the blob from storage to the response
instead, with the row's Content-Type, Content-Encoding, size, ETag and
Last-Modified, and answers If-None-Match and If-Modified-Since with 304
as before. The bytes are checked against the row's content digest as
they are sent, and a mismatch aborts the response like a corrupt cached
artifact. A row without a digest or size, a document over the size
limit, or a blob that can't be opened takes the buffered path as before.
Handlers that rewrite or filter metadata keep reading it into memory,
and the rewrite cache stays in front of them. writeMetadataValidators
is split out of writeMetadataCachedResponseWithEncoding so both paths
share the conditional request handling.
2026-10-24 13:13:12 +02:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"slices"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
2026-10-25 12:32:20 +01:00
|
|
|
"sync/atomic"
|
Stream metadata cache hits for ProxyCached
With cache_metadata on, every metadata cache hit read the whole stored
document into its own buffer before any coalescing, since
coalesceMetadata only covers upstream misses. metadata_max_size bounds
one request, not the sum: 30 concurrent hits on a 20 MB document held
about 820 MB.
The handlers that go through ProxyCached serve the stored document
unchanged, so a hit there no longer needs the bytes in memory.
serveCachedMetadata streams the blob from storage to the response
instead, with the row's Content-Type, Content-Encoding, size, ETag and
Last-Modified, and answers If-None-Match and If-Modified-Since with 304
as before. The bytes are checked against the row's content digest as
they are sent, and a mismatch aborts the response like a corrupt cached
artifact. A row without a digest or size, a document over the size
limit, or a blob that can't be opened takes the buffered path as before.
Handlers that rewrite or filter metadata keep reading it into memory,
and the rewrite cache stays in front of them. writeMetadataValidators
is split out of writeMetadataCachedResponseWithEncoding so both paths
share the conditional request handling.
2026-10-24 13:13:12 +02:00
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/storage"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// stallingStorage hands out cached metadata readers that return the first
|
|
|
|
|
// head bytes and then block until release closes, so a test can see whether a
|
|
|
|
|
// response starts before the whole document has been read.
|
|
|
|
|
type stallingStorage struct {
|
|
|
|
|
storage.Storage
|
|
|
|
|
head int
|
|
|
|
|
release chan struct{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *stallingStorage) Open(ctx context.Context, path string) (io.ReadCloser, error) {
|
|
|
|
|
rc, err := s.Storage.Open(ctx, path)
|
|
|
|
|
if err != nil || !strings.HasPrefix(path, "_metadata/") {
|
|
|
|
|
return rc, err
|
|
|
|
|
}
|
|
|
|
|
return &stallingReader{ReadCloser: rc, left: s.head, release: s.release}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type stallingReader struct {
|
|
|
|
|
io.ReadCloser
|
|
|
|
|
left int
|
|
|
|
|
release chan struct{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *stallingReader) Read(p []byte) (int, error) {
|
|
|
|
|
if r.left == 0 {
|
|
|
|
|
<-r.release
|
|
|
|
|
return r.ReadCloser.Read(p)
|
|
|
|
|
}
|
|
|
|
|
if len(p) > r.left {
|
|
|
|
|
p = p[:r.left]
|
|
|
|
|
}
|
|
|
|
|
n, err := r.ReadCloser.Read(p)
|
|
|
|
|
r.left -= n
|
|
|
|
|
return n, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-25 12:32:20 +01:00
|
|
|
// cachedMetadataServer serves doc through ProxyCached and caches it with one
|
|
|
|
|
// request. upstreamHits counts the requests that reached the upstream.
|
|
|
|
|
func cachedMetadataServer(t *testing.T, doc []byte) (proxy *Proxy, store *mockStorage, srv *httptest.Server, upstreamHits *atomic.Int32) {
|
Stream metadata cache hits for ProxyCached
With cache_metadata on, every metadata cache hit read the whole stored
document into its own buffer before any coalescing, since
coalesceMetadata only covers upstream misses. metadata_max_size bounds
one request, not the sum: 30 concurrent hits on a 20 MB document held
about 820 MB.
The handlers that go through ProxyCached serve the stored document
unchanged, so a hit there no longer needs the bytes in memory.
serveCachedMetadata streams the blob from storage to the response
instead, with the row's Content-Type, Content-Encoding, size, ETag and
Last-Modified, and answers If-None-Match and If-Modified-Since with 304
as before. The bytes are checked against the row's content digest as
they are sent, and a mismatch aborts the response like a corrupt cached
artifact. A row without a digest or size, a document over the size
limit, or a blob that can't be opened takes the buffered path as before.
Handlers that rewrite or filter metadata keep reading it into memory,
and the rewrite cache stays in front of them. writeMetadataValidators
is split out of writeMetadataCachedResponseWithEncoding so both paths
share the conditional request handling.
2026-10-24 13:13:12 +02:00
|
|
|
t.Helper()
|
2026-10-25 12:32:20 +01:00
|
|
|
upstreamHits = &atomic.Int32{}
|
Stream metadata cache hits for ProxyCached
With cache_metadata on, every metadata cache hit read the whole stored
document into its own buffer before any coalescing, since
coalesceMetadata only covers upstream misses. metadata_max_size bounds
one request, not the sum: 30 concurrent hits on a 20 MB document held
about 820 MB.
The handlers that go through ProxyCached serve the stored document
unchanged, so a hit there no longer needs the bytes in memory.
serveCachedMetadata streams the blob from storage to the response
instead, with the row's Content-Type, Content-Encoding, size, ETag and
Last-Modified, and answers If-None-Match and If-Modified-Since with 304
as before. The bytes are checked against the row's content digest as
they are sent, and a mismatch aborts the response like a corrupt cached
artifact. A row without a digest or size, a document over the size
limit, or a blob that can't be opened takes the buffered path as before.
Handlers that rewrite or filter metadata keep reading it into memory,
and the rewrite cache stays in front of them. writeMetadataValidators
is split out of writeMetadataCachedResponseWithEncoding so both paths
share the conditional request handling.
2026-10-24 13:13:12 +02:00
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
2026-10-25 12:32:20 +01:00
|
|
|
upstreamHits.Add(1)
|
Stream metadata cache hits for ProxyCached
With cache_metadata on, every metadata cache hit read the whole stored
document into its own buffer before any coalescing, since
coalesceMetadata only covers upstream misses. metadata_max_size bounds
one request, not the sum: 30 concurrent hits on a 20 MB document held
about 820 MB.
The handlers that go through ProxyCached serve the stored document
unchanged, so a hit there no longer needs the bytes in memory.
serveCachedMetadata streams the blob from storage to the response
instead, with the row's Content-Type, Content-Encoding, size, ETag and
Last-Modified, and answers If-None-Match and If-Modified-Since with 304
as before. The bytes are checked against the row's content digest as
they are sent, and a mismatch aborts the response like a corrupt cached
artifact. A row without a digest or size, a document over the size
limit, or a blob that can't be opened takes the buffered path as before.
Handlers that rewrite or filter metadata keep reading it into memory,
and the rewrite cache stays in front of them. writeMetadataValidators
is split out of writeMetadataCachedResponseWithEncoding so both paths
share the conditional request handling.
2026-10-24 13:13:12 +02:00
|
|
|
w.Header().Set(headerContentType, "application/x-test")
|
|
|
|
|
w.Header().Set(headerETag, `"v1"`)
|
|
|
|
|
_, _ = w.Write(doc)
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(upstream.Close)
|
|
|
|
|
|
2026-10-25 12:32:20 +01:00
|
|
|
proxy, _, store, _ = setupTestProxy(t)
|
Stream metadata cache hits for ProxyCached
With cache_metadata on, every metadata cache hit read the whole stored
document into its own buffer before any coalescing, since
coalesceMetadata only covers upstream misses. metadata_max_size bounds
one request, not the sum: 30 concurrent hits on a 20 MB document held
about 820 MB.
The handlers that go through ProxyCached serve the stored document
unchanged, so a hit there no longer needs the bytes in memory.
serveCachedMetadata streams the blob from storage to the response
instead, with the row's Content-Type, Content-Encoding, size, ETag and
Last-Modified, and answers If-None-Match and If-Modified-Since with 304
as before. The bytes are checked against the row's content digest as
they are sent, and a mismatch aborts the response like a corrupt cached
artifact. A row without a digest or size, a document over the size
limit, or a blob that can't be opened takes the buffered path as before.
Handlers that rewrite or filter metadata keep reading it into memory,
and the rewrite cache stays in front of them. writeMetadataValidators
is split out of writeMetadataCachedResponseWithEncoding so both paths
share the conditional request handling.
2026-10-24 13:13:12 +02:00
|
|
|
proxy.CacheMetadata = true
|
|
|
|
|
proxy.MetadataTTL = time.Hour
|
|
|
|
|
proxy.HTTPClient = upstream.Client()
|
2026-10-25 12:32:20 +01:00
|
|
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
Stream metadata cache hits for ProxyCached
With cache_metadata on, every metadata cache hit read the whole stored
document into its own buffer before any coalescing, since
coalesceMetadata only covers upstream misses. metadata_max_size bounds
one request, not the sum: 30 concurrent hits on a 20 MB document held
about 820 MB.
The handlers that go through ProxyCached serve the stored document
unchanged, so a hit there no longer needs the bytes in memory.
serveCachedMetadata streams the blob from storage to the response
instead, with the row's Content-Type, Content-Encoding, size, ETag and
Last-Modified, and answers If-None-Match and If-Modified-Since with 304
as before. The bytes are checked against the row's content digest as
they are sent, and a mismatch aborts the response like a corrupt cached
artifact. A row without a digest or size, a document over the size
limit, or a blob that can't be opened takes the buffered path as before.
Handlers that rewrite or filter metadata keep reading it into memory,
and the rewrite cache stays in front of them. writeMetadataValidators
is split out of writeMetadataCachedResponseWithEncoding so both paths
share the conditional request handling.
2026-10-24 13:13:12 +02:00
|
|
|
proxy.ProxyCached(w, r, upstream.URL+"/doc", "test", "doc", "*/*")
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
|
|
|
|
|
|
resp, err := http.Get(srv.URL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("populating cache: %v", err)
|
|
|
|
|
}
|
|
|
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
t.Fatalf("populating cache: status %d", resp.StatusCode)
|
|
|
|
|
}
|
2026-10-25 12:32:20 +01:00
|
|
|
return proxy, store, srv, upstreamHits
|
Stream metadata cache hits for ProxyCached
With cache_metadata on, every metadata cache hit read the whole stored
document into its own buffer before any coalescing, since
coalesceMetadata only covers upstream misses. metadata_max_size bounds
one request, not the sum: 30 concurrent hits on a 20 MB document held
about 820 MB.
The handlers that go through ProxyCached serve the stored document
unchanged, so a hit there no longer needs the bytes in memory.
serveCachedMetadata streams the blob from storage to the response
instead, with the row's Content-Type, Content-Encoding, size, ETag and
Last-Modified, and answers If-None-Match and If-Modified-Since with 304
as before. The bytes are checked against the row's content digest as
they are sent, and a mismatch aborts the response like a corrupt cached
artifact. A row without a digest or size, a document over the size
limit, or a blob that can't be opened takes the buffered path as before.
Handlers that rewrite or filter metadata keep reading it into memory,
and the rewrite cache stays in front of them. writeMetadataValidators
is split out of writeMetadataCachedResponseWithEncoding so both paths
share the conditional request handling.
2026-10-24 13:13:12 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConcurrentMetadataCacheHitsStream(t *testing.T) {
|
|
|
|
|
const callers = 10
|
|
|
|
|
doc := bytes.Repeat([]byte("0123456789abcdef"), 1<<18) // 4 MB
|
2026-10-25 12:32:20 +01:00
|
|
|
proxy, store, srv, _ := cachedMetadataServer(t, doc)
|
Stream metadata cache hits for ProxyCached
With cache_metadata on, every metadata cache hit read the whole stored
document into its own buffer before any coalescing, since
coalesceMetadata only covers upstream misses. metadata_max_size bounds
one request, not the sum: 30 concurrent hits on a 20 MB document held
about 820 MB.
The handlers that go through ProxyCached serve the stored document
unchanged, so a hit there no longer needs the bytes in memory.
serveCachedMetadata streams the blob from storage to the response
instead, with the row's Content-Type, Content-Encoding, size, ETag and
Last-Modified, and answers If-None-Match and If-Modified-Since with 304
as before. The bytes are checked against the row's content digest as
they are sent, and a mismatch aborts the response like a corrupt cached
artifact. A row without a digest or size, a document over the size
limit, or a blob that can't be opened takes the buffered path as before.
Handlers that rewrite or filter metadata keep reading it into memory,
and the rewrite cache stays in front of them. writeMetadataValidators
is split out of writeMetadataCachedResponseWithEncoding so both paths
share the conditional request handling.
2026-10-24 13:13:12 +02:00
|
|
|
|
|
|
|
|
gate := &stallingStorage{Storage: store, head: 64 << 10, release: make(chan struct{})}
|
|
|
|
|
proxy.Storage = gate
|
|
|
|
|
var releaseOnce sync.Once
|
|
|
|
|
release := func() { releaseOnce.Do(func() { close(gate.release) }) }
|
|
|
|
|
t.Cleanup(release)
|
|
|
|
|
|
|
|
|
|
// Every hit has to start its response while its reader is stalled after
|
|
|
|
|
// the first 64 KB. A hit that reads the document into memory first can't
|
|
|
|
|
// send anything until release.
|
|
|
|
|
type result struct {
|
|
|
|
|
resp *http.Response
|
|
|
|
|
first []byte
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
started := make(chan result, callers)
|
|
|
|
|
for range callers {
|
|
|
|
|
go func() {
|
|
|
|
|
resp, err := http.Get(srv.URL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
started <- result{err: err}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
first := make([]byte, 1024)
|
|
|
|
|
_, err = io.ReadFull(resp.Body, first)
|
|
|
|
|
started <- result{resp: resp, first: first, err: err}
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
results := make([]result, 0, callers)
|
|
|
|
|
timeout := time.After(5 * time.Second)
|
|
|
|
|
streamed := 0
|
|
|
|
|
for len(results) < callers {
|
|
|
|
|
select {
|
|
|
|
|
case res := <-started:
|
|
|
|
|
results = append(results, res)
|
|
|
|
|
case <-timeout:
|
|
|
|
|
// Let the stalled hits finish, so the server can shut down.
|
|
|
|
|
release()
|
|
|
|
|
timeout = nil
|
|
|
|
|
streamed = len(results)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
release()
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
for _, res := range results {
|
|
|
|
|
if res.resp != nil {
|
|
|
|
|
_ = res.resp.Body.Close()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
if timeout == nil {
|
|
|
|
|
t.Fatalf("%d of %d concurrent cache hits started their response before the document was read to its end", streamed, callers)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, res := range results {
|
|
|
|
|
if res.err != nil {
|
|
|
|
|
t.Fatalf("cache hit: %v", res.err)
|
|
|
|
|
}
|
|
|
|
|
rest, err := io.ReadAll(res.resp.Body)
|
|
|
|
|
_ = res.resp.Body.Close()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("reading cache hit: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if body := slices.Concat(res.first, rest); !bytes.Equal(body, doc) {
|
|
|
|
|
t.Fatalf("cache hit returned %d bytes, want the %d-byte document", len(body), len(doc))
|
|
|
|
|
}
|
|
|
|
|
if got := res.resp.Header.Get(headerContentType); got != "application/x-test" {
|
|
|
|
|
t.Errorf("Content-Type = %q, want application/x-test", got)
|
|
|
|
|
}
|
|
|
|
|
if got := res.resp.Header.Get(headerContentLength); got != strconv.Itoa(len(doc)) {
|
|
|
|
|
t.Errorf("Content-Length = %q, want %d", got, len(doc))
|
|
|
|
|
}
|
|
|
|
|
if got := res.resp.Header.Get(headerETag); got != `"v1"` {
|
|
|
|
|
t.Errorf("ETag = %q, want %q", got, `"v1"`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
|
|
|
|
req.Header.Set("If-None-Match", `"v1"`)
|
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("conditional cache hit: %v", err)
|
|
|
|
|
}
|
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
|
if resp.StatusCode != http.StatusNotModified {
|
|
|
|
|
t.Errorf("conditional cache hit: status %d, want 304", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCachedMetadataHitAbortsOnDigestMismatch(t *testing.T) {
|
|
|
|
|
doc := bytes.Repeat([]byte("metadata "), 1<<14)
|
2026-10-25 12:32:20 +01:00
|
|
|
_, store, srv, upstreamHits := cachedMetadataServer(t, doc)
|
Stream metadata cache hits for ProxyCached
With cache_metadata on, every metadata cache hit read the whole stored
document into its own buffer before any coalescing, since
coalesceMetadata only covers upstream misses. metadata_max_size bounds
one request, not the sum: 30 concurrent hits on a 20 MB document held
about 820 MB.
The handlers that go through ProxyCached serve the stored document
unchanged, so a hit there no longer needs the bytes in memory.
serveCachedMetadata streams the blob from storage to the response
instead, with the row's Content-Type, Content-Encoding, size, ETag and
Last-Modified, and answers If-None-Match and If-Modified-Since with 304
as before. The bytes are checked against the row's content digest as
they are sent, and a mismatch aborts the response like a corrupt cached
artifact. A row without a digest or size, a document over the size
limit, or a blob that can't be opened takes the buffered path as before.
Handlers that rewrite or filter metadata keep reading it into memory,
and the rewrite cache stays in front of them. writeMetadataValidators
is split out of writeMetadataCachedResponseWithEncoding so both paths
share the conditional request handling.
2026-10-24 13:13:12 +02:00
|
|
|
|
|
|
|
|
corrupt := bytes.Clone(doc)
|
|
|
|
|
corrupt[len(corrupt)-1] ^= 0xff
|
|
|
|
|
store.mu.Lock()
|
|
|
|
|
store.files[metadataStoragePath("test", "doc")] = corrupt
|
|
|
|
|
store.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
resp, err := http.Get(srv.URL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("cache hit: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatalf("cache hit completed with %d bytes of a document that doesn't match its digest, want the response aborted", len(body))
|
|
|
|
|
}
|
|
|
|
|
if len(body) >= len(doc) {
|
|
|
|
|
t.Errorf("aborted response still delivered %d of %d bytes", len(body), len(doc))
|
|
|
|
|
}
|
2026-10-25 12:32:20 +01:00
|
|
|
|
|
|
|
|
// The corrupt row is dropped, so the next request fetches the document
|
|
|
|
|
// from upstream again instead of aborting until the TTL runs out.
|
|
|
|
|
resp, err = http.Get(srv.URL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("request after abort: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
body, err = io.ReadAll(resp.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("request after abort: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !bytes.Equal(body, doc) {
|
|
|
|
|
t.Errorf("request after abort got %d bytes, want the upstream document (%d bytes)", len(body), len(doc))
|
|
|
|
|
}
|
|
|
|
|
if got := upstreamHits.Load(); got != 2 {
|
|
|
|
|
t.Errorf("upstream requests = %d, want 2 (populate, then refetch after the corrupt hit)", got)
|
|
|
|
|
}
|
Stream metadata cache hits for ProxyCached
With cache_metadata on, every metadata cache hit read the whole stored
document into its own buffer before any coalescing, since
coalesceMetadata only covers upstream misses. metadata_max_size bounds
one request, not the sum: 30 concurrent hits on a 20 MB document held
about 820 MB.
The handlers that go through ProxyCached serve the stored document
unchanged, so a hit there no longer needs the bytes in memory.
serveCachedMetadata streams the blob from storage to the response
instead, with the row's Content-Type, Content-Encoding, size, ETag and
Last-Modified, and answers If-None-Match and If-Modified-Since with 304
as before. The bytes are checked against the row's content digest as
they are sent, and a mismatch aborts the response like a corrupt cached
artifact. A row without a digest or size, a document over the size
limit, or a blob that can't be opened takes the buffered path as before.
Handlers that rewrite or filter metadata keep reading it into memory,
and the rewrite cache stays in front of them. writeMetadataValidators
is split out of writeMetadataCachedResponseWithEncoding so both paths
share the conditional request handling.
2026-10-24 13:13:12 +02:00
|
|
|
}
|