From cc653735ec4be2b614c5347fa2f7af4aec7f982b Mon Sep 17 00:00:00 2001 From: Francesco Cosentino Date: Thu, 21 Aug 2025 13:02:28 +0300 Subject: [PATCH 1/5] tests(eviction/lfu): add tie-break tests to assert LRU-on-ties behavior (insert order and access order) --- pkg/eviction/lfu_test.go | 61 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 pkg/eviction/lfu_test.go diff --git a/pkg/eviction/lfu_test.go b/pkg/eviction/lfu_test.go new file mode 100644 index 0000000..6983d09 --- /dev/null +++ b/pkg/eviction/lfu_test.go @@ -0,0 +1,61 @@ +package eviction + +import "testing" + +// Test that when two items have equal frequency, the older (least-recent) is evicted first. +func TestLFU_EvictsOldestOnTie_InsertOrder(t *testing.T) { + lfu, err := NewLFUAlgorithm(2) + if err != nil { + t.Fatalf("NewLFUAlgorithm error: %v", err) + } + + lfu.Set("a", 1) + lfu.Set("b", 2) + + // Both have count=1, but "a" is older (inserted first), so it should be evicted first. + key, ok := lfu.Evict() + if !ok { + t.Fatalf("expected an eviction, got none") + } + if key != "a" { + t.Fatalf("expected 'a' to be evicted first on tie, got %q", key) + } + + // Next eviction should evict the remaining one + key, ok = lfu.Evict() + if !ok { + t.Fatalf("expected a second eviction, got none") + } + if key != "b" { + t.Fatalf("expected 'b' to be evicted second, got %q", key) + } +} + +// Test that recency is used to break ties after accesses with equalized frequency. +func TestLFU_EvictsOldestOnTie_AccessOrder(t *testing.T) { + lfu, err := NewLFUAlgorithm(2) + if err != nil { + t.Fatalf("NewLFUAlgorithm error: %v", err) + } + + lfu.Set("a", 1) + lfu.Set("b", 2) + + // Make both have the same frequency (2) but different recency orders. + // Access sequence: bump "b" first, then "a" -> both count=2, and "a" is more recent. + if _, ok := lfu.Get("b"); !ok { + t.Fatalf("expected to get 'b'") + } + if _, ok := lfu.Get("a"); !ok { + t.Fatalf("expected to get 'a'") + } + + // On tie (count=2), the older (least-recent) is "b" now, so it should be evicted first. + key, ok := lfu.Evict() + if !ok { + t.Fatalf("expected an eviction, got none") + } + if key != "b" { + t.Fatalf("expected 'b' to be evicted first on tie after accesses, got %q", key) + } +} From b8ccccd06af12beda14dff84600edd6f36f461ae Mon Sep 17 00:00:00 2001 From: Francesco Cosentino Date: Thu, 21 Aug 2025 13:10:59 +0300 Subject: [PATCH 2/5] eviction: fix Clock and CAWOLFU Evict to return correct keys before pooling; tests: add LRU, Clock, CAWOLFU eviction tests --- pkg/eviction/cawolfu.go | 4 ++- pkg/eviction/cawolfu_test.go | 48 ++++++++++++++++++++++++++++++++++ pkg/eviction/clock.go | 6 +++-- pkg/eviction/clock_test.go | 43 +++++++++++++++++++++++++++++++ pkg/eviction/lru_test.go | 50 ++++++++++++++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 pkg/eviction/cawolfu_test.go create mode 100644 pkg/eviction/clock_test.go create mode 100644 pkg/eviction/lru_test.go diff --git a/pkg/eviction/cawolfu.go b/pkg/eviction/cawolfu.go index 1b43fcf..60ea728 100644 --- a/pkg/eviction/cawolfu.go +++ b/pkg/eviction/cawolfu.go @@ -67,10 +67,12 @@ func (c *CAWOLFU) Evict() (string, bool) { if err == nil { c.length-- + // Preserve key before resetting the node for pool reuse + evictedKey := node.key resetCAWOLFUNode(node) c.nodePool.Put(node) - return node.key, true + return evictedKey, true } // If map/list out of sync, forcibly clean up resetCAWOLFUNode(node) diff --git a/pkg/eviction/cawolfu_test.go b/pkg/eviction/cawolfu_test.go new file mode 100644 index 0000000..fd38903 --- /dev/null +++ b/pkg/eviction/cawolfu_test.go @@ -0,0 +1,48 @@ +package eviction + +import "testing" + +func TestCAWOLFU_EvictsLeastFrequentTail(t *testing.T) { + c, err := NewCAWOLFU(2) + if err != nil { + t.Fatalf("NewCAWOLFU error: %v", err) + } + + c.Set("a", 1) + c.Set("b", 2) + // bump 'a' so 'b' is less frequent + if _, ok := c.Get("a"); !ok { + t.Fatalf("expected to get 'a'") + } + + // Insert 'c' -> evict tail ('b') + c.Set("c", 3) + if _, ok := c.Get("b"); ok { + t.Fatalf("expected 'b' to be evicted") + } + if _, ok := c.Get("a"); !ok { + t.Fatalf("expected 'a' to remain in cache") + } + if v, ok := c.Get("c"); !ok || v.(int) != 3 { + t.Fatalf("expected 'c'=3 in cache, got %v, ok=%v", v, ok) + } +} + +func TestCAWOLFU_EvictMethodOrder(t *testing.T) { + c, err := NewCAWOLFU(2) + if err != nil { + t.Fatalf("NewCAWOLFU error: %v", err) + } + + c.Set("a", 1) + c.Set("b", 2) + // Without additional access, tail is 'a' (inserted first with same count) + key, ok := c.Evict() + if !ok || key != "a" { + t.Fatalf("expected to evict 'a' first, got %q ok=%v", key, ok) + } + key, ok = c.Evict() + if !ok || key != "b" { + t.Fatalf("expected to evict 'b' second, got %q ok=%v", key, ok) + } +} diff --git a/pkg/eviction/clock.go b/pkg/eviction/clock.go index 6d3ac98..a43dbac 100644 --- a/pkg/eviction/clock.go +++ b/pkg/eviction/clock.go @@ -57,11 +57,13 @@ func (c *ClockAlgorithm) Evict() (string, bool) { if item.AccessCount > 0 { item.AccessCount-- } else { - delete(c.keys, item.Key) + // Preserve key before zeroing the item back to the pool + evictedKey := item.Key + delete(c.keys, evictedKey) c.itemPoolManager.Put(item) c.items[c.hand] = nil - return item.Key, true + return evictedKey, true } c.hand = (c.hand + 1) % c.capacity diff --git a/pkg/eviction/clock_test.go b/pkg/eviction/clock_test.go new file mode 100644 index 0000000..5a2a486 --- /dev/null +++ b/pkg/eviction/clock_test.go @@ -0,0 +1,43 @@ +package eviction + +import "testing" + +func TestClock_EvictsWhenHandFindsColdPage(t *testing.T) { + clk, err := NewClockAlgorithm(2) + if err != nil { + t.Fatalf("NewClockAlgorithm error: %v", err) + } + + clk.Set("a", 1) + clk.Set("b", 2) + + // Touch 'a' once to increment its AccessCount; leave 'b' cold + if _, ok := clk.Get("a"); !ok { + t.Fatalf("expected to get 'a'") + } + + // Eviction may require multiple passes due to access count decrements. + // Loop until 'b' is evicted (within a few attempts). + var ( + key string + ok bool + ) + for i := 0; i < 3; i++ { + key, ok = clk.Evict() + if ok && key == "b" { + break + } + } + if !ok || key != "b" { + t.Fatalf("expected to evict 'b' first within retries, got %q ok=%v", key, ok) + } + + // Now evict the remaining 'a', possibly requiring one more pass. + key, ok = clk.Evict() + if !ok { + key, ok = clk.Evict() + } + if !ok || key != "a" { + t.Fatalf("expected to evict 'a' second, got %q ok=%v", key, ok) + } +} diff --git a/pkg/eviction/lru_test.go b/pkg/eviction/lru_test.go new file mode 100644 index 0000000..7960559 --- /dev/null +++ b/pkg/eviction/lru_test.go @@ -0,0 +1,50 @@ +package eviction + +import "testing" + +func TestLRU_EvictsLeastRecentlyUsedOnSet(t *testing.T) { + lru, err := NewLRUAlgorithm(2) + if err != nil { + t.Fatalf("NewLRUAlgorithm error: %v", err) + } + + lru.Set("a", 1) + lru.Set("b", 2) + + // Access "a" so that "b" becomes the least recently used + if _, ok := lru.Get("a"); !ok { + t.Fatalf("expected to get 'a'") + } + + // Insert "c"; should evict "b" + lru.Set("c", 3) + if _, ok := lru.Get("b"); ok { + t.Fatalf("expected 'b' to be evicted") + } + if _, ok := lru.Get("a"); !ok { + t.Fatalf("expected 'a' to remain in cache") + } + if v, ok := lru.Get("c"); !ok || v.(int) != 3 { + t.Fatalf("expected 'c'=3 in cache, got %v, ok=%v", v, ok) + } +} + +func TestLRU_EvictMethodOrder(t *testing.T) { + lru, err := NewLRUAlgorithm(2) + if err != nil { + t.Fatalf("NewLRUAlgorithm error: %v", err) + } + + lru.Set("a", 1) + lru.Set("b", 2) + + // After two inserts, tail should be "a" + key, ok := lru.Evict() + if !ok || key != "a" { + t.Fatalf("expected to evict 'a' first, got %q ok=%v", key, ok) + } + key, ok = lru.Evict() + if !ok || key != "b" { + t.Fatalf("expected to evict 'b' second, got %q ok=%v", key, ok) + } +} From a1d1a0fa2a625f7fcb95e1e16a2e18d22feee89f Mon Sep 17 00:00:00 2001 From: Francesco Cosentino Date: Thu, 21 Aug 2025 13:14:05 +0300 Subject: [PATCH 3/5] tests(eviction): add zero-capacity and delete-path tests for LRU, LFU, Clock, CAWOLFU; fix Evict key handling in Clock & CAWOLFU --- pkg/eviction/cawolfu_test.go | 36 +++++++++++++++++++++++++++++ pkg/eviction/clock_test.go | 45 ++++++++++++++++++++++++++++++++++++ pkg/eviction/lfu_test.go | 37 +++++++++++++++++++++++++++++ pkg/eviction/lru_test.go | 37 +++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+) diff --git a/pkg/eviction/cawolfu_test.go b/pkg/eviction/cawolfu_test.go index fd38903..f944398 100644 --- a/pkg/eviction/cawolfu_test.go +++ b/pkg/eviction/cawolfu_test.go @@ -46,3 +46,39 @@ func TestCAWOLFU_EvictMethodOrder(t *testing.T) { t.Fatalf("expected to evict 'b' second, got %q ok=%v", key, ok) } } + +func TestCAWOLFU_ZeroCapacity_NoOp(t *testing.T) { + c, err := NewCAWOLFU(0) + if err != nil { + t.Fatalf("NewCAWOLFU error: %v", err) + } + + c.Set("a", 1) + if _, ok := c.Get("a"); ok { + t.Fatalf("expected Get to miss on zero-capacity cache") + } + + if key, ok := c.Evict(); ok || key != "" { + t.Fatalf("expected no eviction on zero-capacity, got %q ok=%v", key, ok) + } +} + +func TestCAWOLFU_Delete_RemovesItem(t *testing.T) { + c, err := NewCAWOLFU(2) + if err != nil { + t.Fatalf("NewCAWOLFU error: %v", err) + } + + c.Set("a", 1) + c.Set("b", 2) + c.Delete("a") + + if _, ok := c.Get("a"); ok { + t.Fatalf("expected 'a' to be deleted") + } + + key, ok := c.Evict() + if !ok || key != "b" { + t.Fatalf("expected to evict 'b' as remaining item, got %q ok=%v", key, ok) + } +} diff --git a/pkg/eviction/clock_test.go b/pkg/eviction/clock_test.go index 5a2a486..6f98b9d 100644 --- a/pkg/eviction/clock_test.go +++ b/pkg/eviction/clock_test.go @@ -41,3 +41,48 @@ func TestClock_EvictsWhenHandFindsColdPage(t *testing.T) { t.Fatalf("expected to evict 'a' second, got %q ok=%v", key, ok) } } + +func TestClock_ZeroCapacity_NoOp(t *testing.T) { + clk, err := NewClockAlgorithm(0) + if err != nil { + t.Fatalf("NewClockAlgorithm error: %v", err) + } + + clk.Set("a", 1) + if _, ok := clk.Get("a"); ok { + t.Fatalf("expected Get to miss on zero-capacity cache") + } + + if key, ok := clk.Evict(); ok || key != "" { + t.Fatalf("expected no eviction on zero-capacity, got %q ok=%v", key, ok) + } +} + +func TestClock_Delete_RemovesItem(t *testing.T) { + clk, err := NewClockAlgorithm(2) + if err != nil { + t.Fatalf("NewClockAlgorithm error: %v", err) + } + + clk.Set("a", 1) + clk.Set("b", 2) + clk.Delete("a") + + if _, ok := clk.Get("a"); ok { + t.Fatalf("expected 'a' to be deleted") + } + + // Evict should not return deleted key + // Loop a couple of times due to potential decrements + for i := 0; i < 3; i++ { + if key, ok := clk.Evict(); ok { + if key == "a" { + t.Fatalf("did not expect to evict deleted key 'a'") + } + if key == "b" { + return + } + } + } + t.Fatalf("expected to eventually evict 'b'") +} diff --git a/pkg/eviction/lfu_test.go b/pkg/eviction/lfu_test.go index 6983d09..0c4eee7 100644 --- a/pkg/eviction/lfu_test.go +++ b/pkg/eviction/lfu_test.go @@ -59,3 +59,40 @@ func TestLFU_EvictsOldestOnTie_AccessOrder(t *testing.T) { t.Fatalf("expected 'b' to be evicted first on tie after accesses, got %q", key) } } + +func TestLFU_ZeroCapacity_NoOp(t *testing.T) { + lfu, err := NewLFUAlgorithm(0) + if err != nil { + t.Fatalf("NewLFUAlgorithm error: %v", err) + } + + lfu.Set("a", 1) + if _, ok := lfu.Get("a"); ok { + t.Fatalf("expected Get to miss on zero-capacity cache") + } + + if key, ok := lfu.Evict(); ok || key != "" { + t.Fatalf("expected no eviction on zero-capacity, got %q ok=%v", key, ok) + } +} + +func TestLFU_Delete_RemovesItem(t *testing.T) { + lfu, err := NewLFUAlgorithm(2) + if err != nil { + t.Fatalf("NewLFUAlgorithm error: %v", err) + } + + lfu.Set("a", 1) + lfu.Set("b", 2) + lfu.Delete("a") + + if _, ok := lfu.Get("a"); ok { + t.Fatalf("expected 'a' to be deleted") + } + + // Evict should not return deleted key + key, ok := lfu.Evict() + if !ok || key != "b" { + t.Fatalf("expected to evict 'b' as remaining item, got %q ok=%v", key, ok) + } +} diff --git a/pkg/eviction/lru_test.go b/pkg/eviction/lru_test.go index 7960559..826b18c 100644 --- a/pkg/eviction/lru_test.go +++ b/pkg/eviction/lru_test.go @@ -48,3 +48,40 @@ func TestLRU_EvictMethodOrder(t *testing.T) { t.Fatalf("expected to evict 'b' second, got %q ok=%v", key, ok) } } + +func TestLRU_ZeroCapacity_NoOp(t *testing.T) { + lru, err := NewLRUAlgorithm(0) + if err != nil { + t.Fatalf("NewLRUAlgorithm error: %v", err) + } + + lru.Set("a", 1) + if _, ok := lru.Get("a"); ok { + t.Fatalf("expected Get to miss on zero-capacity cache") + } + + if key, ok := lru.Evict(); ok || key != "" { + t.Fatalf("expected no eviction on zero-capacity, got %q ok=%v", key, ok) + } +} + +func TestLRU_Delete_RemovesItem(t *testing.T) { + lru, err := NewLRUAlgorithm(2) + if err != nil { + t.Fatalf("NewLRUAlgorithm error: %v", err) + } + + lru.Set("a", 1) + lru.Set("b", 2) + lru.Delete("a") + + if _, ok := lru.Get("a"); ok { + t.Fatalf("expected 'a' to be deleted") + } + + // Evict should not return deleted key + key, ok := lru.Evict() + if !ok || key != "b" { + t.Fatalf("expected to evict 'b' as remaining item, got %q ok=%v", key, ok) + } +} From 99966988a4d630377becea2ba5b9d71768ba9966 Mon Sep 17 00:00:00 2001 From: Francesco Cosentino Date: Thu, 21 Aug 2025 13:43:01 +0300 Subject: [PATCH 4/5] fix: address nlreturn lint in ARC helper --- pkg/eviction/arc.go | 485 ++++++++++++++++++++++++++++----------- pkg/eviction/arc_test.go | 71 ++++++ 2 files changed, 427 insertions(+), 129 deletions(-) create mode 100644 pkg/eviction/arc_test.go diff --git a/pkg/eviction/arc.go b/pkg/eviction/arc.go index 37a3bb7..182479b 100644 --- a/pkg/eviction/arc.go +++ b/pkg/eviction/arc.go @@ -1,205 +1,432 @@ -// Package eviction ARC is an in-memory cache that uses the Adaptive Replacement Cache (ARC) algorithm to manage its items. -// It has a map of items to store the items in the cache, and a capacity field that limits the number of items that can be stored in the cache. -// The ARC algorithm uses two lists, t1 and t2, to store the items in the cache. -// The p field represents the "promotion threshold", which determines how many items should be stored in t1. -// The c field represents the current number of items in the cache. +// Package eviction - Adaptive Replacement Cache (ARC) algorithm implementation. package eviction import ( "sync" "github.com/hyp3rd/hypercache/internal/sentinel" - "github.com/hyp3rd/hypercache/pkg/cache" ) -// ARC is an in-memory cache that uses the Adaptive Replacement Cache (ARC) algorithm to manage its items. +type arcNode struct { + key string + value any + prev *arcNode + next *arcNode +} + +type arcGhostNode struct { + key string + prev *arcGhostNode + next *arcGhostNode +} + +type arcList struct { + head *arcNode + tail *arcNode + len int +} + +func (l *arcList) pushFront(node *arcNode) { + node.prev = nil + + node.next = l.head + if l.head != nil { + l.head.prev = node + } + + l.head = node + if l.tail == nil { + l.tail = node + } + + l.len++ +} + +func (l *arcList) remove(node *arcNode) { + switch { + case l.head == l.tail: + l.head = nil + l.tail = nil + case node == l.head: + l.head = node.next + l.head.prev = nil + case node == l.tail: + l.tail = node.prev + l.tail.next = nil + default: + node.prev.next = node.next + node.next.prev = node.prev + } + + node.prev = nil + node.next = nil + l.len-- +} + +func (l *arcList) removeTail() *arcNode { + if l.tail == nil { + return nil + } + + t := l.tail + l.remove(t) + + return t +} + +type arcGhostList struct { + head *arcGhostNode + tail *arcGhostNode + len int +} + +func (l *arcGhostList) pushFront(node *arcGhostNode) { + node.prev = nil + + node.next = l.head + if l.head != nil { + l.head.prev = node + } + + l.head = node + if l.tail == nil { + l.tail = node + } + + l.len++ +} + +func (l *arcGhostList) remove(node *arcGhostNode) { + switch { + case l.head == l.tail: + l.head = nil + l.tail = nil + case node == l.head: + l.head = node.next + l.head.prev = nil + case node == l.tail: + l.tail = node.prev + l.tail.next = nil + default: + node.prev.next = node.next + node.next.prev = node.prev + } + + node.prev = nil + node.next = nil + l.len-- +} + +func (l *arcGhostList) removeTail() *arcGhostNode { + if l.tail == nil { + return nil + } + + t := l.tail + l.remove(t) + + return t +} + +// ARC implements the Adaptive Replacement Cache (resident T1/T2 and ghost B1/B2). type ARC struct { - itemPoolManager *cache.ItemPoolManager // itemPoolManager is used to manage the item pool for memory efficiency - capacity int // capacity is the maximum number of items that can be stored in the cache - t1 map[string]*cache.Item // t1 is a list of items that have been accessed recently - t2 map[string]*cache.Item // t2 is a list of items that have been accessed less recently - b1 map[string]bool // b1 is a list of items that have been evicted from t1 - b2 map[string]bool // b2 is a list of items that have been evicted from t2 - p int // p is the promotion threshold - c int // c is the current number of items in the cache - mutex sync.RWMutex // mutex is a read-write mutex that protects the cache -} - -// NewARCAlgorithm creates a new in-memory cache with the given capacity and the Adaptive Replacement Cache (ARC) algorithm. -// If the capacity is negative, it returns an error. + mutex sync.Mutex + capacity int + p int // target size for T1 + + // resident lists + t1 arcList + t2 arcList + + // ghost lists + b1 arcGhostList + b2 arcGhostList + + // indexes + t1Idx map[string]*arcNode + t2Idx map[string]*arcNode + b1Idx map[string]*arcGhostNode + b2Idx map[string]*arcGhostNode + + length int // |T1| + |T2| +} + +// NewARCAlgorithm creates a new ARC with capacity. func NewARCAlgorithm(capacity int) (*ARC, error) { if capacity < 0 { return nil, sentinel.ErrInvalidCapacity } return &ARC{ - itemPoolManager: cache.NewItemPoolManager(), - capacity: capacity, - t1: make(map[string]*cache.Item, capacity), - t2: make(map[string]*cache.Item, capacity), - b1: make(map[string]bool, capacity), - b2: make(map[string]bool, capacity), - p: 0, - c: 0, + capacity: capacity, + p: 0, + t1Idx: make(map[string]*arcNode, capacity), + t2Idx: make(map[string]*arcNode, capacity), + b1Idx: make(map[string]*arcGhostNode, capacity), + b2Idx: make(map[string]*arcGhostNode, capacity), }, nil } -// Get retrieves the item with the given key from the cache. -// If the key is not found in the cache, it returns nil. -func (arc *ARC) Get(key string) (any, bool) { - arc.mutex.Lock() - defer arc.mutex.Unlock() +// Get returns the value and updates ARC state. +func (a *ARC) Get(key string) (any, bool) { + a.mutex.Lock() + defer a.mutex.Unlock() - // Check t1 - item, ok := arc.t1[key] - if ok { - arc.promote(key) + if node, ok := a.t1Idx[key]; ok { + // move to T2 + a.t1.remove(node) + delete(a.t1Idx, key) + a.t2.pushFront(node) + a.t2Idx[key] = node - return item.Value, true + return node.value, true } - // Check t2 - item, ok = arc.t2[key] - if ok { - arc.demote(key) - return item.Value, true + if node, ok := a.t2Idx[key]; ok { + // refresh in T2 + a.t2.remove(node) + a.t2.pushFront(node) + + return node.value, true } return nil, false } -// Set adds a new item to the cache with the given key. -func (arc *ARC) Set(key string, value any) { - arc.mutex.Lock() - defer arc.mutex.Unlock() +// Set inserts or updates a key according to ARC rules. +func (a *ARC) Set(key string, value any) { + a.mutex.Lock() + defer a.mutex.Unlock() - if arc.capacity == 0 { - // Zero-capacity ARC is a no-op + if a.capacity == 0 { return } - // If key exists in t1 or t2, update value only - if item, ok := arc.t1[key]; ok { - item.Value = value + if a.updateIfResident(key, value) { + return + } + if a.handleGhostHit(key, value) { return } - if item, ok := arc.t2[key]; ok { - item.Value = value + a.insertNew(key, value) +} + +// Delete removes key from ARC (resident or ghost). +func (a *ARC) Delete(key string) { + a.mutex.Lock() + defer a.mutex.Unlock() + + if node, ok := a.t1Idx[key]; ok { + a.t1.remove(node) + delete(a.t1Idx, key) + a.length-- return } - // Check if cache is at capacity - if arc.c >= arc.capacity { - // Eviction needed - evictedKey, ok := arc.Evict() - if !ok { - return - } + if node, ok := a.t2Idx[key]; ok { + a.t2.remove(node) + delete(a.t2Idx, key) + a.length-- - arc.Delete(evictedKey) + return } - item := arc.itemPoolManager.Get() - item.Value = value - arc.t1[key] = item - arc.c++ + if ghost, ok := a.b1Idx[key]; ok { + a.b1.remove(ghost) + delete(a.b1Idx, key) - arc.p++ - if arc.p > arc.capacity { - arc.p = arc.capacity + return + } + + if ghost, ok := a.b2Idx[key]; ok { + a.b2.remove(ghost) + delete(a.b2Idx, key) + + return } } -// Delete removes the item with the given key from the cache. -func (arc *ARC) Delete(key string) { - // Check t1 - item, ok := arc.t1[key] - if ok { - delete(arc.t1, key) - arc.c-- +// Evict selects a victim according to ARC policy. +func (a *ARC) Evict() (string, bool) { + a.mutex.Lock() + defer a.mutex.Unlock() + + if a.capacity == 0 || a.length == 0 { + return "", false + } + + key := a.replace("") + if key == "" { + return "", false + } + + return key, true +} - arc.p-- - if arc.p < 0 { - arc.p = 0 +// replace evicts one resident from T1 or T2 and places its key into B1 or B2. +// Returns the evicted resident key (if any). +func (a *ARC) replace(x string) string { + fromT1 := a.t1.len > 0 && ((x != "" && a.b2Idx[x] != nil && a.t1.len == a.p) || (a.t1.len > a.p)) + if fromT1 { + if tail := a.t1.removeTail(); tail != nil { + delete(a.t1Idx, tail.key) + a.b1.pushFront(&arcGhostNode{key: tail.key}) + a.b1Idx[tail.key] = a.b1.head + a.length-- + + return tail.key } + } else { + if tail := a.t2.removeTail(); tail != nil { + delete(a.t2Idx, tail.key) + a.b2.pushFront(&arcGhostNode{key: tail.key}) + a.b2Idx[tail.key] = a.b2.head + a.length-- + + return tail.key + } + } - arc.itemPoolManager.Put(item) + return "" +} - return +// updateIfResident updates value and placement for keys already in T1 or T2. +func (a *ARC) updateIfResident(key string, value any) bool { + if node, ok := a.t1Idx[key]; ok { + node.value = value + a.t1.remove(node) + delete(a.t1Idx, key) + + a.t2.pushFront(node) + a.t2Idx[key] = node + + return true } - // Check t2 - item, ok = arc.t2[key] - if ok { - delete(arc.t2, key) - arc.c-- - arc.itemPoolManager.Put(item) + if node, ok := a.t2Idx[key]; ok { + node.value = value + a.t2.remove(node) + a.t2.pushFront(node) + + return true } + + return false } -// Evict removes an item from the cache and returns the key of the evicted item. -// If no item can be evicted, it returns an error. -func (arc *ARC) Evict() (string, bool) { - if arc.capacity == 0 { - return "", false +// handleGhostHit processes B1/B2 hits and moves the key to T2 while adapting p. +func (a *ARC) handleGhostHit(key string, value any) bool { + if ghost, ok := a.b1Idx[key]; ok { + increment := a.b2.len / arcIntMax(1, a.b1.len) + if increment < 1 { + increment = 1 + } + + a.p += increment + if a.p > a.capacity { + a.p = a.capacity + } + + a.replace(key) + + a.b1.remove(ghost) + delete(a.b1Idx, key) + + node := &arcNode{key: key, value: value} + a.t2.pushFront(node) + a.t2Idx[key] = node + a.length++ + + return true } - // Check t1 - for key, val := range arc.t1 { - delete(arc.t1, key) - arc.c-- - arc.itemPoolManager.Put(val) - return key, true + if ghost, ok := a.b2Idx[key]; ok { + decrement := a.b1.len / arcIntMax(1, a.b2.len) + if decrement < 1 { + decrement = 1 + } + + a.p -= decrement + if a.p < 0 { + a.p = 0 + } + + a.replace(key) + + a.b2.remove(ghost) + delete(a.b2Idx, key) + + node := &arcNode{key: key, value: value} + a.t2.pushFront(node) + a.t2Idx[key] = node + a.length++ + + return true } - // Check t2 - for key, val := range arc.t2 { - delete(arc.t2, key) - arc.c-- - arc.itemPoolManager.Put(val) - return key, true + return false +} + +// insertNew ensures capacity and history bounds, then inserts key into T1. +func (a *ARC) insertNew(key string, value any) { + if a.length >= a.capacity { + a.ensureCapacityForNew() } - return "", false + node := &arcNode{key: key, value: value} + a.t1.pushFront(node) + a.t1Idx[key] = node + a.length++ } -// Promote moves the item with the given key from t2 to t1. -func (arc *ARC) promote(key string) { - arc.mutex.Lock() - defer arc.mutex.Unlock() +// ensureCapacityForNew frees space for a new resident item per ARC rules. +func (a *ARC) ensureCapacityForNew() { + if a.t1.len+a.b1.len >= a.capacity { + a.trimForHistoryLimit() - item, ok := arc.t2[key] - if !ok { return } - delete(arc.t2, key) - arc.t1[key] = item - - arc.p++ - if arc.p > arc.capacity { - arc.p = arc.capacity - } + a.trimForTotalLimit() } -// Demote moves the item with the given key from t1 to t2. -func (arc *ARC) demote(key string) { - arc.mutex.Lock() - defer arc.mutex.Unlock() +// trimForHistoryLimit handles the case where |T1| + |B1| >= c. +func (a *ARC) trimForHistoryLimit() { + if a.t1.len < a.capacity { + if tail := a.b1.removeTail(); tail != nil { + delete(a.b1Idx, tail.key) + } - item, ok := arc.t1[key] - if !ok { return } - delete(arc.t1, key) - arc.t2[key] = item + if tail := a.t1.removeTail(); tail != nil { + delete(a.t1Idx, tail.key) + a.b1.pushFront(&arcGhostNode{key: tail.key}) + a.b1Idx[tail.key] = a.b1.head + a.length-- + } +} + +// trimForTotalLimit handles the case where total lists exceed 2c, then calls replace. +func (a *ARC) trimForTotalLimit() { + total := a.t1.len + a.t2.len + a.b1.len + a.b2.len + if total >= 2*a.capacity { + if tail := a.b2.removeTail(); tail != nil { + delete(a.b2Idx, tail.key) + } + } + + a.replace("") +} - arc.p-- - if arc.p < 0 { - arc.p = 0 +func arcIntMax(a, b int) int { + if a > b { + return a } + + return b } diff --git a/pkg/eviction/arc_test.go b/pkg/eviction/arc_test.go new file mode 100644 index 0000000..2a2416b --- /dev/null +++ b/pkg/eviction/arc_test.go @@ -0,0 +1,71 @@ +package eviction + +import "testing" + +func TestARC_BasicSetGetAndEvict(t *testing.T) { + arc, err := NewARCAlgorithm(2) + if err != nil { + t.Fatalf("NewARCAlgorithm error: %v", err) + } + + arc.Set("a", 1) + arc.Set("b", 2) + if v, ok := arc.Get("a"); !ok || v.(int) != 1 { + t.Fatalf("expected a=1, got %v ok=%v", v, ok) + } + + // Insert c, causing an eviction + arc.Set("c", 3) + // One of a/b should be evicted; the other should remain. + if _, ok := arc.Get("a"); !ok { + if _, ok2 := arc.Get("b"); !ok2 { + t.Fatalf("expected one of 'a' or 'b' to remain resident") + } + } +} + +func TestARC_ZeroCapacity_NoOp(t *testing.T) { + arc, err := NewARCAlgorithm(0) + if err != nil { + t.Fatalf("NewARCAlgorithm error: %v", err) + } + arc.Set("a", 1) + if _, ok := arc.Get("a"); ok { + t.Fatalf("expected miss on zero-capacity") + } + if key, ok := arc.Evict(); ok || key != "" { + t.Fatalf("expected no eviction on zero-capacity, got %q ok=%v", key, ok) + } +} + +func TestARC_Delete_RemovesResidentAndGhost(t *testing.T) { + arc, err := NewARCAlgorithm(2) + if err != nil { + t.Fatalf("NewARCAlgorithm error: %v", err) + } + arc.Set("a", 1) + arc.Set("b", 2) + arc.Delete("a") + if _, ok := arc.Get("a"); ok { + t.Fatalf("expected 'a' deleted") + } + // create a ghost by forcing eviction + arc.Set("c", 3) + arc.Delete("b") // whether resident or ghost, Delete should handle it +} + +func TestARC_B1GhostHitPromotesToT2(t *testing.T) { + arc, err := NewARCAlgorithm(2) + if err != nil { + t.Fatalf("NewARCAlgorithm error: %v", err) + } + arc.Set("a", 1) + arc.Set("b", 2) + // cause eviction of one resident into ghosts by adding 'c' + arc.Set("c", 3) + // Now reinsert one of the early keys to hit a ghost (B1/B2) path + arc.Set("a", 10) + if v, ok := arc.Get("a"); !ok || v.(int) != 10 { + t.Fatalf("expected updated a=10 resident after B1/B2 hit, got %v ok=%v", v, ok) + } +} From 0262ac1c04fc14bdc62e1dac2bac85c246d3da1a Mon Sep 17 00:00:00 2001 From: Francesco Cosentino Date: Thu, 21 Aug 2025 13:44:28 +0300 Subject: [PATCH 5/5] chore: minor polishing --- pkg/eviction/arc.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/pkg/eviction/arc.go b/pkg/eviction/arc.go index 182479b..4d77509 100644 --- a/pkg/eviction/arc.go +++ b/pkg/eviction/arc.go @@ -320,10 +320,7 @@ func (a *ARC) updateIfResident(key string, value any) bool { // handleGhostHit processes B1/B2 hits and moves the key to T2 while adapting p. func (a *ARC) handleGhostHit(key string, value any) bool { if ghost, ok := a.b1Idx[key]; ok { - increment := a.b2.len / arcIntMax(1, a.b1.len) - if increment < 1 { - increment = 1 - } + increment := max(a.b2.len/arcIntMax(1, a.b1.len), 1) a.p += increment if a.p > a.capacity { @@ -344,10 +341,7 @@ func (a *ARC) handleGhostHit(key string, value any) bool { } if ghost, ok := a.b2Idx[key]; ok { - decrement := a.b1.len / arcIntMax(1, a.b2.len) - if decrement < 1 { - decrement = 1 - } + decrement := max(a.b1.len/arcIntMax(1, a.b2.len), 1) a.p -= decrement if a.p < 0 {