Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-11 08:39:17 -05:00
pkg-proxy/internal/database/pending_deletes_test.go
montehurd 4ce6c37bd9 Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.

storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.

A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 16:14:43 -07:00

199 lines
6.9 KiB
Go

package database
import (
"database/sql"
"fmt"
"slices"
"sync"
"testing"
"time"
)
const (
pendingVersionPURL = "pkg:npm/pending@1.0.0"
pendingFilename = "pending-1.0.0.tgz"
)
func upsertPendingArtifact(t *testing.T, db *DB, storagePath string) {
t.Helper()
a := &Artifact{
VersionPURL: pendingVersionPURL,
Filename: pendingFilename,
UpstreamURL: "https://example.com/" + pendingFilename,
StoragePath: sql.NullString{String: storagePath, Valid: true},
Size: sql.NullInt64{Int64: 1, Valid: true},
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
}
if err := db.UpsertArtifact(a); err != nil {
t.Fatalf("UpsertArtifact(%q): %v", storagePath, err)
}
}
func recordedPath(t *testing.T, db *DB) sql.NullString {
t.Helper()
a, err := db.GetArtifact(pendingVersionPURL, pendingFilename)
if err != nil || a == nil {
t.Fatalf("GetArtifact: %v, %v", a, err)
}
return a.StoragePath
}
// allPendingDeletes returns every queued path, due or not.
func allPendingDeletes(t *testing.T, db *DB) []string {
t.Helper()
paths, err := db.GetDuePendingDeletes(time.Now().Add(time.Hour), 100)
if err != nil {
t.Fatalf("GetDuePendingDeletes: %v", err)
}
return paths
}
func TestUpsertArtifactQueuesThePathItReplaces(t *testing.T) {
runWithBothDatabases(t, func(t *testing.T, db *DB) {
upsertPendingArtifact(t, db, "npm/pending/1.0.0/a/pending-1.0.0.tgz")
upsertPendingArtifact(t, db, "npm/pending/1.0.0/a/pending-1.0.0.tgz")
if got := allPendingDeletes(t, db); len(got) != 0 {
t.Fatalf("queued %v, want nothing while the record keeps its path", got)
}
upsertPendingArtifact(t, db, "npm/pending/1.0.0/b/pending-1.0.0.tgz")
want := []string{"npm/pending/1.0.0/a/pending-1.0.0.tgz"}
if got := allPendingDeletes(t, db); !slices.Equal(got, want) {
t.Errorf("queued %v, want %v", got, want)
}
})
}
// TestUpsertArtifactSkipsRecordThatMoved is the race UpsertArtifact retries:
// another commit moved the record after this one read it, so the write must
// not apply, or the path that commit stored would never be queued.
func TestUpsertArtifactSkipsRecordThatMoved(t *testing.T) {
runWithBothDatabases(t, func(t *testing.T, db *DB) {
upsertPendingArtifact(t, db, "npm/pending/1.0.0/a/pending-1.0.0.tgz")
read := recordedPath(t, db)
upsertPendingArtifact(t, db, "npm/pending/1.0.0/b/pending-1.0.0.tgz")
late := &Artifact{
VersionPURL: pendingVersionPURL,
Filename: pendingFilename,
UpstreamURL: "https://example.com/" + pendingFilename,
StoragePath: sql.NullString{String: "npm/pending/1.0.0/c/pending-1.0.0.tgz", Valid: true},
}
applied, err := db.upsertArtifactFrom(late, read)
if err != nil {
t.Fatalf("upsertArtifactFrom: %v", err)
}
if applied {
t.Error("write applied over a record that moved since it was read")
}
if got := recordedPath(t, db).String; got != "npm/pending/1.0.0/b/pending-1.0.0.tgz" {
t.Errorf("record points at %q, want the newer commit kept", got)
}
missing := &Artifact{VersionPURL: "pkg:npm/pending@2.0.0", Filename: pendingFilename, UpstreamURL: "u"}
applied, err = db.upsertArtifactFrom(missing, sql.NullString{})
if err != nil || !applied {
t.Errorf("insert of a new record: applied=%v err=%v", applied, err)
}
})
}
// TestConcurrentUpsertsQueueEveryReplacedPath commits one artifact from several
// fetches at once: whichever commit ends up recorded, every other path must be
// queued, or its object is never deleted.
func TestConcurrentUpsertsQueueEveryReplacedPath(t *testing.T) {
runWithBothDatabases(t, func(t *testing.T, db *DB) {
// A commit only retries after another commit succeeded, so this many
// always fit within upsertArtifactAttempts.
const commits = upsertArtifactAttempts - 1
paths := make([]string, commits)
errs := make(chan error, commits)
var wg sync.WaitGroup
for i := range paths {
paths[i] = fmt.Sprintf("npm/pending/1.0.0/%d/pending-1.0.0.tgz", i)
wg.Go(func() {
errs <- db.UpsertArtifact(&Artifact{
VersionPURL: pendingVersionPURL,
Filename: pendingFilename,
UpstreamURL: "https://example.com/" + pendingFilename,
StoragePath: sql.NullString{String: paths[i], Valid: true},
})
})
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("UpsertArtifact: %v", err)
}
}
recorded := recordedPath(t, db).String
want := slices.DeleteFunc(slices.Clone(paths), func(p string) bool { return p == recorded })
got := allPendingDeletes(t, db)
slices.Sort(want)
slices.Sort(got)
if !slices.Equal(got, want) {
t.Errorf("recorded %q, queued %v, want %v", recorded, got, want)
}
})
}
func TestClearArtifactCacheLeavesRecordThatMoved(t *testing.T) {
runWithBothDatabases(t, func(t *testing.T, db *DB) {
upsertPendingArtifact(t, db, "npm/pending/1.0.0/a/pending-1.0.0.tgz")
upsertPendingArtifact(t, db, "npm/pending/1.0.0/b/pending-1.0.0.tgz")
if cleared, err := db.ClearArtifactCache(pendingVersionPURL, pendingFilename, "npm/pending/1.0.0/a/pending-1.0.0.tgz"); err != nil || cleared {
t.Fatalf("ClearArtifactCache: cleared=%v err=%v, want nothing cleared", cleared, err)
}
if err := db.DiscardArtifact(pendingVersionPURL, pendingFilename, "npm/pending/1.0.0/a/pending-1.0.0.tgz"); err != nil {
t.Fatalf("DiscardArtifact: %v", err)
}
if got := recordedPath(t, db).String; got != "npm/pending/1.0.0/b/pending-1.0.0.tgz" {
t.Errorf("record points at %q, want the newer commit kept", got)
}
})
}
func TestDiscardArtifactClearsAndQueues(t *testing.T) {
runWithBothDatabases(t, func(t *testing.T, db *DB) {
upsertPendingArtifact(t, db, "npm/pending/1.0.0/a/pending-1.0.0.tgz")
if err := db.DiscardArtifact(pendingVersionPURL, pendingFilename, "npm/pending/1.0.0/a/pending-1.0.0.tgz"); err != nil {
t.Fatalf("DiscardArtifact: %v", err)
}
if got := recordedPath(t, db); got.Valid {
t.Errorf("record still points at %q", got.String)
}
want := []string{"npm/pending/1.0.0/a/pending-1.0.0.tgz"}
if got := allPendingDeletes(t, db); !slices.Equal(got, want) {
t.Errorf("queued %v, want %v", got, want)
}
})
}
func TestGetDuePendingDeletes(t *testing.T) {
runWithBothDatabases(t, func(t *testing.T, db *DB) {
for _, path := range []string{"old", "referenced"} {
if err := db.QueuePendingDelete(path); err != nil {
t.Fatalf("QueuePendingDelete: %v", err)
}
}
upsertPendingArtifact(t, db, "referenced")
if got, err := db.GetDuePendingDeletes(time.Now().Add(-time.Hour), 100); err != nil || len(got) != 0 {
t.Errorf("before the grace period: got %v (err %v), want nothing", got, err)
}
if got := allPendingDeletes(t, db); !slices.Equal(got, []string{"old"}) {
t.Errorf("after the grace period: got %v, want [old] and not the path a record points at", got)
}
if err := db.RemovePendingDelete("old"); err != nil {
t.Fatalf("RemovePendingDelete: %v", err)
}
if got := allPendingDeletes(t, db); len(got) != 0 {
t.Errorf("after removal: got %v", got)
}
})
}