GC engine (internal/gc/): Collector.Run() implements the two-phase algorithm — Phase 1 finds unreferenced blobs and deletes DB rows in a single transaction, Phase 2 deletes blob files from storage. Registry-wide mutex blocks concurrent GC runs. Collector.Reconcile() scans filesystem for orphaned files with no DB row (crash recovery). Wired into admin_gc.go: POST /v1/gc now launches the real collector in a goroutine with gc_started/gc_completed audit events. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
231 lines
5.2 KiB
Go
231 lines
5.2 KiB
Go
package gc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// fakeDB implements gc.DB for tests.
|
|
type fakeDB struct {
|
|
mu sync.Mutex
|
|
unreferenced []UnreferencedBlob
|
|
blobsExist map[string]bool
|
|
}
|
|
|
|
func newFakeDB() *fakeDB {
|
|
return &fakeDB{
|
|
blobsExist: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
func (f *fakeDB) FindAndDeleteUnreferencedBlobs() ([]UnreferencedBlob, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
result := make([]UnreferencedBlob, len(f.unreferenced))
|
|
copy(result, f.unreferenced)
|
|
// Simulate deletion by removing from blobsExist.
|
|
for _, b := range f.unreferenced {
|
|
delete(f.blobsExist, b.Digest)
|
|
}
|
|
f.unreferenced = nil
|
|
return result, nil
|
|
}
|
|
|
|
func (f *fakeDB) BlobExistsByDigest(digest string) (bool, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.blobsExist[digest], nil
|
|
}
|
|
|
|
// fakeStorage implements gc.Storage for tests.
|
|
type fakeStorage struct {
|
|
mu sync.Mutex
|
|
blobs map[string]int64 // digest -> size
|
|
deleted []string
|
|
}
|
|
|
|
func newFakeStorage() *fakeStorage {
|
|
return &fakeStorage{
|
|
blobs: make(map[string]int64),
|
|
}
|
|
}
|
|
|
|
func (f *fakeStorage) Delete(digest string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, ok := f.blobs[digest]; !ok {
|
|
return errors.New("not found")
|
|
}
|
|
delete(f.blobs, digest)
|
|
f.deleted = append(f.deleted, digest)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStorage) ListBlobDigests() ([]string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var digests []string
|
|
for d := range f.blobs {
|
|
digests = append(digests, d)
|
|
}
|
|
return digests, nil
|
|
}
|
|
|
|
func TestGCRemovesUnreferencedBlobs(t *testing.T) {
|
|
db := newFakeDB()
|
|
db.unreferenced = []UnreferencedBlob{
|
|
{Digest: "sha256:dead1", Size: 100},
|
|
{Digest: "sha256:dead2", Size: 200},
|
|
}
|
|
db.blobsExist["sha256:dead1"] = true
|
|
db.blobsExist["sha256:dead2"] = true
|
|
db.blobsExist["sha256:alive"] = true // referenced, not in unreferenced list
|
|
|
|
store := newFakeStorage()
|
|
store.blobs["sha256:dead1"] = 100
|
|
store.blobs["sha256:dead2"] = 200
|
|
store.blobs["sha256:alive"] = 300
|
|
|
|
c := New(db, store)
|
|
result, err := c.Run(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
|
|
if result.BlobsRemoved != 2 {
|
|
t.Fatalf("BlobsRemoved: got %d, want 2", result.BlobsRemoved)
|
|
}
|
|
if result.BytesFreed != 300 {
|
|
t.Fatalf("BytesFreed: got %d, want 300", result.BytesFreed)
|
|
}
|
|
|
|
// Dead blobs should be deleted from storage.
|
|
if _, ok := store.blobs["sha256:dead1"]; ok {
|
|
t.Fatal("sha256:dead1 should have been deleted from storage")
|
|
}
|
|
if _, ok := store.blobs["sha256:dead2"]; ok {
|
|
t.Fatal("sha256:dead2 should have been deleted from storage")
|
|
}
|
|
|
|
// Alive blob should still exist.
|
|
if _, ok := store.blobs["sha256:alive"]; !ok {
|
|
t.Fatal("sha256:alive should still exist in storage")
|
|
}
|
|
}
|
|
|
|
func TestGCDoesNotRemoveReferencedBlobs(t *testing.T) {
|
|
db := newFakeDB()
|
|
// No unreferenced blobs.
|
|
db.blobsExist["sha256:alive"] = true
|
|
|
|
store := newFakeStorage()
|
|
store.blobs["sha256:alive"] = 500
|
|
|
|
c := New(db, store)
|
|
result, err := c.Run(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
|
|
if result.BlobsRemoved != 0 {
|
|
t.Fatalf("BlobsRemoved: got %d, want 0", result.BlobsRemoved)
|
|
}
|
|
|
|
if _, ok := store.blobs["sha256:alive"]; !ok {
|
|
t.Fatal("referenced blob should not be deleted")
|
|
}
|
|
}
|
|
|
|
func TestGCConcurrentRejected(t *testing.T) {
|
|
db := newFakeDB()
|
|
store := newFakeStorage()
|
|
c := New(db, store)
|
|
|
|
// Acquire the lock manually.
|
|
c.mu.Lock()
|
|
|
|
// Try to run GC — should fail.
|
|
_, err := c.Run(context.Background())
|
|
if !errors.Is(err, ErrGCRunning) {
|
|
t.Fatalf("expected ErrGCRunning, got %v", err)
|
|
}
|
|
|
|
c.mu.Unlock()
|
|
|
|
// Now it should work.
|
|
result, err := c.Run(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Run after unlock: %v", err)
|
|
}
|
|
if result.BlobsRemoved != 0 {
|
|
t.Fatalf("BlobsRemoved: got %d, want 0", result.BlobsRemoved)
|
|
}
|
|
}
|
|
|
|
func TestGCEmptyRegistry(t *testing.T) {
|
|
db := newFakeDB()
|
|
store := newFakeStorage()
|
|
c := New(db, store)
|
|
|
|
result, err := c.Run(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if result.BlobsRemoved != 0 {
|
|
t.Fatalf("BlobsRemoved: got %d, want 0", result.BlobsRemoved)
|
|
}
|
|
if result.Duration <= 0 {
|
|
t.Fatal("Duration should be positive")
|
|
}
|
|
}
|
|
|
|
func TestReconcileCleansOrphanedFiles(t *testing.T) {
|
|
db := newFakeDB()
|
|
// Only sha256:alive has a DB row.
|
|
db.blobsExist["sha256:alive"] = true
|
|
|
|
store := newFakeStorage()
|
|
store.blobs["sha256:alive"] = 100
|
|
store.blobs["sha256:orphan1"] = 200
|
|
store.blobs["sha256:orphan2"] = 300
|
|
|
|
c := New(db, store)
|
|
result, err := c.Reconcile(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Reconcile: %v", err)
|
|
}
|
|
|
|
if result.BlobsRemoved != 2 {
|
|
t.Fatalf("BlobsRemoved: got %d, want 2", result.BlobsRemoved)
|
|
}
|
|
|
|
// Alive blob should still exist.
|
|
if _, ok := store.blobs["sha256:alive"]; !ok {
|
|
t.Fatal("sha256:alive should still exist")
|
|
}
|
|
|
|
// Orphans should be gone.
|
|
if _, ok := store.blobs["sha256:orphan1"]; ok {
|
|
t.Fatal("sha256:orphan1 should have been deleted")
|
|
}
|
|
if _, ok := store.blobs["sha256:orphan2"]; ok {
|
|
t.Fatal("sha256:orphan2 should have been deleted")
|
|
}
|
|
}
|
|
|
|
func TestReconcileEmptyStorage(t *testing.T) {
|
|
db := newFakeDB()
|
|
store := newFakeStorage()
|
|
c := New(db, store)
|
|
|
|
result, err := c.Reconcile(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Reconcile: %v", err)
|
|
}
|
|
if result.BlobsRemoved != 0 {
|
|
t.Fatalf("BlobsRemoved: got %d, want 0", result.BlobsRemoved)
|
|
}
|
|
}
|