git@cosmocr.at/gitweb.git @ HEAD · summary refs log
HEAD / internal / gitrepo / cache_test.go /
internal/gitrepo/cache_test.go · 5.9 KiB · raw
view: SourceMarkdown ☑ force

package gitrepo

import ( "os" "path/filepath" "sync" "testing" "time"

"gitweb/internal/store"

)

// dirCount returns the number of children under the cache root. func dirCount(t *testing.T, root string) int { t.Helper() children, err := os.ReadDir(root) if err != nil { t.Fatal(err) } return len(children) }

// twoRepos builds two distinct bare repos in one LocalStore and returns the // store and their repo paths. func twoRepos(t *testing.T) (st store.Store, pathA, pathB string) { t.Helper() root := t.TempDir() pathA = makeBareRepo(t, root, "u@d.com", "a.git") pathB = makeBareRepo(t, root, "u@d.com", "b.git") return store.NewLocalStore(root), pathA, pathB }

// repoBytes returns the materialized size of repoPath (sum of manifest sizes). func repoBytes(t *testing.T, st store.Store, repoPath string) int64 { t.Helper() c := newTestCache(t, st, CacheOptions{}) _, release, err := c.Get(repoPath) if err != nil { t.Fatal(err) } defer release() c.mu.Lock() defer c.mu.Unlock() return c.total }

func TestCacheByteEviction(t *testing.T) { st, pathA, pathB := twoRepos(t) sizeA := repoBytes(t, st, pathA)

// Budget fits one repo but not two: getting B must evict A.
dir := t.TempDir()
c := newTestCache(t, st, CacheOptions{Dir: dir, BudgetBytes: sizeA + sizeA/2})

rA, releaseA, err := c.Get(pathA)
if err != nil {
	t.Fatal(err)
}
releaseA()
if got := dirCount(t, dir); got != 1 {
	t.Fatalf("after Get A: %d dirs, want 1", got)
}

_, releaseB, err := c.Get(pathB)
if err != nil {
	t.Fatal(err)
}
releaseB()
if got := dirCount(t, dir); got != 1 {
	t.Fatalf("after Get B evicted A: %d dirs, want 1", got)
}

// A re-materializes on demand as a fresh entry.
rA2, releaseA2, err := c.Get(pathA)
if err != nil {
	t.Fatal(err)
}
defer releaseA2()
if rA2 == rA {
	t.Fatal("expected evicted repo to re-materialize (different pointer)")
}

}

func TestCacheHoldAcrossEviction(t *testing.T) { st, pathA, pathB := twoRepos(t) sizeA := repoBytes(t, st, pathA)

dir := t.TempDir()
c := newTestCache(t, st, CacheOptions{Dir: dir, BudgetBytes: sizeA + sizeA/2})

rA, releaseA, err := c.Get(pathA)
if err != nil {
	t.Fatal(err)
}
// A is still held when B evicts it: its dir must survive.
_, releaseB, err := c.Get(pathB)
if err != nil {
	t.Fatal(err)
}
defer releaseB()
if got := dirCount(t, dir); got != 2 {
	t.Fatalf("evicted-but-held entry deleted early: %d dirs, want 2", got)
}
// The held repo still reads from its (retired) backing dir.
if _, err := rA.Head(); err != nil {
	t.Fatalf("held repo unreadable after eviction: %v", err)
}
releaseA()
if got := dirCount(t, dir); got != 1 {
	t.Fatalf("after release: %d dirs, want 1", got)
}

}

func TestCacheInvalidateDuringHold(t *testing.T) { root := t.TempDir() repoPath := makeBareRepo(t, root, "u@d.com", "demo.git") st := store.NewLocalStore(root)

dir := t.TempDir()
c := newTestCache(t, st, CacheOptions{Dir: dir})

r1, release1, err := c.Get(repoPath)
if err != nil {
	t.Fatal(err)
}
c.Invalidate(repoPath)
if got := dirCount(t, dir); got != 1 {
	t.Fatalf("invalidated-but-held entry deleted early: %d dirs, want 1", got)
}
if _, err := r1.Head(); err != nil {
	t.Fatalf("held repo unreadable after Invalidate: %v", err)
}

// A new Get re-materializes into a fresh dir.
r2, release2, err := c.Get(repoPath)
if err != nil {
	t.Fatal(err)
}
defer release2()
if r2 == r1 {
	t.Fatal("expected fresh repo after Invalidate")
}
if got := dirCount(t, dir); got != 2 {
	t.Fatalf("old + new dirs: %d, want 2", got)
}

release1()
if got := dirCount(t, dir); got != 1 {
	t.Fatalf("old dir survives its last release: %d dirs, want 1", got)
}

}

func TestCacheConcurrentGet(t *testing.T) { root := t.TempDir() repoPath := makeBareRepo(t, root, "u@d.com", "demo.git") st := store.NewLocalStore(root)

dir := t.TempDir()
c := newTestCache(t, st, CacheOptions{Dir: dir})

const n = 8
var wg sync.WaitGroup
errs := make(chan error, n)
for range n {
	wg.Add(1)
	go func() {
		defer wg.Done()
		repo, release, err := c.Get(repoPath)
		if err != nil {
			errs <- err
			return
		}
		defer release()
		if _, err := repo.Head(); err != nil {
			errs <- err
		}
	}()
}
wg.Wait()
close(errs)
for err := range errs {
	t.Fatal(err)
}

// Race losers cleaned up after themselves: one dir remains.
if got := dirCount(t, dir); got != 1 {
	t.Fatalf("%d dirs after concurrent Gets, want 1", got)
}
// And it is a usable cache hit.
_, release, err := c.Get(repoPath)
if err != nil {
	t.Fatal(err)
}
release()

}

func TestCacheStaleReleaseDeletesOldDir(t *testing.T) { root := t.TempDir() repoPath := makeBareRepo(t, root, "u@d.com", "demo.git") st := store.NewLocalStore(root)

dir := t.TempDir()
c := newTestCache(t, st, CacheOptions{Dir: dir})

_, release1, err := c.Get(repoPath)
if err != nil {
	t.Fatal(err)
}
release1()

// Bump the repo's mtime so LocalStore.Seq changes; the stale entry (now
// unreferenced) is retired and deleted during the next Get.
later := time.Now().Add(2 * time.Second)
if err := os.Chtimes(filepath.Join(root, repoPath, "HEAD"), later, later); err != nil {
	t.Fatal(err)
}
_, release2, err := c.Get(repoPath)
if err != nil {
	t.Fatal(err)
}
defer release2()
if got := dirCount(t, dir); got != 1 {
	t.Fatalf("stale dir not deleted: %d dirs, want 1", got)
}

}

func TestCacheSingleRepoOverBudget(t *testing.T) { root := t.TempDir() repoPath := makeBareRepo(t, root, "u@d.com", "demo.git") st := store.NewLocalStore(root)

// Budget of 1 byte: the sole entry must still be kept and servable.
dir := t.TempDir()
c := newTestCache(t, st, CacheOptions{Dir: dir, BudgetBytes: 1})

r1, release1, err := c.Get(repoPath)
if err != nil {
	t.Fatal(err)
}
release1()
if got := dirCount(t, dir); got != 1 {
	t.Fatalf("sole over-budget entry evicted: %d dirs, want 1", got)
}
r2, release2, err := c.Get(repoPath)
if err != nil {
	t.Fatal(err)
}
defer release2()
if r1 != r2 {
	t.Fatal("expected the over-budget entry to be reused")
}

}