mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
* Separate artifact and metadata cache metrics on /metrics and the analytics page * Serve metadata cache stats from a shared snapshot and split Grafana cache panels by kind
246 lines
6.8 KiB
Go
246 lines
6.8 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/proxy/internal/database"
|
|
)
|
|
|
|
func cacheTestDB(t *testing.T) *database.DB {
|
|
t.Helper()
|
|
db, err := database.Create(filepath.Join(t.TempDir(), "cache.db"))
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return db
|
|
}
|
|
|
|
// The zero value must work, because Server is assembled as a struct literal in
|
|
// places that never call a constructor.
|
|
func TestEcosystemStatsCacheZeroValueIsUsable(t *testing.T) {
|
|
var c ecosystemStatsCache
|
|
|
|
if _, err := c.Get(cacheTestDB(t)); err != nil {
|
|
t.Fatalf("Get on a zero-value cache: %v", err)
|
|
}
|
|
if c.interval() != ecosystemStatsTTL {
|
|
t.Errorf("interval = %v, want the default %v", c.interval(), ecosystemStatsTTL)
|
|
}
|
|
}
|
|
|
|
// A second call inside the TTL must not touch the database again — that is the
|
|
// whole point, since /stats is unauthenticated and pollable.
|
|
func TestEcosystemStatsCacheServesWithinTTL(t *testing.T) {
|
|
db := cacheTestDB(t)
|
|
c := ecosystemStatsCache{ttl: time.Hour}
|
|
|
|
if _, err := c.Get(db); err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
first := c.lastAt
|
|
|
|
if _, err := c.Get(db); err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if !c.lastAt.Equal(first) {
|
|
t.Error("a second Get inside the TTL re-queried the database")
|
|
}
|
|
}
|
|
|
|
func TestEcosystemStatsCacheRefreshesAfterTTL(t *testing.T) {
|
|
db := cacheTestDB(t)
|
|
// A TTL already elapsed by the time the second call lands.
|
|
c := ecosystemStatsCache{ttl: time.Nanosecond}
|
|
|
|
if _, err := c.Get(db); err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
first := c.lastAt
|
|
|
|
time.Sleep(time.Millisecond)
|
|
if _, err := c.Get(db); err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if c.lastAt.Equal(first) {
|
|
t.Error("the cache did not refresh after its TTL elapsed")
|
|
}
|
|
}
|
|
|
|
// Refresh ignores the TTL, so the metrics loop always publishes a fresh read.
|
|
func TestEcosystemStatsCacheRefreshBypassesTTL(t *testing.T) {
|
|
db := cacheTestDB(t)
|
|
c := ecosystemStatsCache{ttl: time.Hour}
|
|
|
|
if _, err := c.Get(db); err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
first := c.lastAt
|
|
|
|
time.Sleep(time.Millisecond)
|
|
if _, err := c.Refresh(db); err != nil {
|
|
t.Fatalf("Refresh: %v", err)
|
|
}
|
|
if c.lastAt.Equal(first) {
|
|
t.Error("Refresh honoured the TTL; it must always re-read")
|
|
}
|
|
}
|
|
|
|
// A transient failure must not erase a snapshot the caller could still show.
|
|
func TestEcosystemStatsCacheKeepsLastGoodSnapshotOnError(t *testing.T) {
|
|
good := cacheTestDB(t)
|
|
seedCachePackage(t, good, "npm", "lodash")
|
|
|
|
c := ecosystemStatsCache{ttl: time.Nanosecond}
|
|
stats, err := c.Get(good)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if len(stats) != 1 {
|
|
t.Fatalf("expected 1 ecosystem from the seeded database, got %+v", stats)
|
|
}
|
|
|
|
// A closed handle stands in for the database going away.
|
|
broken := cacheTestDB(t)
|
|
if err := broken.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
time.Sleep(time.Millisecond)
|
|
stats, err = c.Get(broken)
|
|
if err == nil {
|
|
t.Fatal("expected an error from the closed database")
|
|
}
|
|
if len(stats) != 1 || stats[0].Ecosystem != "npm" {
|
|
t.Errorf("last good snapshot was discarded: got %+v", stats)
|
|
}
|
|
}
|
|
|
|
// An error must not be pinned for the full TTL, or a recovered database stays
|
|
// invisible for a minute.
|
|
func TestEcosystemStatsCacheRetriesSoonerAfterError(t *testing.T) {
|
|
broken := cacheTestDB(t)
|
|
if err := broken.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
c := ecosystemStatsCache{ttl: time.Hour}
|
|
if _, err := c.Get(broken); err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
if got := c.interval(); got != ecosystemStatsErrorTTL {
|
|
t.Errorf("retry interval after an error = %v, want %v", got, ecosystemStatsErrorTTL)
|
|
}
|
|
}
|
|
|
|
// A closed database makes the query fail; the error must reach the caller
|
|
// rather than being swallowed into an empty-looking result.
|
|
func TestEcosystemStatsCacheSurfacesErrors(t *testing.T) {
|
|
db, err := database.Create(filepath.Join(t.TempDir(), "closed.db"))
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if err := db.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
var c ecosystemStatsCache
|
|
if _, err := c.Get(db); err == nil {
|
|
t.Error("expected an error from a closed database")
|
|
}
|
|
}
|
|
|
|
func seedCachePackage(t *testing.T, db *database.DB, ecosystem, name string) {
|
|
t.Helper()
|
|
if err := db.UpsertPackage(&database.Package{
|
|
PURL: "pkg:" + ecosystem + "/" + name,
|
|
Ecosystem: ecosystem,
|
|
Name: name,
|
|
}); err != nil {
|
|
t.Fatalf("UpsertPackage: %v", err)
|
|
}
|
|
}
|
|
|
|
// The snapshot timestamp must track the last successful read, not the last
|
|
// attempt. The analytics page dates its "figures may be out of date" banner
|
|
// from it, and a timestamp that advanced on every failed retry would report an
|
|
// hour-old snapshot as seconds old.
|
|
func TestEcosystemStatsCacheSnapshotAtTracksSuccessOnly(t *testing.T) {
|
|
good := cacheTestDB(t)
|
|
seedCachePackage(t, good, "npm", "lodash")
|
|
|
|
c := ecosystemStatsCache{ttl: time.Nanosecond}
|
|
if c.SnapshotAt(); !c.SnapshotAt().IsZero() {
|
|
t.Fatalf("SnapshotAt on a fresh cache = %v, want the zero time", c.SnapshotAt())
|
|
}
|
|
if _, err := c.Get(good); err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
|
|
at := c.SnapshotAt()
|
|
if at.IsZero() {
|
|
t.Fatal("SnapshotAt is zero after a successful read")
|
|
}
|
|
|
|
broken := cacheTestDB(t)
|
|
if err := broken.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
time.Sleep(2 * time.Millisecond)
|
|
if _, err := c.Get(broken); err == nil {
|
|
t.Fatal("expected an error from the closed database")
|
|
}
|
|
if got := c.SnapshotAt(); !got.Equal(at) {
|
|
t.Errorf("SnapshotAt moved on a failed read: %v, want %v", got, at)
|
|
}
|
|
}
|
|
|
|
// The metadata cache figures go through the same memo as the artifact ones.
|
|
// Rows added inside the TTL stay out of the snapshot until a Refresh, which is
|
|
// what the metrics tick calls.
|
|
func TestMetadataStatsCacheServesSnapshot(t *testing.T) {
|
|
db := cacheTestDB(t)
|
|
c := metadataStatsCache{ttl: time.Hour}
|
|
|
|
seed := func(name string, size int64) {
|
|
t.Helper()
|
|
if err := db.UpsertMetadataCache(&database.MetadataCacheEntry{
|
|
Ecosystem: "npm",
|
|
Name: name,
|
|
StoragePath: "_metadata/npm/" + name,
|
|
Size: sql.NullInt64{Int64: size, Valid: true},
|
|
}); err != nil {
|
|
t.Fatalf("UpsertMetadataCache: %v", err)
|
|
}
|
|
}
|
|
entries := func(stats []database.MetadataCacheStats) int64 {
|
|
var n int64
|
|
for _, s := range stats {
|
|
n += s.Entries
|
|
}
|
|
return n
|
|
}
|
|
|
|
seed("lodash", 100)
|
|
stats, err := c.Get(db)
|
|
if err != nil || entries(stats) != 1 {
|
|
t.Fatalf("first Get = %+v, %v; want 1 entry", stats, err)
|
|
}
|
|
|
|
seed("react", 50)
|
|
if stats, _ := c.Get(db); entries(stats) != 1 {
|
|
t.Errorf("Get inside the TTL = %+v; want the 1-entry snapshot", stats)
|
|
}
|
|
|
|
stats, err = c.Refresh(db)
|
|
if err != nil || entries(stats) != 2 {
|
|
t.Fatalf("Refresh = %+v, %v; want 2 entries", stats, err)
|
|
}
|
|
if stats, _ := c.Get(db); entries(stats) != 2 {
|
|
t.Errorf("Get after Refresh = %+v; want the refreshed snapshot", stats)
|
|
}
|
|
}
|