Files
OwnCord/Server/updater/text_asset_cache_test.go
T
J3vbandClaude Fable 5 3f8e5b59a1 fix(updater): coalesce and negative-cache text-asset fetches (W3-1)
FetchTextAssetCached served the unauthenticated, unrate-limited client-update
endpoint. On TTL expiry every concurrent caller missed the cache and issued its
own outbound fetch: a measured 25 requests for 25 callers. Failures were not
cached at all, so an upstream outage produced one outbound request per caller
for as long as it lasted.

Guards the refresh with singleflight so a burst issues one fetch, caches
failures for errorCacheTTL (mirroring the release cache's existing cachedErr
idiom), and evicts expired keys so the map no longer grows by one entry per
release for the process lifetime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:15:06 +02:00

164 lines
4.7 KiB
Go

package updater
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
)
// textAssetServer serves a fixed body at /asset and counts inbound requests.
// handler may be swapped to simulate an upstream outage.
func textAssetServer(t *testing.T, fail *atomic.Bool, hits *atomic.Int64) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/asset", func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
if fail.Load() {
w.WriteHeader(http.StatusInternalServerError)
return
}
fmt.Fprint(w, "asset-body")
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// A burst of concurrent misses must collapse into a single outbound fetch.
// Without singleflight each caller fetches independently (W3-1).
func TestFetchTextAssetCachedCoalescesConcurrentMisses(t *testing.T) {
var fail atomic.Bool
var hits atomic.Int64
srv := textAssetServer(t, &fail, &hits)
u := newTestUpdater(srv.URL, "1.0.0")
const callers = 25
var wg sync.WaitGroup
results := make([]string, callers)
errs := make([]error, callers)
start := make(chan struct{})
for i := range callers {
wg.Add(1)
go func() {
defer wg.Done()
<-start // release all goroutines together to force a real burst
results[i], errs[i] = u.FetchTextAssetCached(context.Background(), srv.URL+"/asset")
}()
}
close(start)
wg.Wait()
if got := hits.Load(); got != 1 {
t.Fatalf("outbound fetches = %d, want exactly 1 (singleflight should coalesce)", got)
}
for i := range callers {
if errs[i] != nil {
t.Fatalf("caller %d: unexpected error: %v", i, errs[i])
}
if results[i] != "asset-body" {
t.Fatalf("caller %d: content = %q, want %q", i, results[i], "asset-body")
}
}
}
// A failed fetch must be cached for errorCacheTTL so an upstream outage does
// not produce one outbound request per caller.
func TestFetchTextAssetCachedCachesFailures(t *testing.T) {
var fail atomic.Bool
var hits atomic.Int64
srv := textAssetServer(t, &fail, &hits)
fail.Store(true)
u := newTestUpdater(srv.URL, "1.0.0")
url := srv.URL + "/asset"
if _, err := u.FetchTextAssetCached(context.Background(), url); err == nil {
t.Fatal("first call: want error from failing upstream, got nil")
}
if _, err := u.FetchTextAssetCached(context.Background(), url); err == nil {
t.Fatal("second call: want cached error, got nil")
}
if got := hits.Load(); got != 1 {
t.Fatalf("outbound fetches = %d, want 1 (the failure should be cached)", got)
}
// Once the negative entry expires, the upstream is retried — a cached error
// must not be permanent.
u.mu.Lock()
entry := u.textAssetCache[url]
entry.expiry = time.Now().Add(-time.Second)
u.textAssetCache[url] = entry
u.mu.Unlock()
fail.Store(false)
content, err := u.FetchTextAssetCached(context.Background(), url)
if err != nil {
t.Fatalf("after negative-cache expiry: unexpected error: %v", err)
}
if content != "asset-body" {
t.Fatalf("content = %q, want %q", content, "asset-body")
}
if got := hits.Load(); got != 2 {
t.Fatalf("outbound fetches = %d, want 2 (retry after expiry)", got)
}
}
// Asset URLs carry a version, so stale keys must be evicted or the map grows by
// one entry per release for the process lifetime.
func TestFetchTextAssetCachedEvictsExpiredKeys(t *testing.T) {
var fail atomic.Bool
var hits atomic.Int64
srv := textAssetServer(t, &fail, &hits)
u := newTestUpdater(srv.URL, "1.0.0")
// A superseded entry from an earlier release, already past its expiry.
u.mu.Lock()
u.textAssetCache = map[string]textAssetCacheEntry{
"https://example.invalid/v0.9.0/chatserver.exe.sig": {
content: "stale",
expiry: time.Now().Add(-time.Hour),
},
}
u.mu.Unlock()
if _, err := u.FetchTextAssetCached(context.Background(), srv.URL+"/asset"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
u.mu.Lock()
defer u.mu.Unlock()
if _, ok := u.textAssetCache["https://example.invalid/v0.9.0/chatserver.exe.sig"]; ok {
t.Fatal("expired key survived a cache write; it should have been evicted")
}
if len(u.textAssetCache) != 1 {
t.Fatalf("cache size = %d, want 1 (only the fresh entry)", len(u.textAssetCache))
}
}
// A live cached entry must be served without any outbound request.
func TestFetchTextAssetCachedServesFromCache(t *testing.T) {
var fail atomic.Bool
var hits atomic.Int64
srv := textAssetServer(t, &fail, &hits)
u := newTestUpdater(srv.URL, "1.0.0")
url := srv.URL + "/asset"
for range 3 {
content, err := u.FetchTextAssetCached(context.Background(), url)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "asset-body" {
t.Fatalf("content = %q, want %q", content, "asset-body")
}
}
if got := hits.Load(); got != 1 {
t.Fatalf("outbound fetches = %d, want 1 (subsequent calls should hit cache)", got)
}
}