Skip to content
quarry Artifact [bc2a828fe8]
Login

Artifact [bc2a828fe8]

Artifact bc2a828fe884c297db67e9395ba30b0fe1308d09df5013c370b292196c17765f:


package zitcli

import (
	"context"
	"log"
	"os"
	"runtime"
	"strings"
	"testing"
	"time"
)

// The budget is in BYTES because the values are file contents: an
// entry-counted cache with a 30MB blob in it is a memory leak with a
// polite name.
func TestBlobCacheEvictsByBytes(t *testing.T) {
	c := newBlobCache(100)
	c.put("a", make([]byte, 60))
	c.put("b", make([]byte, 30))
	if n, b := c.stats(); n != 2 || b != 90 {
		t.Fatalf("entries=%d bytes=%d, want 2/90", n, b)
	}

	// Admitting c must evict the least recently used, not the oldest
	// inserted — so touch a first.
	if _, ok := c.get("a"); !ok {
		t.Fatal("a missing")
	}
	c.put("d", make([]byte, 30))
	if _, ok := c.get("b"); ok {
		t.Error("evicted the recently-used entry instead of the least-recently-used")
	}
	if _, ok := c.get("a"); !ok {
		t.Error("a was evicted despite being used most recently")
	}
	if _, b := c.stats(); b > 100 {
		t.Errorf("cache holds %d bytes, over its 100-byte budget", b)
	}
}

// A value bigger than the whole budget must be refused rather than
// admitted by evicting everything: holding one item at the cost of all
// the others is worse than a miss.
func TestBlobCacheRefusesOversizedValues(t *testing.T) {
	c := newBlobCache(100)
	c.put("small", []byte("keep me"))
	c.put("huge", make([]byte, 500))

	if _, ok := c.get("huge"); ok {
		t.Error("cached a value larger than the entire budget")
	}
	if _, ok := c.get("small"); !ok {
		t.Error("an oversized put evicted a value that fit")
	}
}

// Replacing a key must not double-count its bytes.
func TestBlobCacheReplaceAccountsCorrectly(t *testing.T) {
	c := newBlobCache(100)
	c.put("k", make([]byte, 40))
	c.put("k", make([]byte, 10))
	if n, b := c.stats(); n != 1 || b != 10 {
		t.Fatalf("entries=%d bytes=%d, want 1/10", n, b)
	}
}

// Past the forge-wide ceiling, the cache doing the put gives up its own
// oldest entries; a quiet repository's cache is untouched. This is what
// stopped thirty-three per-repository budgets adding up to more memory
// than the machine had.
func TestCeilingEvictsThePuttingCacheOnly(t *testing.T) {
	var f ceiling
	f.max.Store(100)
	quiet, busy := newBlobCache(1000), newBlobCache(1000)
	quiet.ceil, busy.ceil = &f, &f

	quiet.put("q", make([]byte, 40))
	for _, k := range []string{"a", "b", "c", "d"} {
		busy.put(k, make([]byte, 20))
	}
	if f.used.Load() > 100 {
		t.Fatalf("ceiling holds %d bytes, over its 100", f.used.Load())
	}
	if _, ok := quiet.get("q"); !ok {
		t.Fatal("a quiet cache lost its entry to another cache's traffic")
	}
	if _, ok := busy.get("a"); ok {
		t.Error("the busy cache kept its oldest entry past the ceiling")
	}
	if _, ok := busy.get("d"); !ok {
		t.Error("the busy cache dropped its newest entry instead of its oldest")
	}
}

// When the putting cache has nothing of its own left to give, the new
// value is not kept, rather than taking the forge over its ceiling.
func TestCeilingRefusesWhenNothingOfOwnToEvict(t *testing.T) {
	var f ceiling
	f.max.Store(100)
	quiet, busy := newBlobCache(1000), newBlobCache(1000)
	quiet.ceil, busy.ceil = &f, &f
	quiet.put("q", make([]byte, 90))
	busy.put("x", make([]byte, 30))
	if _, ok := busy.get("x"); ok {
		t.Error("cached a value that took the forge past its ceiling")
	}
	if n, b := busy.stats(); n != 0 || b != 0 || f.used.Load() != 90 {
		t.Errorf("busy holds %d/%d, ceiling %d; want 0/0 and 90", n, b, f.used.Load())
	}
}

// A blob is charged for the buffer it lives in, not its length: zit's
// output is read into a buffer that grows by doubling.
func TestBlobChargedByCapacity(t *testing.T) {
	c := newBlobCache(1 << 20)
	c.ceil = &ceiling{}
	c.put("k", make([]byte, 10, 64))
	if _, b := c.stats(); b != 64 {
		t.Errorf("charged %d bytes for a 64-byte buffer", b)
	}
}

// Listings are bounded by bytes: one listing of a large store weighs
// more than many of a small one.
func TestListingCacheEvictsByBytes(t *testing.T) {
	big := make([]LsEntry, 1000)
	for i := range big {
		big[i] = LsEntry{Blob: strings.Repeat("b", 64), Name: "some/path/name.c", Perm: "-"}
	}
	one := newListingCache(0).sizeOf(big)
	c := newListingCache(one + one/2)
	c.ceil = &ceiling{}
	c.put("v1", big)
	c.put("v2", big)
	if n, b := c.stats(); n != 1 || b > one+one/2 {
		t.Fatalf("entries=%d bytes=%d, want 1 within %d", n, b, one+one/2)
	}
	if _, ok := c.get("v2"); !ok {
		t.Error("kept the older listing instead of the newer")
	}
}

