Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: tests
on:
push:
tags:
- v*
branches:
- master
- patchset
pull_request:
jobs:
go-test-race:
name: go test -race
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- name: go test -race
run: go test -race ./...
1 change: 1 addition & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ go_library(
visibility = ["//visibility:private"],
deps = [
"//cache/disk:go_default_library",
"//cache/lruflush:go_default_library",
"//config:go_default_library",
"//ldap:go_default_library",
"//server:go_default_library",
Expand Down
2 changes: 2 additions & 0 deletions cache/disk/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ go_test(
"findmissing_test.go",
"lru_capture_bench_test.go",
"lru_capture_test.go",
"lru_maxentries_test.go",
"lru_mem_test.go",
"lru_test.go",
"operation_observer_test.go",
],
Expand Down
8 changes: 8 additions & 0 deletions cache/disk/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,14 @@ func (c *diskCache) loadExistingFiles(maxSizeBytes int64, cc CacheConfig) error
bytesToGigaBytes(c.lru.maxSizeHardLimit))
}

if cc.maxEntries > 0 {
// Set before the Add loop below, so a cache directory holding
// more files than the cap is trimmed (oldest first) during load,
// like a restart with a smaller maxSize.
c.lru.maxEntries = cc.maxEntries
log.Printf("Will evict at max entries: %d", cc.maxEntries)
}

