From a7bac80a9aa65139a7eb071296015810a09bf007 Mon Sep 17 00:00:00 2001 From: Omar Polo Date: Sun, 12 Jul 2026 09:35:52 +0000 Subject: [PATCH 01/12] caching/lru: augment the coverage * add a test for more than one eviction * test Stats() as we operate on the cache --- caching/lru/lru_test.go | 80 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/caching/lru/lru_test.go b/caching/lru/lru_test.go index e7d0b6c47..3c852eb9d 100644 --- a/caching/lru/lru_test.go +++ b/caching/lru/lru_test.go @@ -2,6 +2,7 @@ package lru import ( "errors" + "fmt" "testing" "github.com/stretchr/testify/require" @@ -63,6 +64,38 @@ func TestCache(t *testing.T) { require.Equal(t, "three", val) }) + t.Run("Eviction Beyond One Step", func(t *testing.T) { + cache := New[int, string](2, nil) + defer cache.Close() + + // Push far more items through the cache than its capacity so + // several evictions happen in sequence, checking at every step + // that the two most recent keys survive and anything older is + // gone. + for i := 1; i <= 10; i++ { + err := cache.Put(i, fmt.Sprintf("val-%d", i)) + require.NoError(t, err) + + if i > 2 { + _, ok := cache.Get(i - 2) + require.False(t, ok, "key %d should have been evicted", i-2) + } + + val, ok := cache.items[i] + require.True(t, ok) + require.Equal(t, fmt.Sprintf("val-%d", i), val) + + if i > 1 { + val, ok := cache.items[i-1] + require.True(t, ok) + require.Equal(t, fmt.Sprintf("val-%d", i-1), val) + } + } + + _, _, size := cache.Stats() + require.Equal(t, uint64(2), size) + }) + t.Run("OnEvict Callback", func(t *testing.T) { evicted := make(map[int]string) onEvict := func(key int, val string) error { @@ -70,7 +103,7 @@ func TestCache(t *testing.T) { return nil } - cache := New[int, string](2, onEvict) + cache := New(2, onEvict) defer cache.Close() // Fill the cache @@ -93,7 +126,7 @@ func TestCache(t *testing.T) { return expectedErr } - cache := New[int, string](2, onEvict) + cache := New(2, onEvict) defer cache.Close() // Fill the cache @@ -136,6 +169,49 @@ func TestCache(t *testing.T) { require.Equal(t, uint64(2), size) }) + t.Run("Stats After Eviction", func(t *testing.T) { + cache := New[int, string](2, nil) + defer cache.Close() + + err := cache.Put(1, "one") + require.NoError(t, err) + err = cache.Put(2, "two") + require.NoError(t, err) + + // cache is now full; size must reflect that, with no hits/misses + // recorded yet + hits, misses, size := cache.Stats() + require.Equal(t, uint64(0), hits) + require.Equal(t, uint64(0), misses) + require.Equal(t, uint64(2), size) + + // this Put evicts key 1, so size must stay at 2, not grow to 3 + err = cache.Put(3, "three") + require.NoError(t, err) + + hits, misses, size = cache.Stats() + require.Equal(t, uint64(0), hits) + require.Equal(t, uint64(0), misses) + require.Equal(t, uint64(2), size) + + // a lookup for the evicted key must count as a miss and must not + // change size + _, ok := cache.Get(1) + require.False(t, ok) + + hits, misses, size = cache.Stats() + require.Equal(t, uint64(0), hits) + require.Equal(t, uint64(1), misses) + require.Equal(t, uint64(2), size) + + // evict once more (key 2) and confirm size still holds at 2 + err = cache.Put(4, "four") + require.NoError(t, err) + + _, _, size = cache.Stats() + require.Equal(t, uint64(2), size) + }) + t.Run("Update Existing", func(t *testing.T) { cache := New[int, string](3, nil) defer cache.Close() From 90e88fb3ffd20595df6a222531e1afdffa0bd08a Mon Sep 17 00:00:00 2001 From: Omar Polo Date: Sat, 11 Jul 2026 17:57:46 +0000 Subject: [PATCH 02/12] caching/lru: add some benchmarks --- caching/lru/lru_bench_test.go | 159 ++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 caching/lru/lru_bench_test.go diff --git a/caching/lru/lru_bench_test.go b/caching/lru/lru_bench_test.go new file mode 100644 index 000000000..f13686241 --- /dev/null +++ b/caching/lru/lru_bench_test.go @@ -0,0 +1,159 @@ +package lru + +import ( + "fmt" + "testing" +) + +func BenchmarkPut(b *testing.B) { + for _, target := range []int{16, 256, 4096} { + b.Run(fmt.Sprintf("target=%d", target), func(b *testing.B) { + cache := New[int, int](target, nil) + defer cache.Close() + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if err := cache.Put(i, i); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func BenchmarkPutUpdateExisting(b *testing.B) { + cache := New[int, int](256, nil) + defer cache.Close() + + for i := range 256 { + if err := cache.Put(i, i); err != nil { + b.Fatal(err) + } + } + + b.ReportAllocs() + for i := 0; b.Loop(); i++ { + if err := cache.Put(i%256, i); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkGetHit(b *testing.B) { + cache := New[int, int](256, nil) + defer cache.Close() + + for i := range 256 { + if err := cache.Put(i, i); err != nil { + b.Fatal(err) + } + } + + b.ReportAllocs() + for i := 0; b.Loop(); i++ { + _, _ = cache.Get(i % 256) + } +} + +func BenchmarkGetMiss(b *testing.B) { + cache := New[int, int](256, nil) + defer cache.Close() + + for i := range 256 { + if err := cache.Put(i, i); err != nil { + b.Fatal(err) + } + } + + b.ReportAllocs() + for i := 0; b.Loop(); i++ { + _, _ = cache.Get(i + 1_000_000) + } +} + +func BenchmarkGetParallel(b *testing.B) { + cache := New[int, int](256, nil) + defer cache.Close() + + for i := range 256 { + if err := cache.Put(i, i); err != nil { + b.Fatal(err) + } + } + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + _, _ = cache.Get(i % 256) + i++ + } + }) +} + +// BenchmarkPutEviction measures Put cost when every insert forces an +// eviction, i.e. the cache is kept permanently full. +func BenchmarkPutEviction(b *testing.B) { + const target = 256 + cache := New[int, int](target, nil) + defer cache.Close() + + for i := range target { + if err := cache.Put(i, i); err != nil { + b.Fatal(err) + } + } + + b.ReportAllocs() + for i := 0; b.Loop(); i++ { + if err := cache.Put(target+i, i); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkPutEvictionWithCallback(b *testing.B) { + const target = 256 + cache := New(target, func(K int, V int) error { return nil }) + defer cache.Close() + + for i := range target { + if err := cache.Put(i, i); err != nil { + b.Fatal(err) + } + } + + b.ReportAllocs() + for i := 0; b.Loop(); i++ { + if err := cache.Put(target+i, i); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkMixed simulates a workload with 90% reads and 10% writes, +// where writes cycle through a fixed key space and steadily trigger +// evictions once the cache is warm. +func BenchmarkMixed(b *testing.B) { + const target = 512 + cache := New[int, int](target, nil) + defer cache.Close() + + for i := range target { + if err := cache.Put(i, i); err != nil { + b.Fatal(err) + } + } + + b.ReportAllocs() + for i := 0; b.Loop(); i++ { + if i%10 == 0 { + if err := cache.Put(target+i, i); err != nil { + b.Fatal(err) + } + } else { + _, _ = cache.Get(i % target) + } + } +} From 714aaeb491a38a74dab6c67b30a8d60bf7d49f10 Mon Sep 17 00:00:00 2001 From: Omar Polo Date: Sat, 11 Jul 2026 15:50:58 +0000 Subject: [PATCH 03/12] caching/lru: refactor: move the value from the hash-map into the list This makes it easier to implement a real LRU, since now the behaviour is that of a FIFO. --- caching/lru/lru.go | 32 +++++++++++++++++--------------- caching/lru/lru_test.go | 8 ++++---- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/caching/lru/lru.go b/caching/lru/lru.go index 241b196bc..c5db86736 100644 --- a/caching/lru/lru.go +++ b/caching/lru/lru.go @@ -5,9 +5,10 @@ import ( "sync/atomic" ) -type node[K comparable] struct { +type node[K comparable, V any] struct { key K - next *node[K] + val V + next *node[K, V] } type Cache[K comparable, V any] struct { @@ -18,9 +19,9 @@ type Cache[K comparable, V any] struct { onevict func(K, V) error - items map[K]V - head *node[K] - tail *node[K] + items map[K]*node[K, V] + head *node[K, V] + tail *node[K, V] hits atomic.Uint64 misses atomic.Uint64 @@ -30,15 +31,13 @@ func New[K comparable, V any](target int, onevict func(K, V) error) *Cache[K, V] return &Cache[K, V]{ target: target, onevict: onevict, - items: make(map[K]V, target), + items: make(map[K]*node[K, V], target), } } func (c *Cache[K, V]) put(key K, val V) { c.size++ - c.items[key] = val - - n := &node[K]{key: key} + n := &node[K, V]{key: key, val: val} if c.head == nil { c.head = n c.tail = n @@ -46,13 +45,15 @@ func (c *Cache[K, V]) put(key K, val V) { c.tail.next = n c.tail = n } + + c.items[key] = n } // assume that the item was just removed from the linked list func (c *Cache[K, V]) flush(key K) error { - val := c.items[key] + n := c.items[key] if c.onevict != nil { - if err := c.onevict(key, val); err != nil { + if err := c.onevict(key, n.val); err != nil { return err } } @@ -64,16 +65,17 @@ func (c *Cache[K, V]) flush(key K) error { func (c *Cache[K, V]) Get(key K) (V, bool) { c.mtx.RLock() - val, ok := c.items[key] + n, ok := c.items[key] c.mtx.RUnlock() if ok { c.hits.Add(1) + return n.val, true } else { c.misses.Add(1) + var zero V + return zero, false } - - return val, ok } // Put adds or overrides an element in the cache. @@ -81,7 +83,7 @@ func (c *Cache[K, V]) Put(key K, val V) error { c.mtx.Lock() defer c.mtx.Unlock() if _, ok := c.items[key]; ok { - c.items[key] = val + c.items[key].val = val return nil } diff --git a/caching/lru/lru_test.go b/caching/lru/lru_test.go index 3c852eb9d..19b406992 100644 --- a/caching/lru/lru_test.go +++ b/caching/lru/lru_test.go @@ -81,14 +81,14 @@ func TestCache(t *testing.T) { require.False(t, ok, "key %d should have been evicted", i-2) } - val, ok := cache.items[i] + n, ok := cache.items[i] require.True(t, ok) - require.Equal(t, fmt.Sprintf("val-%d", i), val) + require.Equal(t, fmt.Sprintf("val-%d", i), n.val) if i > 1 { - val, ok := cache.items[i-1] + n, ok := cache.items[i-1] require.True(t, ok) - require.Equal(t, fmt.Sprintf("val-%d", i-1), val) + require.Equal(t, fmt.Sprintf("val-%d", i-1), n.val) } } From adf9b0efa10ee8c05a95bc34bf1e8c40a2585d80 Mon Sep 17 00:00:00 2001 From: Omar Polo Date: Sat, 11 Jul 2026 17:43:29 +0000 Subject: [PATCH 04/12] caching/lru: turn the linked list into a double-linked list while here, also change the way it's managed: we push items to the head and evict from the tail. --- caching/lru/lru.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/caching/lru/lru.go b/caching/lru/lru.go index c5db86736..aa8e51201 100644 --- a/caching/lru/lru.go +++ b/caching/lru/lru.go @@ -9,6 +9,7 @@ type node[K comparable, V any] struct { key K val V next *node[K, V] + prev *node[K, V] } type Cache[K comparable, V any] struct { @@ -42,8 +43,9 @@ func (c *Cache[K, V]) put(key K, val V) { c.head = n c.tail = n } else { - c.tail.next = n - c.tail = n + n.next = c.head + c.head.prev = n + c.head = n } c.items[key] = n @@ -88,10 +90,14 @@ func (c *Cache[K, V]) Put(key K, val V) error { } if c.size == c.target { - if err := c.flush(c.head.key); err != nil { + if err := c.flush(c.tail.key); err != nil { return err } - c.head = c.head.next + c.tail = c.tail.prev + if c.tail != nil { + c.tail.next.prev = nil + c.tail.next = nil + } } c.put(key, val) From 96d73ccba0b559b012eb21b2e00cb89859b6cdc7 Mon Sep 17 00:00:00 2001 From: Omar Polo Date: Sun, 12 Jul 2026 09:58:30 +0000 Subject: [PATCH 05/12] caching/lru: actually behave like a LRU Get now "bumps" the retrieved element at the top of the list, which makes eviction at the tail actually remove the last accessed item. --- caching/lru/lru.go | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/caching/lru/lru.go b/caching/lru/lru.go index aa8e51201..ee5613141 100644 --- a/caching/lru/lru.go +++ b/caching/lru/lru.go @@ -13,7 +13,7 @@ type node[K comparable, V any] struct { } type Cache[K comparable, V any] struct { - mtx sync.RWMutex + mtx sync.Mutex size int target int @@ -66,9 +66,28 @@ func (c *Cache[K, V]) flush(key K) error { } func (c *Cache[K, V]) Get(key K) (V, bool) { - c.mtx.RLock() + c.mtx.Lock() n, ok := c.items[key] - c.mtx.RUnlock() + if ok && n != c.head { + // move it as the first item in the list + prev := n.prev + next := n.next + prev.next = next + if next != nil { + next.prev = prev + } + + if c.tail == n { + c.tail = n.prev + c.tail.next = nil + } + + n.next = c.head + c.head.prev = n + c.head = n + n.prev = nil + } + c.mtx.Unlock() if ok { c.hits.Add(1) From 3f2612bbb88cad832a8869f76dbcaf5b643f3c55 Mon Sep 17 00:00:00 2001 From: Omar Polo Date: Sun, 12 Jul 2026 10:26:46 +0000 Subject: [PATCH 06/12] caching/lru: add a doc for New and silently bump size silently bump non-sensical values for size to a minimum of two, and while here add some doc for the New method. --- caching/lru/lru.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/caching/lru/lru.go b/caching/lru/lru.go index ee5613141..411a7cfe4 100644 --- a/caching/lru/lru.go +++ b/caching/lru/lru.go @@ -28,7 +28,12 @@ type Cache[K comparable, V any] struct { misses atomic.Uint64 } +// New constructs a cache of maximum size target. The minimum size is +// two, and any value minor than that will silently be bumped to at +// least 2. onevict is an optional callback that is called when +// evicting an item from the cache. func New[K comparable, V any](target int, onevict func(K, V) error) *Cache[K, V] { + target = max(target, 2) return &Cache[K, V]{ target: target, onevict: onevict, From 8aeff7fd1b3bedf38b7768316414f9b6fbaaecf5 Mon Sep 17 00:00:00 2001 From: Omar Polo Date: Sun, 12 Jul 2026 12:16:18 +0000 Subject: [PATCH 07/12] caching/lru: improve eviction test --- caching/lru/lru_test.go | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/caching/lru/lru_test.go b/caching/lru/lru_test.go index 19b406992..0be07303a 100644 --- a/caching/lru/lru_test.go +++ b/caching/lru/lru_test.go @@ -68,28 +68,35 @@ func TestCache(t *testing.T) { cache := New[int, string](2, nil) defer cache.Close() - // Push far more items through the cache than its capacity so - // several evictions happen in sequence, checking at every step - // that the two most recent keys survive and anything older is - // gone. - for i := 1; i <= 10; i++ { + err := cache.Put(1, "one") + require.NoError(t, err) + err = cache.Put(2, "two") + require.NoError(t, err) + + // Repeatedly touch key 1 so it stays the most-recently-used + // entry, while a stream of new keys cycles through the other + // slot. + for i := 3; i <= 10; i++ { + _, ok := cache.Get(1) + require.True(t, ok) + err := cache.Put(i, fmt.Sprintf("val-%d", i)) require.NoError(t, err) - if i > 2 { - _, ok := cache.Get(i - 2) - require.False(t, ok, "key %d should have been evicted", i-2) - } + // the key from the previous round, never touched again + // after it was inserted, must now be gone + _, ok = cache.Get(i - 1) + require.False(t, ok, "key %d should have been evicted", i-1) - n, ok := cache.items[i] + // key 1 and the newly inserted key must both still be + // present + val, ok := cache.Get(1) require.True(t, ok) - require.Equal(t, fmt.Sprintf("val-%d", i), n.val) + require.Equal(t, "one", val) - if i > 1 { - n, ok := cache.items[i-1] - require.True(t, ok) - require.Equal(t, fmt.Sprintf("val-%d", i-1), n.val) - } + val, ok = cache.Get(i) + require.True(t, ok) + require.Equal(t, fmt.Sprintf("val-%d", i), val) } _, _, size := cache.Stats() From 8cdd02b385c5c3673ffa0e3d02d81629e51da9d1 Mon Sep 17 00:00:00 2001 From: Omar Polo Date: Sun, 12 Jul 2026 12:43:54 +0000 Subject: [PATCH 08/12] caching/lru: add a "promote middle node" test for full coverage --- caching/lru/lru_test.go | 77 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/caching/lru/lru_test.go b/caching/lru/lru_test.go index 0be07303a..e77399ea8 100644 --- a/caching/lru/lru_test.go +++ b/caching/lru/lru_test.go @@ -8,6 +8,28 @@ import ( "github.com/stretchr/testify/require" ) +// requireConsistentList walks the cache's internal linked list from +// head to tail and checks that it forms a single well-formed chain of +// exactly c.size nodes with correctly paired prev/next pointers, no +// cycles, and a tail that matches where the walk actually ends. +func requireConsistentList[K comparable, V any](t *testing.T, c *Cache[K, V]) { + t.Helper() + + seen := make(map[K]bool, c.size) + var prev *node[K, V] + count := 0 + for n := c.head; n != nil; n = n.next { + count++ + require.LessOrEqualf(t, count, c.size, "list has more nodes than size=%d (cycle?)", c.size) + require.Falsef(t, seen[n.key], "key %v visited twice walking from head (cycle)", n.key) + seen[n.key] = true + require.Equal(t, prev, n.prev, "node %v has a mismatched prev pointer", n.key) + prev = n + } + require.Equal(t, c.size, count, "list length does not match c.size") + require.Equal(t, c.tail, prev, "c.tail does not match the last node reached from head") +} + func TestCache(t *testing.T) { t.Run("New Cache", func(t *testing.T) { cache := New[int, string](10, nil) @@ -103,6 +125,61 @@ func TestCache(t *testing.T) { require.Equal(t, uint64(2), size) }) + t.Run("Promote Middle Node", func(t *testing.T) { + cache := New[int, string](3, nil) + defer cache.Close() + + // With 3 resident keys, key 2 sits strictly between head and + // tail. + err := cache.Put(1, "one") + require.NoError(t, err) + err = cache.Put(2, "two") + require.NoError(t, err) + err = cache.Put(3, "three") + require.NoError(t, err) + requireConsistentList(t, cache) + + val, ok := cache.Get(2) + require.True(t, ok) + require.Equal(t, "two", val) + requireConsistentList(t, cache) + + // nothing should have been evicted by a Get; 1 and 3 must still + // be reachable even though 2 was spliced out from between them + val, ok = cache.Get(1) + require.True(t, ok) + require.Equal(t, "one", val) + requireConsistentList(t, cache) + + val, ok = cache.Get(3) + require.True(t, ok) + require.Equal(t, "three", val) + requireConsistentList(t, cache) + + // recency order is now (MRU -> LRU): 3, 1, 2. + // Filling the cache once more must evict 2, the one + // entry not touched since being spliced out. + err = cache.Put(4, "four") + require.NoError(t, err) + requireConsistentList(t, cache) + + _, ok = cache.Get(2) + require.False(t, ok, "key 2 should have been evicted") + + val, ok = cache.Get(1) + require.True(t, ok) + require.Equal(t, "one", val) + + val, ok = cache.Get(3) + require.True(t, ok) + require.Equal(t, "three", val) + + val, ok = cache.Get(4) + require.True(t, ok) + require.Equal(t, "four", val) + requireConsistentList(t, cache) + }) + t.Run("OnEvict Callback", func(t *testing.T) { evicted := make(map[int]string) onEvict := func(key int, val string) error { From fbc583d45be40457f55321b154ecf88c1bb1749d Mon Sep 17 00:00:00 2001 From: Omar Polo Date: Sun, 12 Jul 2026 14:10:37 +0000 Subject: [PATCH 09/12] caching/lru: rename flush() to evictItem --- caching/lru/lru.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/caching/lru/lru.go b/caching/lru/lru.go index 411a7cfe4..7c75b9739 100644 --- a/caching/lru/lru.go +++ b/caching/lru/lru.go @@ -56,8 +56,7 @@ func (c *Cache[K, V]) put(key K, val V) { c.items[key] = n } -// assume that the item was just removed from the linked list -func (c *Cache[K, V]) flush(key K) error { +func (c *Cache[K, V]) evictItem(key K) error { n := c.items[key] if c.onevict != nil { if err := c.onevict(key, n.val); err != nil { @@ -114,7 +113,7 @@ func (c *Cache[K, V]) Put(key K, val V) error { } if c.size == c.target { - if err := c.flush(c.tail.key); err != nil { + if err := c.evictItem(c.tail.key); err != nil { return err } c.tail = c.tail.prev @@ -134,7 +133,7 @@ func (c *Cache[K, V]) Close() error { var err error for n := c.head; n != nil; n = n.next { - if e := c.flush(n.key); e != nil { + if e := c.evictItem(n.key); e != nil { err = e } } From dfe7598c4106672922580f365c11faa1b92294e9 Mon Sep 17 00:00:00 2001 From: Omar Polo Date: Sun, 12 Jul 2026 17:04:59 +0000 Subject: [PATCH 10/12] btree: fix tests after LRU rework in both cases, the breakage comes from the fact that the new LRU has an implicit bigger size, so tests that were relying on tiny capacities needed a fix. * cache_internal_test: add a few more nodes to fill the cache * iter_internal_test: flush the cache to restore previous behaviour --- btree/cache_internal_test.go | 24 ++++++++++++++++++++---- btree/iter_internal_test.go | 3 +++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/btree/cache_internal_test.go b/btree/cache_internal_test.go index cfa4c8850..ef1817023 100644 --- a/btree/cache_internal_test.go +++ b/btree/cache_internal_test.go @@ -18,15 +18,31 @@ func Test_Get(t *testing.T) { Keys: []rune{'b'}, Pointers: []int{2}, } + n3 := Node[rune, int, int]{ + Keys: []rune{'c'}, + Pointers: []int{3}, + } + n4 := Node[rune, int, int]{ + Keys: []rune{'d'}, + Pointers: []int{4}, + } ptr1, err := st.Put(&n1) require.NoError(t, err) ptr2, err := st.Put(&n2) require.NoError(t, err) - - c := cachefor[rune, int, int](&st, 1) - n, err := st.Get(ptr1) + ptr3, err := st.Put(&n3) + require.NoError(t, err) + ptr4, err := st.Put(&n4) require.NoError(t, err) - require.NoError(t, c.Update(ptr1, n)) + + c := cachefor[rune, int, int](&st, 2) + + // warm up the cache + for _, ptrn := range []int{ptr1, ptr2, ptr3, ptr4} { + n, err := st.Get(ptrn) + require.NoError(t, err) + require.NoError(t, c.Update(ptrn, n)) + } flushErr := errors.New("Store.Update() failed") st.UpdateFn = func(ptr int, n *Node[rune, int, int]) error { return flushErr } diff --git a/btree/iter_internal_test.go b/btree/iter_internal_test.go index 2752de568..43e0a626f 100644 --- a/btree/iter_internal_test.go +++ b/btree/iter_internal_test.go @@ -504,6 +504,9 @@ func TestBackwardIter(t *testing.T) { return &st.store[ptr], nil } + // flush the cache + it.b.cache.lru.Close() + require.False(t, it.Next()) require.ErrorIs(t, it.Err(), getErr) }) From 0b201e331e688bbab54657704bf43674526b1461 Mon Sep 17 00:00:00 2001 From: Omar Polo Date: Sun, 12 Jul 2026 19:27:17 +0000 Subject: [PATCH 11/12] caching/lru: add some docs --- caching/lru/lru.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/caching/lru/lru.go b/caching/lru/lru.go index 7c75b9739..3039b4e5d 100644 --- a/caching/lru/lru.go +++ b/caching/lru/lru.go @@ -12,6 +12,8 @@ type node[K comparable, V any] struct { prev *node[K, V] } +// Cache implements a LRU caching strategy. All methods beside Close +// are safe for concurrent use. type Cache[K comparable, V any] struct { mtx sync.Mutex @@ -33,6 +35,9 @@ type Cache[K comparable, V any] struct { // least 2. onevict is an optional callback that is called when // evicting an item from the cache. func New[K comparable, V any](target int, onevict func(K, V) error) *Cache[K, V] { + // it's simpler to assume that we'll always have something in + // the linked list, rather than trying to cope with + // patological cases like zero or one. target = max(target, 2) return &Cache[K, V]{ target: target, @@ -56,6 +61,8 @@ func (c *Cache[K, V]) put(key K, val V) { c.items[key] = n } +// evictItem removes an item from the `items' map, without touching +// the list. func (c *Cache[K, V]) evictItem(key K) error { n := c.items[key] if c.onevict != nil { @@ -69,6 +76,9 @@ func (c *Cache[K, V]) evictItem(key K) error { return nil } +// Get retrieves a key from the cache, returning the value and true, +// or the zero value and false if not found. It also bumps the +// "recent-ness" of the key. func (c *Cache[K, V]) Get(key K) (V, bool) { c.mtx.Lock() n, ok := c.items[key] @@ -103,7 +113,9 @@ func (c *Cache[K, V]) Get(key K) (V, bool) { } } -// Put adds or overrides an element in the cache. +// Put adds or overrides an element in the cache, eventually evicting +// the less recent item in the cache. It can only fail if the +// `onevict` callback fails, in which case it aborts the operation. func (c *Cache[K, V]) Put(key K, val V) error { c.mtx.Lock() defer c.mtx.Unlock() @@ -127,6 +139,11 @@ func (c *Cache[K, V]) Put(key K, val V) error { return nil } +// Close flushes all the items in the cache using the `onevict` +// callback, if provided. It is safe to reuse a cache after it has +// been closed, provided that the size and `onevict` callback are +// still fine: this method behaves like a reset. It can only fail if +// `onevict` fails, in which case it returns the last error. func (c *Cache[K, V]) Close() error { c.mtx.Lock() defer c.mtx.Unlock() @@ -142,6 +159,8 @@ func (c *Cache[K, V]) Close() error { return err } +// Stats return the number of cache hit, misses and the size of the +// cache. It does *not* reset them. func (c *Cache[K, V]) Stats() (hit, miss, size uint64) { return c.hits.Load(), c.misses.Load(), uint64(c.size) } From f29ebf2c3148990b6b40ef6903ba08c47b6b91f5 Mon Sep 17 00:00:00 2001 From: Omar Polo Date: Sun, 12 Jul 2026 19:26:45 +0000 Subject: [PATCH 12/12] caching/lru: simplify Close --- caching/lru/lru.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/caching/lru/lru.go b/caching/lru/lru.go index 3039b4e5d..5891fc77f 100644 --- a/caching/lru/lru.go +++ b/caching/lru/lru.go @@ -149,11 +149,15 @@ func (c *Cache[K, V]) Close() error { defer c.mtx.Unlock() var err error - for n := c.head; n != nil; n = n.next { - if e := c.evictItem(n.key); e != nil { - err = e + if c.onevict != nil { + for key, n := range c.items { + if e := c.onevict(key, n.val); e != nil { + err = e + } } } + clear(c.items) + c.size = 0 c.head = nil c.tail = nil return err