From b2b7b7d6401b6947f939de4b12e645619e51afb0 Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Tue, 25 Nov 2025 22:53:02 +0900 Subject: [PATCH 1/2] Refactor cache API: remove value handling --- adaptsize/adaptsize.go | 60 +++++++++++++++++++++++++------------ adaptsize/adaptsize_test.go | 40 ++++++++++++++++++------- go.mod | 2 +- 3 files changed, 72 insertions(+), 30 deletions(-) diff --git a/adaptsize/adaptsize.go b/adaptsize/adaptsize.go index 2b87b61..075b935 100644 --- a/adaptsize/adaptsize.go +++ b/adaptsize/adaptsize.go @@ -46,7 +46,6 @@ type Cache struct { type entry struct { key string - val []byte size int64 node *list.Element } @@ -117,71 +116,94 @@ func (c *Cache) UsedBytes() int64 { c.mu.RLock(); defer c.mu.RUnlock(); return c // ParameterC returns current c. func (c *Cache) ParameterC() float64 { return math.Float64frombits(c.cBits.Load()) } -// Get returns value and ok. Touches LRU and records stats. -func (c *Cache) Get(key string) ([]byte, bool) { +// Get records a get request for metrics. Returns true if the key is currently tracked. +func (c *Cache) Get(key string) bool { c.mu.Lock() defer c.mu.Unlock() if e, ok := c.items[key]; ok { c.lru.MoveToFront(e.node) c.record(key, e.size) - return e.val, true + return true } c.record(key, 0) - return nil, false + return false } -// Set inserts or updates value. Admission is probabilistic. -func (c *Cache) Set(key string, value []byte) error { - size := int64(len(value)) +// Store records a store request with key and size. Admission is probabilistic. +// Returns true if admitted and a list of keys that should be evicted. +func (c *Cache) Store(key string, size int64) (bool, []string) { if size > c.opts.CapacityBytes { - return nil // never admit larger than capacity + c.record(key, size) + return false, nil // never admit larger than capacity } // admission using atomic c cVal := math.Float64frombits(c.cBits.Load()) admit := c.opts.Rand.Float64() < math.Exp(-float64(size)/cVal) if !admit { c.record(key, size) - return nil + return false, nil } c.mu.Lock() defer c.mu.Unlock() + var evicted []string if e, ok := c.items[key]; ok { c.used += size - e.size - e.val = value e.size = size c.lru.MoveToFront(e.node) } else { for c.used+size > c.opts.CapacityBytes { - c.evictOne() + evictedKey := c.evictOne() + if evictedKey != "" { + evicted = append(evicted, evictedKey) + } } n := c.lru.PushFront(&entry{key: key}) - e := &entry{key: key, val: value, size: size, node: n} + e := &entry{key: key, size: size, node: n} n.Value = e c.items[key] = e c.used += size } // stats c.record(key, size) - return nil + return true, evicted +} + +// EvictKeys returns a list of keys that should be evicted based on LRU order. +// This removes all tracked keys. The caller should remove these keys from their actual storage. +func (c *Cache) EvictKeys() []string { + c.mu.Lock() + defer c.mu.Unlock() + + var keys []string + for c.lru.Len() > 0 { + key := c.evictOne() + if key != "" { + keys = append(keys, key) + } + } + return keys } -func (c *Cache) evictOne() { +func (c *Cache) evictOne() string { if c.lru.Len() == 0 { - return + return "" } b := c.lru.Back() if b == nil { - return + return "" } e, ok := b.Value.(*entry) if !ok { - return + c.lru.Remove(b) + return "" } - delete(c.items, e.key) + key := e.key + delete(c.items, key) c.used -= e.size c.lru.Remove(b) + return key } func (c *Cache) record(key string, size int64) { diff --git a/adaptsize/adaptsize_test.go b/adaptsize/adaptsize_test.go index 271c16e..2e9ed7f 100644 --- a/adaptsize/adaptsize_test.go +++ b/adaptsize/adaptsize_test.go @@ -29,14 +29,20 @@ func TestAdmissionMonotonic(t *testing.T) { admittedSmall := 0 admittedLarge := 0 for i := 0; i < N; i++ { - _ = c.Set(randKey("s", i), make([]byte, 1<<10)) // 1 KiB - if _, ok := c.Get(randKey("s", i)); ok { + admitted, _ := c.Store(randKey("s", i), 1<<10) // 1 KiB + if admitted { admittedSmall++ } - _ = c.Set(randKey("L", i), make([]byte, 4<<20)) // 4 MiB - if _, ok := c.Get(randKey("L", i)); ok { + if c.Get(randKey("s", i)) { + // track metrics + } + admitted, _ = c.Store(randKey("L", i), 4<<20) // 4 MiB + if admitted { admittedLarge++ } + if c.Get(randKey("L", i)) { + // track metrics + } } ps := float64(admittedSmall) / float64(N) pl := float64(admittedLarge) / float64(N) @@ -49,14 +55,28 @@ func TestLRUEviction(t *testing.T) { c := newDeterministic(1024) // 1 KiB defer c.Close() c.cBits.Store(math.Float64bits(1 << 30)) // admit almost always - _ = c.Set("a", make([]byte, 800)) - _ = c.Set("b", make([]byte, 400)) // should evict a - if _, ok := c.Get("a"); ok { + _, evicted1 := c.Store("a", 800) + if len(evicted1) > 0 { + t.Fatalf("unexpected evictions: %v", evicted1) + } + _, evicted2 := c.Store("b", 400) // should evict a + if c.Get("a") { t.Fatal("expected a evicted") } - if _, ok := c.Get("b"); !ok { + if !c.Get("b") { t.Fatal("expected b present") } + // Check that "a" was in the evicted list + found := false + for _, key := range evicted2 { + if key == "a" { + found = true + break + } + } + if !found { + t.Fatal("expected 'a' to be in evicted list") + } } func TestBackgroundTuningMovesC(t *testing.T) { @@ -77,11 +97,11 @@ func TestBackgroundTuningMovesC(t *testing.T) { for i := 0; i < 30_000; i++ { // small hot keys cycle k := randKey("hot", i%128) - _ = c.Set(k, make([]byte, 512)) + _, _ = c.Store(k, 512) c.Get(k) // occasional large misses if i%50 == 0 { - _ = c.Set(randKey("cold", i), make([]byte, 256<<10)) + _, _ = c.Store(randKey("cold", i), 256<<10) } } // give time for tuner to run diff --git a/go.mod b/go.mod index 0cf2c6b..7342fc5 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module example.com/adaptsize -go 1.22 +go 1.25.3 From 9a4f34b058fefd264bfd15beadb6a41c1d27f795 Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Wed, 10 Dec 2025 00:06:50 +0900 Subject: [PATCH 2/2] Refactor cache logic to use Request struct --- README.md | 8 ++- adaptsize/adaptsize.go | 134 ++++++++---------------------------- adaptsize/adaptsize_test.go | 118 +++++++++++++++++++++---------- 3 files changed, 115 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index 5349b70..57f8b1f 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,12 @@ cache := adaptsize.New(adaptsize.Options{ }) defer cache.Close() -_ = cache.Set("k1", []byte("v")) -if v, ok := cache.Get("k1"); ok { _ = v } +// For every request, record it and decide admission on misses. +req := adaptsize.Request{Key: "k1", SizeBytes: 1234, Hit: false} // hit comes from your cache +admit := cache.Request(req) +if !req.Hit && admit { + // insert into your cache implementation +} c := cache.ParameterC() _ = c diff --git a/adaptsize/adaptsize.go b/adaptsize/adaptsize.go index 075b935..668231c 100644 --- a/adaptsize/adaptsize.go +++ b/adaptsize/adaptsize.go @@ -1,7 +1,6 @@ package adaptsize import ( - "container/list" crand "crypto/rand" "encoding/binary" "math" @@ -25,11 +24,6 @@ type Options struct { type Cache struct { opts Options - mu sync.RWMutex - used int64 - items map[string]*entry - lru *list.List - // parameter c stored atomically cBits atomic.Uint64 @@ -44,17 +38,19 @@ type Cache struct { stopCh chan struct{} } -type entry struct { - key string - size int64 - node *list.Element -} - type obs struct { size int64 cnt int64 } +// Request holds metrics about a cache access. The caller is responsible for +// determining whether it was a hit in their underlying cache. +type Request struct { + Key string + SizeBytes int64 + Hit bool +} + func defaultRandom() *rand.PCG { var s1, s2 uint64 if err := binary.Read(crand.Reader, binary.LittleEndian, &s1); err != nil { @@ -92,8 +88,6 @@ func New(opts Options) *Cache { c := &Cache{ opts: opts, - items: make(map[string]*entry), - lru: list.New(), obs: make(map[string]*obs), prevR: make(map[string]float64), tuneCh: make(chan struct{}, 1), @@ -105,105 +99,29 @@ func New(opts Options) *Cache { } // Close stops background tuning. -func (c *Cache) Close() { close(c.stopCh) } - -// Len returns number of cached entries. -func (c *Cache) Len() int { c.mu.RLock(); defer c.mu.RUnlock(); return len(c.items) } - -// UsedBytes returns used capacity. -func (c *Cache) UsedBytes() int64 { c.mu.RLock(); defer c.mu.RUnlock(); return c.used } +func (c *Cache) Close() { + close(c.stopCh) +} // ParameterC returns current c. -func (c *Cache) ParameterC() float64 { return math.Float64frombits(c.cBits.Load()) } - -// Get records a get request for metrics. Returns true if the key is currently tracked. -func (c *Cache) Get(key string) bool { - c.mu.Lock() - defer c.mu.Unlock() - if e, ok := c.items[key]; ok { - c.lru.MoveToFront(e.node) - c.record(key, e.size) - return true - } - c.record(key, 0) - return false +func (c *Cache) ParameterC() float64 { + return math.Float64frombits(c.cBits.Load()) } -// Store records a store request with key and size. Admission is probabilistic. -// Returns true if admitted and a list of keys that should be evicted. -func (c *Cache) Store(key string, size int64) (bool, []string) { - if size > c.opts.CapacityBytes { - c.record(key, size) - return false, nil // never admit larger than capacity +// Request records a cache request and returns whether a miss should be +// admitted. On misses (Hit=false), the return value is the probabilistic +// admission decision based on exp(-size/c). +func (c *Cache) Request(req Request) bool { + c.record(req.Key, req.SizeBytes) + if req.Hit { + return false + } + if req.SizeBytes > c.opts.CapacityBytes { + return false // never admit larger than capacity } // admission using atomic c cVal := math.Float64frombits(c.cBits.Load()) - admit := c.opts.Rand.Float64() < math.Exp(-float64(size)/cVal) - if !admit { - c.record(key, size) - return false, nil - } - - c.mu.Lock() - defer c.mu.Unlock() - - var evicted []string - if e, ok := c.items[key]; ok { - c.used += size - e.size - e.size = size - c.lru.MoveToFront(e.node) - } else { - for c.used+size > c.opts.CapacityBytes { - evictedKey := c.evictOne() - if evictedKey != "" { - evicted = append(evicted, evictedKey) - } - } - n := c.lru.PushFront(&entry{key: key}) - e := &entry{key: key, size: size, node: n} - n.Value = e - c.items[key] = e - c.used += size - } - // stats - c.record(key, size) - return true, evicted -} - -// EvictKeys returns a list of keys that should be evicted based on LRU order. -// This removes all tracked keys. The caller should remove these keys from their actual storage. -func (c *Cache) EvictKeys() []string { - c.mu.Lock() - defer c.mu.Unlock() - - var keys []string - for c.lru.Len() > 0 { - key := c.evictOne() - if key != "" { - keys = append(keys, key) - } - } - return keys -} - -func (c *Cache) evictOne() string { - if c.lru.Len() == 0 { - return "" - } - b := c.lru.Back() - if b == nil { - return "" - } - e, ok := b.Value.(*entry) - if !ok { - c.lru.Remove(b) - return "" - } - key := e.key - delete(c.items, key) - c.used -= e.size - c.lru.Remove(b) - return key + return c.opts.Rand.Float64() < math.Exp(-float64(req.SizeBytes)/cVal) } func (c *Cache) record(key string, size int64) { @@ -229,4 +147,6 @@ func (c *Cache) record(key string, size int64) { } } -func (c *Cache) setC(v int64) { c.cBits.Store(math.Float64bits(float64(v))) } +func (c *Cache) setC(v int64) { + c.cBits.Store(math.Float64bits(float64(v))) +} diff --git a/adaptsize/adaptsize_test.go b/adaptsize/adaptsize_test.go index 2e9ed7f..8b99cf1 100644 --- a/adaptsize/adaptsize_test.go +++ b/adaptsize/adaptsize_test.go @@ -29,20 +29,12 @@ func TestAdmissionMonotonic(t *testing.T) { admittedSmall := 0 admittedLarge := 0 for i := 0; i < N; i++ { - admitted, _ := c.Store(randKey("s", i), 1<<10) // 1 KiB - if admitted { + if c.Request(Request{Key: randKey("s", i), SizeBytes: 1 << 10, Hit: false}) { // miss admittedSmall++ } - if c.Get(randKey("s", i)) { - // track metrics - } - admitted, _ = c.Store(randKey("L", i), 4<<20) // 4 MiB - if admitted { + if c.Request(Request{Key: randKey("L", i), SizeBytes: 4 << 20, Hit: false}) { // miss admittedLarge++ } - if c.Get(randKey("L", i)) { - // track metrics - } } ps := float64(admittedSmall) / float64(N) pl := float64(admittedLarge) / float64(N) @@ -51,31 +43,26 @@ func TestAdmissionMonotonic(t *testing.T) { } } -func TestLRUEviction(t *testing.T) { - c := newDeterministic(1024) // 1 KiB +func TestRequestHitRecordsMetrics(t *testing.T) { + c := newDeterministic(1 << 20) defer c.Close() - c.cBits.Store(math.Float64bits(1 << 30)) // admit almost always - _, evicted1 := c.Store("a", 800) - if len(evicted1) > 0 { - t.Fatalf("unexpected evictions: %v", evicted1) - } - _, evicted2 := c.Store("b", 400) // should evict a - if c.Get("a") { - t.Fatal("expected a evicted") - } - if !c.Get("b") { - t.Fatal("expected b present") - } - // Check that "a" was in the evicted list - found := false - for _, key := range evicted2 { - if key == "a" { - found = true - break - } + if c.Request(Request{Key: "a", SizeBytes: 800, Hit: true}) { // hit should not request admission + t.Fatal("expected hit to return false for admission") + } + c.winMu.Lock() + obs := c.obs["a"] + c.winMu.Unlock() + if obs == nil || obs.cnt != 1 || obs.size != 800 { + t.Fatalf("hit metrics not recorded: %+v", obs) } - if !found { - t.Fatal("expected 'a' to be in evicted list") + if c.Request(Request{Key: "a", SizeBytes: 1200, Hit: true}) { + t.Fatal("expected hit to return false for admission") + } + c.winMu.Lock() + obs = c.obs["a"] + c.winMu.Unlock() + if obs.cnt != 2 || obs.size != 1200 { + t.Fatalf("hit metrics not updated: %+v", obs) } } @@ -97,11 +84,11 @@ func TestBackgroundTuningMovesC(t *testing.T) { for i := 0; i < 30_000; i++ { // small hot keys cycle k := randKey("hot", i%128) - _, _ = c.Store(k, 512) - c.Get(k) + _ = c.Request(Request{Key: k, SizeBytes: 512, Hit: false}) // miss then admit decision ignored here + _ = c.Request(Request{Key: k, SizeBytes: 512, Hit: true}) // subsequent hit to record hotness // occasional large misses if i%50 == 0 { - _, _ = c.Store(randKey("cold", i), 256<<10) + _ = c.Request(Request{Key: randKey("cold", i), SizeBytes: 256 << 10, Hit: false}) } } // give time for tuner to run @@ -115,6 +102,65 @@ func TestBackgroundTuningMovesC(t *testing.T) { } } +func TestRequestOversize(t *testing.T) { + c := newDeterministic(1024) + defer c.Close() + admit := c.Request(Request{Key: "big", SizeBytes: 2048, Hit: false}) + if admit { + t.Fatal("expected oversize object not to be admitted") + } + c.winMu.Lock() + obs := c.obs["big"] + c.winMu.Unlock() + if obs == nil || obs.cnt != 1 || obs.size != 2048 { + t.Fatalf("oversize request not recorded: %+v", obs) + } +} + +func TestBuildRatesEMA(t *testing.T) { + c := newDeterministic(1 << 20) + prevAOld := 10.0 + c.prevR["a"] = prevAOld + snap := map[string]obs{ + "a": {size: 100, cnt: 2}, + "b": {size: 200, cnt: 3}, + "z": {size: 0, cnt: 5}, // should be ignored due to size 0 + } + items, total := c.buildRates(snap) + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if total <= 0 { + t.Fatalf("expected positive total, got %f", total) + } + expA := 0.5*float64(snap["a"].cnt) + 0.5*prevAOld + expB := 0.5 * float64(snap["b"].cnt) + if c.prevR["a"] != expA || c.prevR["b"] != expB { + t.Fatalf("unexpected EMA values: prevR=%v", c.prevR) + } + rate := func(size int64) float64 { + for _, it := range items { + if it.s == size { + return it.r + } + } + return -1 + } + if rate(100) != expA || rate(200) != expB { + t.Fatalf("unexpected rates: %+v", items) + } +} + +func TestTuneOnceNoDataKeepsC(t *testing.T) { + c := newDeterministic(1 << 20) + defer c.Close() + c0 := c.ParameterC() + c.TuneOnce() + if c.ParameterC() != c0 { + t.Fatalf("expected c unchanged without data, got %f -> %f", c0, c.ParameterC()) + } +} + func randKey(prefix string, i int) string { return prefix + "-" + strconvI(i) } func strconvI(i int) string {