// Growing an entry in place can evict the entry itself; the accounting
// must come back to zero with it rather than drift.
func TestReplaceWithLargerAccounts(t *testing.T) {
	var f ceiling
	f.max.Store(100)
	c := newBlobCache(1000)
	c.ceil = &f
	c.put("a", make([]byte, 30))
	c.put("k", make([]byte, 30))
	c.put("k", make([]byte, 90))
	if n, b := c.stats(); n != 1 || b != 90 || f.used.Load() != 90 {
		t.Fatalf("entries=%d bytes=%d ceiling=%d; want 1/90/90", n, b, f.used.Load())
	}
	// Bigger than the ceiling outright: refused, and the old value stays.
	c.put("k", make([]byte, 150))
	if n, b := c.stats(); n != 1 || b != 90 || f.used.Load() != 90 {
		t.Fatalf("after an oversized replace: entries=%d bytes=%d ceiling=%d; want 1/90/90", n, b, f.used.Load())
	}
	// Fits the ceiling, but not beside another cache's bytes: growing
	// in place evicts the entry itself, and the charge goes with it.
	other := newBlobCache(1000)
	other.ceil = &f
	c.put("k", make([]byte, 10))
	other.put("o", make([]byte, 50))
	c.put("k", make([]byte, 60))
	if n, b := c.stats(); n != 0 || b != 0 || f.used.Load() != 50 {
		t.Fatalf("after a replace that cannot fit: entries=%d bytes=%d ceiling=%d; want 0/0/50", n, b, f.used.Load())
	}
}

// release gives a cache's bytes back to the ceiling.
func TestReleaseReturnsCharge(t *testing.T) {
	var f ceiling
	c := newBlobCache(1000)
	c.ceil = &f
	c.put("a", make([]byte, 40))
	c.put("b", make([]byte, 20))
	c.release()
	if n, b := c.stats(); n != 0 || b != 0 || f.used.Load() != 0 {
		t.Fatalf("after release: entries=%d bytes=%d ceiling=%d", n, b, f.used.Load())
	}
}

// A Client nobody holds any more gives its bytes back to the forge, or
// the ceiling would fill with repositories that were removed.
func TestDroppedClientReleasesCeiling(t *testing.T) {
	before := forgeCeiling.used.Load()
	func() {
		c := New(t.TempDir())
		c.blobs.put("k", make([]byte, 4096))
	}()
	if forgeCeiling.used.Load() < before+4096 {
		t.Fatal("the put was not charged to the forge ceiling")
	}
	deadline := time.Now().Add(10 * time.Second)
	for forgeCeiling.used.Load() > before {
		if time.Now().After(deadline) {
			t.Fatalf("ceiling still holds %d bytes of a dropped client", forgeCeiling.used.Load()-before)
		}
		runtime.GC()
		time.Sleep(10 * time.Millisecond)
	}
}

// A value the cache cannot keep is logged, naming the repository and
// the cache, once a minute rather than once a request: a miss that
// repeats is otherwise invisible, and on a large store it is a minute
// per page.
func TestARefusedValueIsLoggedOnceAMinute(t *testing.T) {
	var buf strings.Builder
	log.SetOutput(&buf)
	defer log.SetOutput(os.Stderr)

	var f ceiling
	f.max.Store(100)
	quiet, busy := newBlobCache(1000), newBlobCache(1000)
	quiet.ceil, busy.ceil = &f, &f
	busy.repo, busy.kind = "netbsd-src", "listing"
	quiet.put("q", make([]byte, 90))
	busy.put("x", make([]byte, 30))
	busy.put("y", make([]byte, 30))

	lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
	if len(lines) != 1 || !strings.Contains(lines[0], "netbsd-src") || !strings.Contains(lines[0], "listing") {
		t.Fatalf("FAIL: want one line naming the repository and the cache, got %q", buf.String())
	}
	busy.dropLogged = time.Now().Add(-2 * time.Minute)
	busy.put("z", make([]byte, 2000))
	if n := strings.Count(strings.TrimSpace(buf.String()), "\n") + 1; n != 2 {
		t.Fatalf("FAIL: a value over the budget a minute later logged %d lines in all, want 2", n)
	}
	t.Log("ok")
}

// A repository reports what each of its caches holds, by kind.
func TestCacheUseReportsEachCache(t *testing.T) {
	c := New(t.TempDir())
	c.blobs.put("k", make([]byte, 10, 64))
	got := map[string]CacheUse{}
	for _, u := range c.CacheUse() {
		got[u.Kind] = u
	}
	if len(got) != 3 || got["file"].Entries != 1 || got["file"].Bytes != 64 || got["listing"].Budget != lsCacheBytes {
		t.Fatalf("FAIL: cache use %+v", got)
	}
	if used, _ := ForgeCacheUse(); used < 64 {
		t.Fatalf("FAIL: the forge's caches report %d bytes with 64 held", used)
	}
	t.Log("ok")
}

// The names listing of a check-in whose annotated listing is cached is
// that listing: one copy, not two. On netbsd-src the two together were
// over the repository's listing budget, so the About panel and the file
// tree evicted each other's and each page recomputed a minute of zit.
func TestNamesAreServedFromACachedAnnotatedListing(t *testing.T) {
	c := New(t.TempDir() + "/no-such-store")
	v := strings.Repeat("ab", 32)
	c.lsCache.put(v+"\x00", []LsEntry{{Blob: "b1", Name: "README", Size: 10, Perm: "-"}})
	got, err := c.LsNames(context.Background(), v)
	if err != nil || len(got) != 1 || got[0].Name != "README" || got[0].Blob != "b1" {
		t.Fatalf("FAIL: LsNames = %+v, %v; want the cached annotated listing", got, err)
	}
	if n, _ := c.lsCache.stats(); n != 1 {
		t.Fatalf("FAIL: the listing cache holds %d entries, want the one", n)
	}
	t.Log("ok")
}