Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 23:59:16 -05:00
pkg-proxy/internal/server/eviction_metadata_test.go
Christian Heim d56d4367c9
Count cached metadata against max_size and evict it LRU (#507)
* max_size now counts cached metadata as well as artifacts, and eviction drops whichever was used least recently, so metadata no longer piles up for good (#461). Metadata rows record when they were last served, an artifact not yet served counts from its download, and Arch stores a database blob again if eviction removed it under a refreshed row.

* Shorter comments on the metadata last-access migration.

* Show evicted metadata on the analytics page next to evicted artifacts, and cover the new counter in the metric surface test.

* Evicting metadata now drops the row and queues its path in one transaction, so a crash before the blob is deleted leaves it to reclaim instead of orphaning it.

* The last-access backfill now runs in the background in batches of 1000 instead of inside the migration, resuming after a restart, and until a row is backfilled eviction orders it by its write time.

---------

Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-10-20 10:22:55 +01:00

194 lines
7.1 KiB
Go

package server
import (
"context"
"database/sql"
"io"
"log/slog"
"slices"
"strings"
"testing"
"time"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/storage"
)
// seedMetadata caches a 500-byte metadata document last used at
// accessedAt and returns its storage path.
func seedMetadata(t *testing.T, ctx context.Context, db *database.DB, store storage.Storage, key string, accessedAt time.Time) string {
t.Helper()
path := "_metadata/golang/" + key + "/metadata"
size, _, err := store.Store(ctx, path, strings.NewReader(strings.Repeat("m", 500)))
if err != nil {
t.Fatalf("storing metadata: %v", err)
}
if err := db.UpsertMetadataCache(&database.MetadataCacheEntry{
Ecosystem: "golang",
Name: key,
StoragePath: path,
Size: sql.NullInt64{Int64: size, Valid: true},
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
}); err != nil {
t.Fatalf("upserting metadata: %v", err)
}
query := db.Rebind(`UPDATE metadata_cache SET last_accessed_at = ? WHERE ecosystem = ? AND name = ?`)
if _, err := db.Exec(query, accessedAt.UTC(), "golang", key); err != nil {
t.Fatalf("setting last access: %v", err)
}
return path
}
func TestEvictLRU_CountsAndEvictsMetadata(t *testing.T) {
db, store := setupEvictionTest(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
now := time.Now()
oldTile := seedMetadata(t, ctx, db, store, "tile-old", now.Add(-3*time.Hour))
seedArtifact(t, ctx, db, store, "mid-pkg", 500, now.Add(-2*time.Hour))
newTile := seedMetadata(t, ctx, db, store, "tile-new", now)
// Artifacts alone are within the limit; with metadata the cache is
// 1500 bytes, so the least recently used entry has to go, and that is
// the old metadata document.
evictLRU(ctx, db, store, logger, 1100)
if entry, _ := db.GetMetadataCache("golang", "tile-old"); entry != nil {
t.Error("least recently used metadata row was kept")
}
if ok, _ := store.Exists(ctx, oldTile); ok {
t.Error("least recently used metadata blob was kept")
}
if entry, _ := db.GetMetadataCache("golang", "tile-new"); entry == nil {
t.Error("recently used metadata row was evicted")
}
if ok, _ := store.Exists(ctx, newTile); !ok {
t.Error("recently used metadata blob was deleted")
}
art, err := db.GetArtifact("pkg:npm/mid-pkg@1.0.0", "mid-pkg-1.0.0.tgz")
if err != nil || art == nil || !art.StoragePath.Valid {
t.Errorf("artifact used after the old metadata was evicted: %+v, %v", art, err)
}
}
func TestEvictLRU_EvictsArtifactBeforeNewerMetadata(t *testing.T) {
db, store := setupEvictionTest(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
now := time.Now()
seedArtifact(t, ctx, db, store, "old-pkg", 500, now.Add(-3*time.Hour))
seedMetadata(t, ctx, db, store, "tile", now)
evictLRU(ctx, db, store, logger, 600)
art, err := db.GetArtifact("pkg:npm/old-pkg@1.0.0", "old-pkg-1.0.0.tgz")
if err != nil || art == nil || art.StoragePath.Valid {
t.Errorf("older artifact kept: %+v, %v", art, err)
}
if entry, _ := db.GetMetadataCache("golang", "tile"); entry == nil {
t.Error("newer metadata evicted although evicting the artifact was enough")
}
}
// TestEvictBatch_KeepsMetadataUsedSinceRead evicts from a row read before a
// request served the document again. The row and its blob stay, and nothing
// is counted as freed.
func TestEvictBatch_KeepsMetadataUsedSinceRead(t *testing.T) {
db, store := setupEvictionTest(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
path := seedMetadata(t, ctx, db, store, "tile", time.Now().Add(-time.Hour))
stale, err := db.GetLeastRecentlyUsedCacheEntries(evictionBatch)
if err != nil || len(stale) != 1 {
t.Fatalf("reading LRU rows: %v, %v", stale, err)
}
if err := db.RecordMetadataHit("golang", "tile"); err != nil {
t.Fatalf("recording hit: %v", err)
}
cleared, freed := evictBatch(ctx, db, store, logger, stale, 0, 500)
if cleared != 0 || freed != 0 {
t.Errorf("cleared %d records freeing %d bytes, want nothing", cleared, freed)
}
if entry, _ := db.GetMetadataCache("golang", "tile"); entry == nil {
t.Error("metadata row served since it was read was evicted")
}
if ok, _ := store.Exists(ctx, path); !ok {
t.Error("metadata blob served since it was read was deleted")
}
}
// TestEvictLRU_MetadataLeavesNothingQueued checks that a completed eviction
// takes the path it queued with the row's delete off the queue again.
func TestEvictLRU_MetadataLeavesNothingQueued(t *testing.T) {
db, store := setupEvictionTest(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
path := seedMetadata(t, ctx, db, store, "tile", time.Now().Add(-time.Hour))
evictLRU(ctx, db, store, logger, 0)
if ok, _ := store.Exists(ctx, path); ok {
t.Error("metadata blob kept")
}
if got := queuedPaths(t, db); len(got) != 0 {
t.Errorf("queue = %v, want it empty after the delete", got)
}
}
// TestEvictMetadata_InterruptedBeforeDeleteIsReclaimed stops an eviction
// after the row is gone but before the blob is deleted, as a crash would. The
// path is queued, so reclaim still deletes the blob.
func TestEvictMetadata_InterruptedBeforeDeleteIsReclaimed(t *testing.T) {
db, store := setupEvictionTest(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
path := seedMetadata(t, ctx, db, store, "tile", time.Now().Add(-time.Hour))
entries, err := db.GetLeastRecentlyUsedCacheEntries(evictionBatch)
if err != nil || len(entries) != 1 {
t.Fatalf("reading LRU rows: %v, %v", entries, err)
}
e := entries[0]
if ok, err := db.EvictMetadataCache(e.Key, e.Name, path, e.LastAccessedAt); err != nil || !ok {
t.Fatalf("EvictMetadataCache = %v, %v", ok, err)
}
if got := queuedPaths(t, db); !slices.Equal(got, []string{path}) {
t.Fatalf("queue = %v, want the evicted path", got)
}
// Nothing left for a restarted eviction pass to retry; reclaim takes it.
if again, _ := db.GetLeastRecentlyUsedCacheEntries(evictionBatch); len(again) != 0 {
t.Errorf("LRU after interruption = %+v, want nothing", again)
}
reclaimDue(ctx, db, store, logger, time.Now().Add(time.Hour), time.Minute)
if objectExists(t, store, path) {
t.Error("blob still stored after reclaim")
}
if got := queuedPaths(t, db); len(got) != 0 {
t.Errorf("queue = %v, want it drained", got)
}
}
func TestBackfillMetadataAccessDrainsInBatches(t *testing.T) {
db, _ := setupEvictionTest(t)
for _, name := range []string{"a", "b", "c", "d", "e"} {
query := db.Rebind(`INSERT INTO metadata_cache (ecosystem, name, storage_path, size, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`)
if _, err := db.Exec(query, "npm", name, "_metadata/npm/"+name+"/metadata", 1, time.Now(), time.Now()); err != nil {
t.Fatalf("inserting: %v", err)
}
}
backfillMetadataAccess(context.Background(), db, slog.New(slog.NewTextHandler(io.Discard, nil)), 2)
var left int
if err := db.Get(&left, `SELECT COUNT(*) FROM metadata_cache WHERE last_accessed_at IS NULL`); err != nil {
t.Fatalf("counting: %v", err)
}
if left != 0 {
t.Errorf("%d rows left without an access time", left)
}
}