// Start one single goroutine running in background, continuously
// waiting for files to be removed and removing them. Benchmarks on
// Linux with the XFS file system have surprisingly shown that removal
Expand Down
30 changes: 30 additions & 0 deletions cache/disk/lru.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ type SizedLRU struct {
// cache below maxSize.
maxSize int64

// When positive, SizedLRU additionally evicts items as needed to keep
// the number of resident entries at or below maxEntries. This bounds
// the index metadata (key string, entry struct, list node, map slot -
// a fixed cost per entry regardless of blob size), which the byte
// budget alone does not: zero-byte blobs charge nothing against
// maxSize and tiny blobs charge at most one 4 KiB block, so without a
// count bound the metadata of a byte-full cache is effectively
// unbounded. Zero or negative means no entry-count bound.
maxEntries int64

// Channel containing evicted entries removed from ll, but not yet
// removed from the file system.
//
Expand Down Expand Up @@ -65,6 +75,7 @@ type SizedLRU struct {
gaugeCacheLogicalBytes prometheus.Gauge
counterEvictedBytes prometheus.Counter
counterOverwrittenBytes prometheus.Counter
counterMaxEntriesEvicted prometheus.Counter

summaryCacheItemBytes prometheus.Summary

Expand Down Expand Up @@ -130,6 +141,10 @@ func NewSizedLRU(maxSize int64, onEvict EvictCallback, initialCapacity int) Size
Name: "bazel_remote_disk_cache_overwritten_bytes_total",
Help: "The total number of bytes removed from disk backend, due to put of already existing key",
}),
counterMaxEntriesEvicted: prometheus.NewCounter(prometheus.CounterOpts{
Name: "bazel_remote_disk_cache_max_entries_evictions_total",
Help: "The total number of entries evicted because the entry-count limit was reached, rather than the byte-size limit",
}),
summaryCacheItemBytes: prometheus.NewSummary(prometheus.SummaryOpts{
Name: "bazel_remote_disk_cache_entry_bytes",
Help: "Size of cache entries",
Expand All @@ -150,6 +165,7 @@ func (c *SizedLRU) RegisterMetrics() {
prometheus.MustRegister(c.gaugeCacheLogicalBytes)
prometheus.MustRegister(c.counterEvictedBytes)
prometheus.MustRegister(c.counterOverwrittenBytes)
prometheus.MustRegister(c.counterMaxEntriesEvicted)
prometheus.MustRegister(c.summaryCacheItemBytes)

// Set gauges to constant configured values to help visualize configured limits
Expand Down Expand Up @@ -222,6 +238,20 @@ func (c *SizedLRU) Add(key string, value lruItem) (ok bool) {
}
}

// Entry-count eviction (see maxEntries). Only inserts of new keys can
// grow the count, and the new entry is at the front of the list, so
// the evicted tail is always an older entry.
if c.maxEntries > 0 {
for int64(c.ll.Len()) > c.maxEntries {
ele := c.ll.Back()
if ele == nil {
break
}
c.removeElement(ele)
c.counterMaxEntriesEvicted.Inc()
}
}

c.currentSize += sizeDelta
c.uncompressedSize += uncompressedSizeDelta

Expand Down
223 changes: 223 additions & 0 deletions cache/disk/lru_maxentries_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package disk

import (
"bytes"
"context"
"fmt"
"io"
"math"
"os"
"testing"

"github.com/buchgr/bazel-remote/v2/cache"
testutils "github.com/buchgr/bazel-remote/v2/utils"
)

// Tests for the entry-count bound (SizedLRU.maxEntries / WithMaxEntries),
// which caps resident index metadata independently of the byte budget.

func TestMaxEntriesEvictsFromTail(t *testing.T) {
var evicted []string
onEvict := func(key string, value lruItem) {
evicted = append(evicted, key)
}

lru := NewSizedLRU(math.MaxInt64, onEvict, 0)
lru.maxEntries = 2

for i := 0; i < 4; i++ {
if !lru.Add(fmt.Sprintf("key-%d", i), lruItem{size: 1, sizeOnDisk: 1}) {
t.Fatalf("Add of key-%d rejected", i)
}
}

if lru.Len() != 2 {
t.Fatalf("expected 2 entries, got %d", lru.Len())
}
for _, key := range []string{"key-2", "key-3"} {
if _, ok := lru.Peek(key); !ok {
t.Fatalf("expected %s to be resident", key)
}
}

// Evicted entries reach onEvict through the queued-eviction channel,
// which has no background consumer in LRU-only tests; drain it once
// (all queued entries merge into a single slice).
lru.performQueuedEvictions()
if len(evicted) != 2 || evicted[0] != "key-0" || evicted[1] != "key-1" {
t.Fatalf("expected [key-0 key-1] evicted in LRU order, got %v", evicted)
}

// The byte accounting must reflect the evictions: 2 resident single-
// block entries.
if lru.TotalSize() != 2*BlockSize {
t.Fatalf("expected currentSize %d, got %d", 2*BlockSize, lru.TotalSize())
}
}

func TestMaxEntriesBoundsZeroByteEntries(t *testing.T) {
// Zero-byte entries charge nothing against the byte budget, so only
// the entry-count bound limits them.
lru := NewSizedLRU(math.MaxInt64, func(string, lruItem) {}, 0)
lru.maxEntries = 10

for i := 0; i < 100; i++ {
if !lru.Add(fmt.Sprintf("zero-%d", i), lruItem{}) {
t.Fatalf("Add of zero-%d rejected", i)
}
}

if lru.Len() != 10 {
t.Fatalf("expected 10 entries, got %d", lru.Len())
}
if lru.TotalSize() != 0 {
t.Fatalf("expected zero currentSize, got %d", lru.TotalSize())
}
}

func TestMaxEntriesOverwriteDoesNotEvict(t *testing.T) {
var evicted []string
onEvict := func(key string, value lruItem) {
evicted = append(evicted, key)
}

lru := NewSizedLRU(math.MaxInt64, onEvict, 0)
lru.maxEntries = 2

if !lru.Add("a", lruItem{size: 1, sizeOnDisk: 1}) {
t.Fatal("Add of a rejected")
}
if !lru.Add("b", lruItem{size: 1, sizeOnDisk: 1}) {
t.Fatal("Add of b rejected")
}

// Overwriting a resident key at the cap must not change the count or
// evict the other entry. (The overwrite itself queues the replaced
// file for removal, which is not a count eviction but does reach
// onEvict.)
if !lru.Add("a", lruItem{size: 2, sizeOnDisk: 2}) {
t.Fatal("overwrite of a rejected")
}

if lru.Len() != 2 {
t.Fatalf("expected 2 entries, got %d", lru.Len())
}
if _, ok := lru.Peek("b"); !ok {
t.Fatal("expected b to remain resident after overwrite of a")
}

lru.performQueuedEvictions()
if len(evicted) != 1 || evicted[0] != "a" {
t.Fatalf("expected only the replaced version of a in the eviction queue, got %v", evicted)
}
}

func TestMaxEntriesDisabledByDefault(t *testing.T) {
lru := NewSizedLRU(math.MaxInt64, func(string, lruItem) {}, 0)

for i := 0; i < 1000; i++ {
if !lru.Add(fmt.Sprintf("zero-%d", i), lruItem{}) {
t.Fatalf("Add of zero-%d rejected", i)
}
}

if lru.Len() != 1000 {
t.Fatalf("expected 1000 entries with no count bound, got %d", lru.Len())
}
}

func TestDiskCacheWithMaxEntries(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

cacheDir := tempDir(t)
defer func() { _ = os.RemoveAll(cacheDir) }()

const maxEntries = 4
const numBlobs = 10
const itemSize = int64(64)

testCacheI, err := New(cacheDir, math.MaxInt64,
WithAccessLogger(testutils.NewSilentLogger()),
WithMaxEntries(maxEntries))
if err != nil {
t.Fatal(err)
}
testCache := testCacheI.(*diskCache)

var hashes []string
for i := 0; i < numBlobs; i++ {
data, hash := testutils.RandomDataAndHash(itemSize)
hashes = append(hashes, hash)
err = testCache.Put(ctx, cache.CAS, hash, itemSize,
io.NopCloser(bytes.NewReader(data)))
if err != nil {
t.Fatal(err)
}
}

if testCache.lru.Len() != maxEntries {
t.Fatalf("expected %d resident entries, got %d",
maxEntries, testCache.lru.Len())
}

// The most recently put blobs are resident, the oldest are not.
for _, hash := range hashes[numBlobs-maxEntries:] {
if ok, _ := testCache.Contains(ctx, cache.CAS, hash, itemSize); !ok {
t.Fatalf("expected recent blob %s to be resident", hash)
}
}
for _, hash := range hashes[:numBlobs-maxEntries] {
if ok, _ := testCache.Contains(ctx, cache.CAS, hash, itemSize); ok {
t.Fatalf("expected old blob %s to have been evicted", hash)
}
}
}

func TestDiskCacheMaxEntriesTrimsOnLoad(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

cacheDir := tempDir(t)
defer func() { _ = os.RemoveAll(cacheDir) }()

const maxEntries = 3
const numBlobs = 8
const itemSize = int64(64)

// Fill a cache directory with no entry-count bound.
unboundedI, err := New(cacheDir, math.MaxInt64,
WithAccessLogger(testutils.NewSilentLogger()))
if err != nil {
t.Fatal(err)
}
unbounded := unboundedI.(*diskCache)
for i := 0; i < numBlobs; i++ {
data, hash := testutils.RandomDataAndHash(itemSize)
err = unbounded.Put(ctx, cache.CAS, hash, itemSize,
io.NopCloser(bytes.NewReader(data)))
if err != nil {
t.Fatal(err)
}
}
if unbounded.lru.Len() != numBlobs {
t.Fatalf("expected %d entries before reload, got %d",
numBlobs, unbounded.lru.Len())
}

// Reloading the same directory with a bound trims it during load,
// like a restart with a smaller maxSize. New only returns after the
// eviction backlog has been removed from disk.
boundedI, err := New(cacheDir, math.MaxInt64,
WithAccessLogger(testutils.NewSilentLogger()),
WithMaxEntries(maxEntries))
if err != nil {
t.Fatal(err)
}
bounded := boundedI.(*diskCache)

if bounded.lru.Len() != maxEntries {
t.Fatalf("expected %d entries after bounded reload, got %d",
maxEntries, bounded.lru.Len())
}
}
Loading
Loading