Files
artifactapi/internal/provider/rpm/github_repodata_cache_test.go
T
unkin-agent e4833b6f96 Make repodata coalescing tests deterministic (#177)
The repodata coalescing tests released the gated build after a fixed 50ms sleep, so a goroutine stalled past that window under -race could miss the in-flight singleflight call, trigger a second build and flake the test.

- add sfWaiters, counting goroutines parked in singleflight Do via runtime.Stack
- release the gate only once every follower is parked on the in-flight call
- drop the trailing sleep from waitFor

Reviewed-on: #177
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-25 22:51:03 +11:00

366 lines
11 KiB
Go

package rpm
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync"
"testing"
"time"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// fpStore adds a fingerprint and a full-read counter to fakeStore.
type fpStore struct {
*fakeStore
lists, fps int
seq int64
fpErr, listErr error
gate chan struct{} // when set, full reads block until it is closed
}
func (s *fpStore) ListRPMMetadataEntries(ctx context.Context, name string) ([]provider.RPMMetadata, error) {
s.mu.Lock()
s.lists++
gate, err := s.gate, s.listErr
s.mu.Unlock()
if gate != nil {
<-gate
}
if err != nil {
return nil, err
}
return s.fakeStore.ListRPMMetadataEntries(ctx, name)
}
func (s *fpStore) InsertRPMMetadata(ctx context.Context, m *provider.RPMMetadata) error {
s.mu.Lock()
_, exists := s.rows[m.FilePath]
s.mu.Unlock()
if err := s.fakeStore.InsertRPMMetadata(ctx, m); err != nil || exists {
return err // ON CONFLICT DO NOTHING leaves max(id) unchanged
}
s.mu.Lock()
s.seq++
s.mu.Unlock()
return nil
}
func (s *fpStore) RPMMetadataFingerprint(_ context.Context, _ string) (int64, int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.fps++
return int64(len(s.rows)), s.seq, s.fpErr
}
func (s *fpStore) fpCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.fps
}
func (s *fpStore) listCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.lists
}
func newCacheFixture(t *testing.T, cache bool) (*GitHubProvider, *fpStore, models.Remote) {
t.Helper()
remote := models.Remote{Name: "cached-rpm", PackageType: models.PackageGitHubRPM, MutableTTL: 3600, CacheRepodata: cache}
p := newGitHubProvider()
p.lastScan[remote.Name] = time.Now()
store := &fpStore{fakeStore: newFakeStore()}
addPkg(t, store, "alpha")
return p, store, remote
}
func addPkg(t *testing.T, store *fpStore, name string) {
t.Helper()
if err := store.InsertRPMMetadata(context.Background(), &provider.RPMMetadata{
RepoName: "cached-rpm", FilePath: name + "-1-1.noarch.rpm", ContentHash: "sha256:" + name,
Name: name, Version: "1", Release: "1", Arch: "noarch", Group: "Unspecified",
Files: []provider.RPMFile{{Path: "/usr/bin/" + name}},
}); err != nil {
t.Fatal(err)
}
}
func serveReq(t *testing.T, p *GitHubProvider, remote models.Remote, store *fpStore, req *http.Request, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
if !p.ServeRemote(rec, req, remote, path, "https://proxy.example", store) {
t.Errorf("%s not handled", path)
}
return rec
}
func serve(t *testing.T, p *GitHubProvider, remote models.Remote, store *fpStore, path string) []byte {
t.Helper()
rec := serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), path)
if rec.Code != http.StatusOK {
t.Fatalf("%s: code %d: %s", path, rec.Code, rec.Body.String())
}
return rec.Body.Bytes()
}
var repodataPaths = []string{
"repodata/repomd.xml",
"repodata/x-primary.xml.gz",
"repodata/x-filelists.xml.gz",
"repodata/x-other.xml.gz",
}
func TestCachedRepodataHitSkipsRegeneration(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
repomd := serve(t, p, remote, store, "repodata/repomd.xml")
primary := serve(t, p, remote, store, "repodata/x-primary.xml.gz")
if n := store.listCount(); n != 1 {
t.Fatalf("full metadata reads = %d, want 1", n)
}
if !strings.Contains(string(repomd), sha256Hex(primary)+"-primary.xml.gz") {
t.Fatalf("cached repomd does not reference cached primary:\n%s", repomd)
}
}
func TestCachedRepodataRebuildsOnDataChange(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
before := serve(t, p, remote, store, "repodata/repomd.xml")
addPkg(t, store, "beta")
after := serve(t, p, remote, store, "repodata/repomd.xml")
primary := serve(t, p, remote, store, "repodata/x-primary.xml.gz")
if string(before) == string(after) {
t.Fatal("repomd unchanged after metadata change")
}
if !strings.Contains(string(after), sha256Hex(primary)+"-primary.xml.gz") {
t.Fatal("rebuilt repomd does not reference rebuilt primary")
}
if !strings.Contains(gunzip(t, primary), "<name>beta</name>") {
t.Fatal("rebuilt primary missing new package")
}
if n := store.listCount(); n != 2 {
t.Fatalf("full metadata reads = %d, want 2", n)
}
}
func TestUncachedRepodataMatchesCachedAndReadsEveryTime(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
cached := map[string]string{}
for _, path := range repodataPaths {
cached[path] = string(serve(t, p, remote, store, path))
}
remote.CacheRepodata = false
reads := store.listCount()
for _, path := range repodataPaths {
if got := string(serve(t, p, remote, store, path)); got != cached[path] {
t.Fatalf("%s differs between cached and uncached", path)
}
}
if n := store.listCount() - reads; n != len(repodataPaths) {
t.Fatalf("uncached full reads = %d, want %d", n, len(repodataPaths))
}
if _, ok := p.repodataCache[remote.Name]; ok {
t.Fatal("disabling cache_repodata should drop the cached index")
}
}
func TestCachedRepodataRebuildsOnSameCountReinsert(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
addPkg(t, store, "alpha") // conflicting insert: fingerprint must not move
serve(t, p, remote, store, "repodata/repomd.xml")
serve(t, p, remote, store, "repodata/repomd.xml")
if n := store.listCount(); n != 1 {
t.Fatalf("full reads after conflicting insert = %d, want 1", n)
}
if err := store.DeleteRPMMetadata(context.Background(), remote.Name, "alpha-1-1.noarch.rpm"); err != nil {
t.Fatal(err)
}
addPkg(t, store, "alpha")
serve(t, p, remote, store, "repodata/repomd.xml")
if n := store.listCount(); n != 2 {
t.Fatalf("full reads after same-count reinsert = %d, want 2", n)
}
}
func TestCachedRepodataEvictsIdleRemotes(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
serve(t, p, remote, store, "repodata/repomd.xml")
p.repodataCache["gone"] = cachedRepodata{used: time.Now().Add(-2 * repodataIdleTTL), repodata: &repodata{}}
p.repodataSwept = time.Time{}
serve(t, p, remote, store, "repodata/repomd.xml")
if _, ok := p.repodataCache["gone"]; ok {
t.Fatal("idle remote's index not evicted")
}
if _, ok := p.repodataCache[remote.Name]; !ok {
t.Fatal("active remote's index evicted")
}
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatal("condition not reached")
}
time.Sleep(time.Millisecond)
}
}
// sfWaiters counts goroutines parked on an in-flight singleflight call.
func sfWaiters() int {
buf := make([]byte, 1<<20)
for {
n := runtime.Stack(buf, true)
if n < len(buf) {
buf = buf[:n]
break
}
buf = make([]byte, 2*len(buf))
}
waiters := 0
for _, g := range strings.Split(string(buf), "\n\n") {
if strings.Contains(g, "sync.(*WaitGroup).Wait") && strings.Contains(g, "singleflight.(*Group).Do(") {
waiters++
}
}
return waiters
}
func TestCachedRepodataCoalescesConcurrentBuilds(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
gate := make(chan struct{})
store.gate = gate
const n = 8
var wg sync.WaitGroup
codes := make([]int, n)
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
codes[i] = serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), repodataPaths[i%len(repodataPaths)]).Code
}()
}
waitFor(t, func() bool { return store.listCount() == 1 && sfWaiters() == n-1 })
close(gate)
wg.Wait()
for i, c := range codes {
if c != http.StatusOK {
t.Errorf("request %d: code %d", i, c)
}
}
if got := store.listCount(); got != 1 {
t.Fatalf("full metadata reads = %d, want 1", got)
}
}
func TestCachedRepodataLeaderCancelDoesNotFailWaiters(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
gate := make(chan struct{})
store.gate = gate
leaderCtx, cancelLeader := context.WithCancel(context.Background())
results := make(chan int, 2)
go func() {
req := httptest.NewRequest(http.MethodGet, "/x", nil).WithContext(leaderCtx)
results <- serveReq(t, p, remote, store, req, "repodata/repomd.xml").Code
}()
waitFor(t, func() bool { return store.listCount() == 1 })
go func() {
results <- serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), "repodata/repomd.xml").Code
}()
waitFor(t, func() bool { return sfWaiters() == 1 })
cancelLeader()
close(gate)
for range 2 {
if c := <-results; c != http.StatusOK {
t.Fatalf("code %d after leader cancel, want 200", c)
}
}
if got := store.listCount(); got != 1 {
t.Fatalf("full metadata reads = %d, want 1", got)
}
}
func TestCachedRepodataOlderBuildDoesNotOverwriteNewer(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
newer := &repodata{}
p.repodataCache[remote.Name] = cachedRepodata{count: 99, maxID: 99, seq: 1 << 40, used: time.Now(), repodata: newer}
serve(t, p, remote, store, "repodata/repomd.xml")
if p.repodataCache[remote.Name].repodata != newer {
t.Fatal("older build replaced a newer cache entry")
}
}
func TestCachedRepodataErrors(t *testing.T) {
secret := errors.New("pq: password authentication failed for user secret")
for _, tc := range []struct {
name string
fpErr, listErr error
code int
}{
{"fingerprint", secret, nil, http.StatusInternalServerError},
{"list", nil, secret, http.StatusInternalServerError},
{"fingerprint deadline", context.DeadlineExceeded, nil, http.StatusServiceUnavailable},
{"list canceled", nil, context.Canceled, http.StatusServiceUnavailable},
} {
t.Run(tc.name, func(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
store.fpErr, store.listErr = tc.fpErr, tc.listErr
rec := serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), "repodata/repomd.xml")
if rec.Code != tc.code {
t.Fatalf("code %d, want %d", rec.Code, tc.code)
}
if strings.Contains(rec.Body.String(), "secret") {
t.Fatalf("DB error leaked: %q", rec.Body.String())
}
if retry := rec.Header().Get("Retry-After"); (tc.code == http.StatusServiceUnavailable) != (retry != "") {
t.Fatalf("Retry-After = %q for %d", retry, tc.code)
}
if _, ok := p.repodataCache[remote.Name]; ok {
t.Fatal("failed build was cached")
}
})
}
}
func TestCachedRepodataUnknownTail404sWithoutDB(t *testing.T) {
p, store, remote := newCacheFixture(t, true)
rec := serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), "repodata/bogus.txt")
if rec.Code != http.StatusNotFound {
t.Fatalf("code %d, want 404", rec.Code)
}
if store.fpCount() != 0 || store.listCount() != 0 {
t.Fatalf("unknown tail hit the DB: fps=%d lists=%d", store.fpCount(), store.listCount())
}
}
func TestCacheEmptyUsesFingerprint(t *testing.T) {
p := newGitHubProvider()
store := &fpStore{fakeStore: newFakeStore()}
if !p.cacheEmpty(context.Background(), store, "cached-rpm") {
t.Fatal("empty store not reported empty")
}
addPkg(t, store, "alpha")
if p.cacheEmpty(context.Background(), store, "cached-rpm") {
t.Fatal("populated store reported empty")
}
store.fpErr = errors.New("db down")
if p.cacheEmpty(context.Background(), store, "cached-rpm") {
t.Fatal("fingerprint error must not report empty")
}
if store.listCount() != 0 {
t.Fatalf("cacheEmpty did full reads: %d", store.listCount())
}
}