fix(cache): make cache truly LRU and resize correctly

Get() never promoted accessed entries, so eviction was insertion
order (FIFO), not LRU. Get() now MoveToBacks the element; cache
mu becomes sync.Mutex.

Resize() updated capacity but never evicted to fit the new size,
and incorrectly reset the evictions counter. It now evicts from
the front until len(entries) <= capacity and preserves the counter.

Also cleaned up the Set() eviction loop and added tests for LRU
promotion, immediate-shrink resize, and concurrent access.
This commit is contained in:
ns 2026-05-07 07:07:08 +00:00
commit 29746d5c99
No known key found for this signature in database
GPG key ID: 69784C31D818C1A1
2 changed files with 103 additions and 15 deletions

View file

@ -10,7 +10,7 @@ import (
type Cache struct {
capacity int
mu sync.RWMutex
mu sync.Mutex
entries map[uint64]*list.Element
values *list.List
evictions uint64
@ -49,12 +49,13 @@ func (c *Cache) Set(ip netip.Addr, resp Response) {
minEvictions := len(c.entries) - c.capacity + 1
if minEvictions > 0 { // At or above capacity. Shrink the cache
evicted := 0
for el := c.values.Front(); el != nil && evicted < minEvictions; {
value := el.Value.(Response)
delete(c.entries, key(value.IP))
next := el.Next()
for evicted < minEvictions {
el := c.values.Front()
if el == nil {
break
}
delete(c.entries, key(el.Value.(Response).IP))
c.values.Remove(el)
el = next
evicted++
}
c.evictions += uint64(evicted)
@ -68,29 +69,38 @@ func (c *Cache) Set(ip netip.Addr, resp Response) {
func (c *Cache) Get(ip netip.Addr) (Response, bool) {
k := key(ip)
c.mu.RLock()
defer c.mu.RUnlock()
r, ok := c.entries[k]
c.mu.Lock()
defer c.mu.Unlock()
el, ok := c.entries[k]
if !ok {
return Response{}, false
}
return r.Value.(Response), true
c.values.MoveToBack(el)
return el.Value.(Response), true
}
func (c *Cache) Resize(capacity int) error {
if capacity < 0 {
return fmt.Errorf("invalid capacity: %d\n", capacity)
return fmt.Errorf("invalid capacity: %d", capacity)
}
c.mu.Lock()
defer c.mu.Unlock()
c.capacity = capacity
c.evictions = 0
for len(c.entries) > c.capacity {
el := c.values.Front()
if el == nil {
break
}
delete(c.entries, key(el.Value.(Response).IP))
c.values.Remove(el)
c.evictions++
}
return nil
}
func (c *Cache) Stats() CacheStats {
c.mu.RLock()
defer c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
return CacheStats{
Size: len(c.entries),
Capacity: c.capacity,

View file

@ -2,7 +2,9 @@ package http
import (
"fmt"
"math/rand"
"net/netip"
"sync"
"testing"
)
@ -76,7 +78,8 @@ func TestCacheResize(t *testing.T) {
if err := c.Resize(5); err != nil {
t.Fatal(err)
}
if got, want := c.evictions, uint64(0); got != want {
// Resize should evict to fit and accumulate (not reset) the evictions counter.
if got, want := c.evictions, uint64(15); got != want {
t.Errorf("want %d evictions, got %d", want, got)
}
ip, _ := netip.ParseAddr("192.0.2.42")
@ -86,3 +89,78 @@ func TestCacheResize(t *testing.T) {
t.Errorf("want %d entries, got %d", want, got)
}
}
func TestCacheLRUPromotion(t *testing.T) {
c := NewCache(2)
ip1, _ := netip.ParseAddr("192.0.2.1")
ip2, _ := netip.ParseAddr("192.0.2.2")
ip3, _ := netip.ParseAddr("192.0.2.3")
c.Set(ip1, Response{IP: ip1})
c.Set(ip2, Response{IP: ip2})
// Access ip1 — should promote it to most-recently-used.
if _, ok := c.Get(ip1); !ok {
t.Fatalf("expected Get(ip1) to hit")
}
// Inserting ip3 should evict the least-recently-used: ip2.
c.Set(ip3, Response{IP: ip3})
if _, ok := c.Get(ip1); !ok {
t.Errorf("ip1 was evicted; expected ip1 to remain after promotion")
}
if _, ok := c.Get(ip2); ok {
t.Errorf("ip2 still present; expected ip2 to be evicted as LRU")
}
if _, ok := c.Get(ip3); !ok {
t.Errorf("ip3 missing; expected ip3 to be present")
}
}
func TestCacheResizeShrink(t *testing.T) {
c := NewCache(10)
for i := 0; i < 10; i++ {
ip, _ := netip.ParseAddr(fmt.Sprintf("192.0.2.%d", i))
c.Set(ip, Response{IP: ip})
}
if got, want := len(c.entries), 10; got != want {
t.Fatalf("pre-resize len(entries) = %d, want %d", got, want)
}
preEvictions := c.evictions
if err := c.Resize(5); err != nil {
t.Fatal(err)
}
// Shrink must take effect immediately, not on next Set.
if got, want := len(c.entries), 5; got != want {
t.Errorf("post-resize len(entries) = %d, want %d", got, want)
}
if got, want := c.values.Len(), 5; got != want {
t.Errorf("post-resize values.Len = %d, want %d", got, want)
}
// Evictions counter must increment by 5, not reset.
if got, want := c.evictions, preEvictions+5; got != want {
t.Errorf("post-resize evictions = %d, want %d", got, want)
}
}
func TestCacheConcurrent(t *testing.T) {
c := NewCache(100)
var wg sync.WaitGroup
const goroutines = 1000
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func(seed int64) {
defer wg.Done()
r := rand.New(rand.NewSource(seed))
for j := 0; j < 50; j++ {
ip, _ := netip.ParseAddr(fmt.Sprintf("192.0.2.%d", r.Intn(256)))
if r.Intn(2) == 0 {
c.Set(ip, Response{IP: ip})
} else {
c.Get(ip)
}
}
}(int64(i))
}
wg.Wait()
if got := len(c.entries); got > 100 {
t.Errorf("len(entries) = %d, exceeds capacity 100", got)
}
}