cache: Short TTL negative cache for 404/410 depot objects
Stop re-fetching gone depot objects on every miss: store 404/410 in the existing VFS cache under the same key with a short TTL (default 5m).
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
package steamcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func steamGet(t *testing.T, sc *SteamCache, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
||||
rec := httptest.NewRecorder()
|
||||
sc.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestNegativeCache404(t *testing.T) {
|
||||
var upstreamHits atomic.Int64
|
||||
f := func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamHits.Add(1)
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
|
||||
sc.ResetMetrics()
|
||||
|
||||
rec1 := steamGet(t, sc, "/depot/gone/chunk")
|
||||
if rec1.Code != http.StatusNotFound {
|
||||
t.Fatalf("first request: expected 404, got %d body=%q", rec1.Code, rec1.Body.String())
|
||||
}
|
||||
if n := upstreamHits.Load(); n != 1 {
|
||||
t.Fatalf("first request: expected 1 upstream hit, got %d", n)
|
||||
}
|
||||
|
||||
rec2 := steamGet(t, sc, "/depot/gone/chunk")
|
||||
if rec2.Code != http.StatusNotFound {
|
||||
t.Fatalf("second request: expected 404, got %d", rec2.Code)
|
||||
}
|
||||
if n := upstreamHits.Load(); n != 1 {
|
||||
t.Fatalf("repeated miss must not re-hit upstream within TTL, got %d", n)
|
||||
}
|
||||
|
||||
stats := sc.GetMetrics()
|
||||
if stats.NegativeCacheHits < 1 {
|
||||
t.Errorf("expected NegativeCacheHits >= 1, got %d", stats.NegativeCacheHits)
|
||||
}
|
||||
if stats.CacheHits < 1 {
|
||||
t.Errorf("negative hit should also count as cache_hits, got %d", stats.CacheHits)
|
||||
}
|
||||
if stats.UpstreamErrors != 1 {
|
||||
t.Errorf("first 404 should count upstream error once, got %d", stats.UpstreamErrors)
|
||||
}
|
||||
|
||||
mrec := httptest.NewRecorder()
|
||||
sc.ServeHTTP(mrec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
if !bytes.Contains(mrec.Body.Bytes(), []byte("negative_cache_hits")) {
|
||||
t.Errorf("/metrics missing negative_cache_hits:\n%s", mrec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegativeCache410(t *testing.T) {
|
||||
var upstreamHits atomic.Int64
|
||||
f := func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamHits.Add(1)
|
||||
w.WriteHeader(http.StatusGone)
|
||||
}
|
||||
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
|
||||
|
||||
rec1 := steamGet(t, sc, "/depot/gone410/chunk")
|
||||
if rec1.Code != http.StatusGone {
|
||||
t.Fatalf("first request: expected 410, got %d", rec1.Code)
|
||||
}
|
||||
rec2 := steamGet(t, sc, "/depot/gone410/chunk")
|
||||
if rec2.Code != http.StatusGone {
|
||||
t.Fatalf("second request: expected 410, got %d", rec2.Code)
|
||||
}
|
||||
if n := upstreamHits.Load(); n != 1 {
|
||||
t.Fatalf("410 negative cache should suppress second upstream hit, got %d", n)
|
||||
}
|
||||
if sc.GetMetrics().NegativeCacheHits < 1 {
|
||||
t.Errorf("expected NegativeCacheHits >= 1 after cached 410, got %d", sc.GetMetrics().NegativeCacheHits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegativeCacheExpiredRefetch(t *testing.T) {
|
||||
var upstreamHits atomic.Int64
|
||||
f := func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamHits.Add(1)
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
s := httptest.NewServer(http.HandlerFunc(f))
|
||||
t.Cleanup(s.Close)
|
||||
|
||||
sc, err := NewWithOptions(Options{
|
||||
Address: "127.0.0.1:0",
|
||||
MemorySize: "1MB",
|
||||
DiskSize: "0",
|
||||
DiskPath: t.TempDir(),
|
||||
Upstream: s.URL,
|
||||
MemoryGC: "lru",
|
||||
DiskGC: "lru",
|
||||
MaxConcurrentRequests: 10,
|
||||
MaxRequestsPerClient: 5,
|
||||
MaxObjectSize: "0",
|
||||
NegativeTTL: "1s",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithOptions: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { sc.Shutdown() })
|
||||
|
||||
if rec := steamGet(t, sc, "/depot/ttl/chunk"); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("first request: expected 404, got %d", rec.Code)
|
||||
}
|
||||
if n := upstreamHits.Load(); n != 1 {
|
||||
t.Fatalf("first request: expected 1 upstream hit, got %d", n)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
rec := steamGet(t, sc, "/depot/ttl/chunk")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 after expiry poll, got %d", rec.Code)
|
||||
}
|
||||
if upstreamHits.Load() >= 2 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expired negative entry did not re-fetch upstream; hits=%d", upstreamHits.Load())
|
||||
}
|
||||
|
||||
func TestSerializeNegativeHeader(t *testing.T) {
|
||||
raw := []byte("HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\n\r\ngone")
|
||||
pos, err := serializeRawResponse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("serialize positive: %v", err)
|
||||
}
|
||||
posFile, err := deserializeCacheFile(pos)
|
||||
if err != nil {
|
||||
t.Fatalf("deserialize positive: %v", err)
|
||||
}
|
||||
if posFile.ExpiresUnix != 0 {
|
||||
t.Errorf("positive entry ExpiresUnix=%d, want 0", posFile.ExpiresUnix)
|
||||
}
|
||||
|
||||
expires := time.Now().Add(5 * time.Minute).Unix()
|
||||
neg, err := serializeCacheFile(raw, expires)
|
||||
if err != nil {
|
||||
t.Fatalf("serialize negative: %v", err)
|
||||
}
|
||||
negFile, err := deserializeCacheFile(neg)
|
||||
if err != nil {
|
||||
t.Fatalf("deserialize negative: %v", err)
|
||||
}
|
||||
if negFile.ExpiresUnix != expires {
|
||||
t.Errorf("ExpiresUnix=%d, want %d", negFile.ExpiresUnix, expires)
|
||||
}
|
||||
if !bytes.Equal(negFile.Response, raw) {
|
||||
t.Error("negative raw response not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInvalidNegativeTTL(t *testing.T) {
|
||||
sc, err := New("127.0.0.1:0", "1MB", "0", t.TempDir(), "", "lru", "lru", 10, 5, "0", nil, "not-a-duration")
|
||||
if err == nil {
|
||||
if sc != nil {
|
||||
sc.Shutdown()
|
||||
}
|
||||
t.Fatal("expected error for invalid negative ttl")
|
||||
}
|
||||
if sc != nil {
|
||||
t.Error("expected nil SteamCache on invalid negative ttl")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid negative ttl") {
|
||||
t.Errorf("err %q missing invalid negative ttl", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteTextNegativeCacheHits(t *testing.T) {
|
||||
body := []byte("ok")
|
||||
f := func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
|
||||
srv := newCacheServer(t, sc)
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/metrics", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET /metrics: %v", err)
|
||||
}
|
||||
out, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read /metrics: %v", err)
|
||||
}
|
||||
if !bytes.Contains(out, []byte("negative_cache_hits 0\n")) {
|
||||
t.Errorf("/metrics missing negative_cache_hits 0:\n%s", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user