diff --git a/internal/cache/lru/radix_arena.go b/internal/cache/lru/radix_arena.go new file mode 100644 index 0000000000..c2d856f07e --- /dev/null +++ b/internal/cache/lru/radix_arena.go @@ -0,0 +1,437 @@ +// Copyright 2026 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lru + +import ( + "math" + "strings" + + "github.com/googlecloudplatform/gcsfuse/v3/internal/locker" +) + +const nilNode uint32 = math.MaxUint32 + +// arenaRadixNode represents a node in the custom radix tree. +// It uses a Left-Child Right-Sibling (LCRS) representation and 32-bit indices to avoid slice allocations and pointer overhead. +type arenaRadixNode struct { + prefix string + value ValueType + parent uint32 + child uint32 + sibling uint32 + prev uint32 + next uint32 +} + +// arenaRadix encapsulates the core tree structure and implements the lru.Cache interface. +type arenaRadix struct { + maxSize uint64 + currentSize uint64 + mu locker.RWLocker + + nodes []arenaRadixNode + freeHead uint32 + + nodeMap map[uint64]uint32 + + root uint32 + + head uint32 + tail uint32 + + len int +} + +// FNV-1a 64-bit constants +const ( + // offset64 is the FNV-1a offset basis for 64-bit hashes + offset64 = 14695981039346656037 + // prime64 is the FNV-1a prime for 64-bit hashes + prime64 = 1099511628211 +) + +// FNV-1a 64-bit hash +func hashString(s string) uint64 { + var h uint64 = offset64 + for i := range len(s) { + h ^= uint64(s[i]) + h *= prime64 + } + return h +} + +// hashNodeKey computes the FNV-1a hash of the full key for a given node +// without allocating a concatenated string, reducing GC pressure. +func (c *arenaRadix) hashNodeKey(nodeID uint32) uint64 { + var stackBuf [64]uint32 + stack := stackBuf[:0] + + curr := nodeID + for curr != c.root && curr != nilNode { + stack = append(stack, curr) + curr = c.nodes[curr].parent + } + + var h uint64 = offset64 + for i := len(stack) - 1; i >= 0; i-- { + prefix := c.nodes[stack[i]].prefix + for j := 0; j < len(prefix); j++ { + h ^= uint64(prefix[j]) + h *= prime64 + } + } + return h +} + +func (c *arenaRadix) allocateNode() uint32 { + if c.freeHead != nilNode { + id := c.freeHead + c.freeHead = c.nodes[id].next + c.nodes[id] = arenaRadixNode{ + parent: nilNode, + child: nilNode, + sibling: nilNode, + prev: nilNode, + next: nilNode, + } + return id + } + id := uint32(len(c.nodes)) + if id >= nilNode { + panic("arena radix capacity exceeded limit") + } + c.nodes = append(c.nodes, arenaRadixNode{ + parent: nilNode, + child: nilNode, + sibling: nilNode, + prev: nilNode, + next: nilNode, + }) + return id +} + +func (c *arenaRadix) freeNode(id uint32) { + n := &c.nodes[id] + n.prefix = "" + n.value = nil + n.parent = nilNode + n.child = nilNode + n.sibling = nilNode + n.prev = nilNode + + n.next = c.freeHead + c.freeHead = id +} + +// getChild finds a child node whose prefix starts with the given byte. +func (c *arenaRadix) getChild(nID uint32, b byte) uint32 { + n := &c.nodes[nID] + for curr := n.child; curr != nilNode; curr = c.nodes[curr].sibling { + if c.nodes[curr].prefix[0] == b { + return curr + } + //as the siblings are arranged in lexicographical order we can exit early + if c.nodes[curr].prefix[0] > b { + return nilNode + } + } + return nilNode +} + +// addChild adds a child node, maintaining sorted order by the first byte of the prefix. +func (c *arenaRadix) addChild(nID uint32, newChildID uint32) { + c.nodes[newChildID].parent = nID + pcurr := &c.nodes[nID].child + for *pcurr != nilNode && c.nodes[*pcurr].prefix[0] < c.nodes[newChildID].prefix[0] { + pcurr = &c.nodes[*pcurr].sibling + } + c.nodes[newChildID].sibling = *pcurr + *pcurr = newChildID +} + +// removeChild directly removes a child node by reference from the sibling list. +func (c *arenaRadix) removeChild(nID uint32, childToRemoveID uint32) { + for pcurr := &c.nodes[nID].child; *pcurr != nilNode; pcurr = &c.nodes[*pcurr].sibling { + if *pcurr != childToRemoveID { + continue + } + *pcurr = c.nodes[childToRemoveID].sibling + c.nodes[childToRemoveID].sibling = nilNode + c.nodes[childToRemoveID].parent = nilNode + return + } + panic("removeChild: requested child not found in sibling list") +} + +// replaceChild finds oldChild in the sibling linked-list and replaces it with newChild, +// seamlessly preserving the rest of the sibling chain. +func (c *arenaRadix) replaceChild(nID uint32, oldChildID uint32, newChildID uint32) { + for pcurr := &c.nodes[nID].child; *pcurr != nilNode; pcurr = &c.nodes[*pcurr].sibling { + if *pcurr != oldChildID { + continue + } + c.nodes[newChildID].sibling = c.nodes[oldChildID].sibling + *pcurr = newChildID + c.nodes[oldChildID].sibling = nilNode + c.nodes[oldChildID].parent = nilNode + return + } + panic("replaceChild: requested child not found in sibling list") +} + +// insertNode inserts a new key into the radix tree and returns the leaf node ID. +func (c *arenaRadix) insertNode(key string, value ValueType) (uint32, ValueType) { + if value == nil { + return nilNode, nil + } + + nodeID := c.root + search := key + + for { + if len(search) == 0 { + oldValue := c.nodes[nodeID].value + c.nodes[nodeID].value = value + return nodeID, oldValue + } + + childID := c.getChild(nodeID, search[0]) + if childID == nilNode { + newLeafID := c.allocateNode() + c.nodes[newLeafID].prefix = strings.Clone(search) + c.nodes[newLeafID].value = value + c.addChild(nodeID, newLeafID) + return newLeafID, nil + } + + lcp := longestCommonPrefix(search, c.nodes[childID].prefix) + + if lcp == len(c.nodes[childID].prefix) { + search = search[lcp:] + nodeID = childID + continue + } + + splitNodeID := c.allocateNode() + c.nodes[splitNodeID].prefix = strings.Clone(c.nodes[childID].prefix[:lcp]) + c.nodes[splitNodeID].parent = nodeID + + c.replaceChild(nodeID, childID, splitNodeID) + + c.nodes[childID].prefix = strings.Clone(c.nodes[childID].prefix[lcp:]) + c.addChild(splitNodeID, childID) + + if lcp == len(search) { + oldValue := c.nodes[splitNodeID].value + c.nodes[splitNodeID].value = value + return splitNodeID, oldValue + } + + newLeafID := c.allocateNode() + c.nodes[newLeafID].prefix = strings.Clone(search[lcp:]) + c.nodes[newLeafID].value = value + c.addChild(splitNodeID, newLeafID) + return newLeafID, nil + } +} + +// verifyKey checks if the node's full path matches the given key with ZERO allocations. +func (c *arenaRadix) verifyKey(nodeID uint32, key string) bool { + curr := nodeID + end := len(key) + + for curr != c.root && curr != nilNode { + prefix := c.nodes[curr].prefix + prefixLen := len(prefix) + + if end < prefixLen { + return false + } + if key[end-prefixLen:end] != prefix { + return false + } + end -= prefixLen + curr = c.nodes[curr].parent + } + + return end == 0 && curr == c.root +} + +// getNodeKey finds a leaf node ID by key. +func (c *arenaRadix) getNodeKey(key string) (uint32, bool) { + nodeID, ok := c.nodeMap[hashString(key)] + if ok && c.nodes[nodeID].value != nil { + if c.verifyKey(nodeID, key) { + return nodeID, true + } + } + + curr := c.root + search := key + + for curr != nilNode { + if len(search) == 0 { + if c.nodes[curr].value != nil { + c.nodeMap[hashString(key)] = curr + return curr, true + } + return nilNode, false + } + + childID := c.getChild(curr, search[0]) + if childID == nilNode { + return nilNode, false + } + + prefix := c.nodes[childID].prefix + if !strings.HasPrefix(search, prefix) { + return nilNode, false + } + + search = search[len(prefix):] + curr = childID + } + + return nilNode, false +} + +// deleteNode removes a leaf node directly using its parent pointers. +func (c *arenaRadix) deleteNode(nodeID uint32) { + if nodeID == nilNode || c.nodes[nodeID].value == nil { + return + } + + c.nodes[nodeID].value = nil + c.compressPathUpwards(nodeID) +} + +// compressPathUpwards walks up the tree, pruning empty leaves and compressing single-child routing nodes. +func (c *arenaRadix) compressPathUpwards(currID uint32) { + for currID != nilNode && currID != c.root { + if c.nodes[currID].value != nil { + break + } + + if c.nodes[currID].child == nilNode { + parentID := c.nodes[currID].parent + c.removeChild(parentID, currID) + c.freeNode(currID) + currID = parentID + continue + } + + if c.nodes[c.nodes[currID].child].sibling == nilNode { + onlyChildID := c.nodes[currID].child + c.nodes[onlyChildID].prefix = c.nodes[currID].prefix + c.nodes[onlyChildID].prefix + c.nodes[onlyChildID].parent = c.nodes[currID].parent + + parentID := c.nodes[currID].parent + c.replaceChild(parentID, currID, onlyChildID) + + c.freeNode(currID) + currID = parentID + continue + } + break + } +} + +// --- LRU Logic --- +func (c *arenaRadix) moveToFront(nodeID uint32) { + if c.head == nodeID { + return + } + prev := c.nodes[nodeID].prev + next := c.nodes[nodeID].next + + if prev != nilNode { + c.nodes[prev].next = next + } + if next != nilNode { + c.nodes[next].prev = prev + } + if c.tail == nodeID { + c.tail = prev + } + c.nodes[nodeID].prev = nilNode + c.nodes[nodeID].next = c.head + if c.head != nilNode { + c.nodes[c.head].prev = nodeID + } + c.head = nodeID +} + +func (c *arenaRadix) pushFront(nodeID uint32) { + c.nodes[nodeID].prev = nilNode + c.nodes[nodeID].next = c.head + if c.head != nilNode { + c.nodes[c.head].prev = nodeID + } + c.head = nodeID + if c.tail == nilNode { + c.tail = nodeID + } + c.len++ +} + +func (c *arenaRadix) remove(nodeID uint32) { + if c.head != nodeID && c.nodes[nodeID].prev == nilNode { + return + } + prev := c.nodes[nodeID].prev + next := c.nodes[nodeID].next + + if prev != nilNode { + c.nodes[prev].next = next + } else { + c.head = next + } + if next != nilNode { + c.nodes[next].prev = prev + } else { + c.tail = prev + } + c.nodes[nodeID].prev = nilNode + c.nodes[nodeID].next = nilNode + c.len-- +} + +func (c *arenaRadix) evictOne() ValueType { + nodeID := c.tail + if nodeID == nilNode { + return nil + } + + return c.eraseInternal(nodeID) +} + +//////////////////////////////////////////////////////////////////////// +// Cache interface +//////////////////////////////////////////////////////////////////////// + +func (c *arenaRadix) eraseInternal(nodeID uint32) (value ValueType) { + deletedEntry := c.nodes[nodeID].value + c.currentSize -= deletedEntry.Size() + + // Prevent hash collision cross-deletions + hash := c.hashNodeKey(nodeID) + if c.nodeMap[hash] == nodeID { + delete(c.nodeMap, hash) + } + + c.remove(nodeID) + c.deleteNode(nodeID) + + return deletedEntry +} diff --git a/internal/cache/lru/radix_arena_lru.go b/internal/cache/lru/radix_arena_lru.go new file mode 100644 index 0000000000..5ad159b763 --- /dev/null +++ b/internal/cache/lru/radix_arena_lru.go @@ -0,0 +1,319 @@ +// Copyright 2026 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lru + +import ( + "fmt" + + "github.com/googlecloudplatform/gcsfuse/v3/internal/locker" +) + +// NewArenaRadixCache returns the reference of cache object by initialising the cache with +// the supplied maxSize, which must be greater than zero. +func NewArenaRadixCache(maxSize uint64) Cache { + if maxSize == 0 { + panic("Invalid maxSize") + } + + c := &arenaRadix{ + maxSize: maxSize, + freeHead: nilNode, + head: nilNode, + tail: nilNode, + nodeMap: make(map[uint64]uint32), + } + + c.root = c.allocateNode() + c.mu = locker.NewRW("ArenaRadixCache", c.checkInvariants) + return c +} + +func (c *arenaRadix) checkInvariants() { + // INVARIANT: currentSize <= maxSize + if c.currentSize > c.maxSize { + panic(fmt.Sprintf("CurrentSize %v over maxSize %v", c.currentSize, c.maxSize)) + } + + // INVARIANT: Each element in the LRU list must have a valid value + lruCount := 0 + for currID := c.head; currID != nilNode; currID = c.nodes[currID].next { + lruCount++ + if c.nodes[currID].value == nil { + panic(fmt.Sprintf("Unexpected empty value in LRU list for prefix: %v", c.nodes[currID].prefix)) + } + } + + if lruCount != c.len { + panic(fmt.Sprintf("LRU list actual count %v does not match c.len %v", lruCount, c.len)) + } + + // INVARIANT: Every value-bearing node in the tree must exist in the LRU list exactly once + treeCount := 0 + + // Iterative pre-order traversal using parent/sibling pointers (O(1) space) to prevent stack overflows + currID := c.root + for currID != nilNode { + if c.nodes[currID].value != nil { + treeCount++ + // A node is verifiably in the LRU list if it is the head, or if it has a predecessor + inLRU := c.head == currID || c.nodes[currID].prev != nilNode + if !inLRU { + panic(fmt.Sprintf("Mismatch: Node with prefix '%v' has a value but is missing from LRU list", c.nodes[currID].prefix)) + } + } + + // Advance to next node + if c.nodes[currID].child != nilNode { + currID = c.nodes[currID].child + continue + } + + // Backtrack up parent chain + for currID != c.root && c.nodes[currID].sibling == nilNode { + currID = c.nodes[currID].parent + } + if currID == c.root { + break + } + currID = c.nodes[currID].sibling + } + + if treeCount != c.len { + panic(fmt.Sprintf("Tree actual value count %v does not match LRU length %v", treeCount, c.len)) + } +} + +//////////////////////////////////////////////////////////////////////// +// Cache interface +//////////////////////////////////////////////////////////////////////// + +// Insert the supplied value into the cache, overwriting any previous entry for +// the given key. The value must be non-nil. +// Also returns a slice of ValueType evicted by the new inserted entry. +func (c *arenaRadix) Insert(key string, value ValueType) ([]ValueType, error) { + if value == nil { + return nil, ErrInvalidEntry + } + + valueSize := value.Size() + if valueSize > c.maxSize { + return nil, ErrInvalidEntrySize + } + + c.mu.Lock() + defer c.mu.Unlock() + + if nodeID, oldValue := c.insertNode(key, value); oldValue != nil { + c.currentSize += valueSize - oldValue.Size() + c.nodeMap[hashString(key)] = nodeID + c.moveToFront(nodeID) + } else { + c.pushFront(nodeID) + c.currentSize += valueSize + c.nodeMap[hashString(key)] = nodeID + } + + var evictedValues []ValueType + // Evict until we're at or below maxSize + for c.currentSize > c.maxSize && c.tail != nilNode { + evictedValues = append(evictedValues, c.evictOne()) + } + + return evictedValues, nil +} + +// Erase any entry for the supplied key, also returns the value of erased key. +func (c *arenaRadix) Erase(key string) (value ValueType) { + c.mu.Lock() + defer c.mu.Unlock() + + nodeID, ok := c.getNodeKey(key) + if !ok { + return nil + } + + return c.eraseInternal(nodeID) +} + +// LookUp a previously-inserted value for the given key. Return nil if no +// value is present. +func (c *arenaRadix) LookUp(key string) (value ValueType) { + c.mu.Lock() + defer c.mu.Unlock() + + nodeID, ok := c.getNodeKey(key) + if !ok { + return nil + } + c.moveToFront(nodeID) + + return c.nodes[nodeID].value +} + +// LookUpWithoutChangingOrder looks up previously-inserted value for a given key +// without changing the order of entries in the cache. Return nil if no value +// is present. +// +// Note: Even though this lookup doesn't change the MRU order, we must acquire a +// write lock because getNodeKey can lazily mutate the internal nodeMap cache. +func (c *arenaRadix) LookUpWithoutChangingOrder(key string) (value ValueType) { + c.mu.Lock() + defer c.mu.Unlock() + + nodeID, ok := c.getNodeKey(key) + if !ok { + return nil + } + + return c.nodes[nodeID].value +} + +// UpdateWithoutChangingOrder updates entry with the given key in cache with +// given value without changing order of entries in cache, returning error if an +// entry with given key doesn't exist. Also, the size of value for entry +// shouldn't be updated with this method (use c.Insert for updating size). +func (c *arenaRadix) UpdateWithoutChangingOrder(key string, value ValueType) error { + if value == nil { + return ErrInvalidEntry + } + + c.mu.Lock() + defer c.mu.Unlock() + + nodeID, ok := c.getNodeKey(key) + if !ok { + return ErrEntryNotExist + } + + if value.Size() != c.nodes[nodeID].value.Size() { + return ErrInvalidUpdateEntrySize + } + + c.nodes[nodeID].value = value + return nil +} + +// UpdateSize updates the currentSize accounting when an entry's size has changed. +// This is needed for entries whose size grows incrementally (e.g., sparse files). +// The entry's order in the LRU is not changed. +func (c *arenaRadix) UpdateSize(key string, sizeDelta uint64) error { + c.mu.Lock() + defer c.mu.Unlock() + + _, ok := c.getNodeKey(key) + if !ok { + return ErrEntryNotExist + } + + // Update currentSize accounting + c.currentSize += sizeDelta + + // Evict until we're at or below maxSize to maintain invariants + for c.currentSize > c.maxSize && c.tail != nilNode { + c.evictOne() + } + + return nil +} + +func (c *arenaRadix) EraseEntriesWithGivenPrefix(prefix string) { + c.mu.Lock() + defer c.mu.Unlock() + + if prefix == "" { + c.nodes = nil + c.freeHead = nilNode + + c.root = c.allocateNode() + c.head = nilNode + c.tail = nilNode + c.currentSize = 0 + c.len = 0 + clear(c.nodeMap) + return + } + + nodeID := c.root + search := prefix + + for len(search) > 0 { + childID := c.getChild(nodeID, search[0]) + if childID == nilNode { + return // Prefix doesn't exist + } + + lcp := longestCommonPrefix(search, c.nodes[childID].prefix) + + if lcp == len(search) { + // We found the exact node where the prefix ends. + // Sever it entirely from the tree structure + c.removeChild(nodeID, childID) + + // temporarily restore upward parent pointer + c.nodes[childID].parent = nodeID + + // Now sweep the detached subtree to fix LRU and currentSize + c.freeSubtree(childID) + c.compressPathUpwards(nodeID) + return + } + + if lcp == len(c.nodes[childID].prefix) { + search = search[len(c.nodes[childID].prefix):] + nodeID = childID + continue + } + + return + } +} + +// freeSubtree recursively cleans up a detached subtree and correctly frees every node +func (c *arenaRadix) freeSubtree(nodeID uint32) { + if nodeID == nilNode { + return + } + currID := nodeID + for currID != nilNode { + if c.nodes[currID].value != nil { + c.currentSize -= c.nodes[currID].value.Size() + c.remove(currID) + hash := c.hashNodeKey(currID) + if c.nodeMap[hash] == currID { + delete(c.nodeMap, hash) + } + c.nodes[currID].value = nil + } + + if c.nodes[currID].child != nilNode { + currID = c.nodes[currID].child + continue + } + + for currID != nodeID && c.nodes[currID].sibling == nilNode { + parentID := c.nodes[currID].parent + c.freeNode(currID) + currID = parentID + } + + if currID == nodeID { + c.freeNode(currID) + return + } + + siblingID := c.nodes[currID].sibling + c.freeNode(currID) + currID = siblingID + } +} diff --git a/internal/cache/lru/radix_arena_test.go b/internal/cache/lru/radix_arena_test.go new file mode 100644 index 0000000000..36cf8612e9 --- /dev/null +++ b/internal/cache/lru/radix_arena_test.go @@ -0,0 +1,465 @@ +// Copyright 2026 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lru_test + +import ( + "fmt" + "math/rand" + "sync" + "testing" + + "github.com/googlecloudplatform/gcsfuse/v3/internal/cache/lru" + + "github.com/googlecloudplatform/gcsfuse/v3/internal/locker" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupArenaRadixCacheTest(t *testing.T) lru.Cache { + locker.EnableInvariantsCheck() + return lru.NewArenaRadixCache(testMaxSize) +} + +func TestArenaRadixCache_LookUpInEmptyCache(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + assert.Nil(t, cache.LookUp("")) + assert.Nil(t, cache.LookUp("taco")) +} + +func TestArenaRadixCache_InsertNilValue(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "taco", nil, []int64{}, lru.ErrInvalidEntry) +} + +func TestArenaRadixCache_InsertEmptyKey(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + + insertAndAssert(t, cache, "", testData{Value: 42, DataSize: 10}, []int64{}, nil) + + assert.Equal(t, int64(42), cache.LookUp("").(testData).Value) + assert.Nil(t, cache.LookUp("taco")) +} + +func TestArenaRadixCache_LookUpUnknownKey(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "burrito", testData{Value: 23, DataSize: 4}, []int64{}, nil) + insertAndAssert(t, cache, "taco", testData{Value: 23, DataSize: 8}, []int64{}, nil) + + assert.Nil(t, cache.LookUp("")) + assert.Nil(t, cache.LookUp("enchilada")) +} + +func TestArenaRadixCache_FillUpToCapacity(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "burrito", testData{Value: 23, DataSize: 4}, []int64{}, nil) + insertAndAssert(t, cache, "taco", testData{Value: 26, DataSize: 20}, []int64{}, nil) + insertAndAssert(t, cache, "enchilada", testData{Value: 28, DataSize: 26}, []int64{}, nil) + + assert.Equal(t, int64(23), cache.LookUp("burrito").(testData).Value) + assert.Equal(t, int64(26), cache.LookUp("taco").(testData).Value) + assert.Equal(t, int64(28), cache.LookUp("enchilada").(testData).Value) +} + +func TestArenaRadixCache_ExpiresLeastRecentlyUsed(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "burrito", testData{Value: 23, DataSize: 4}, []int64{}, nil) + + // Least recent. + insertAndAssert(t, cache, "taco", testData{Value: 26, DataSize: 20}, []int64{}, nil) + + // Second most recent. + insertAndAssert(t, cache, "enchilada", testData{Value: 28, DataSize: 26}, []int64{}, nil) + + assert.Equal(t, int64(23), cache.LookUp("burrito").(testData).Value) // Most recent + + // Insert another. + insertAndAssert(t, cache, "queso", testData{Value: 34, DataSize: 5}, []int64{26}, nil) + + // See what's left. + assert.Nil(t, cache.LookUp("taco")) + assert.Equal(t, int64(23), cache.LookUp("burrito").(testData).Value) + assert.Equal(t, int64(28), cache.LookUp("enchilada").(testData).Value) + assert.Equal(t, int64(34), cache.LookUp("queso").(testData).Value) +} + +func TestArenaRadixCache_Overwrite(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "burrito", testData{Value: 23, DataSize: 4}, []int64{}, nil) + insertAndAssert(t, cache, "taco", testData{Value: 26, DataSize: 20}, []int64{}, nil) + insertAndAssert(t, cache, "enchilada", testData{Value: 28, DataSize: 20}, []int64{}, nil) + insertAndAssert(t, cache, "burrito", testData{Value: 33, DataSize: 6}, []int64{}, nil) + + // Increase the DataSize while modifying, so eviction should happen + insertAndAssert(t, cache, "burrito", testData{Value: 33, DataSize: 12}, []int64{26}, nil) + + assert.Nil(t, cache.LookUp("taco")) + assert.Equal(t, int64(33), cache.LookUp("burrito").(testData).Value) + assert.Equal(t, int64(28), cache.LookUp("enchilada").(testData).Value) +} + +func TestArenaRadixCache_MultipleEviction(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "burrito", testData{Value: 23, DataSize: 4}, []int64{}, nil) + insertAndAssert(t, cache, "taco", testData{Value: 26, DataSize: 20}, []int64{}, nil) + insertAndAssert(t, cache, "enchilada", testData{Value: 28, DataSize: 20}, []int64{}, nil) + + // Increase the DataSize while modifying, so eviction should happen + insertAndAssert(t, cache, "large_data", testData{Value: 33, DataSize: 45}, []int64{23, 26, 28}, nil) + + assert.Nil(t, cache.LookUp("taco")) + assert.Nil(t, cache.LookUp("burrito")) + assert.Nil(t, cache.LookUp("enchilada")) + assert.Equal(t, int64(33), cache.LookUp("large_data").(testData).Value) +} + +func TestArenaRadixCache_WhenEntrySizeMoreThanCacheMaxSize(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "burrito", testData{Value: 23, DataSize: 4}, []int64{}, nil) + + // Insert entry with size greater than maxSize of cache. + insertAndAssert(t, cache, "taco", testData{Value: 26, DataSize: testMaxSize + 1}, []int64{}, lru.ErrInvalidEntrySize) + + assert.Equal(t, int64(23), cache.LookUp("burrito").(testData).Value) +} + +func TestArenaRadixCache_EraseWhenKeyPresent(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "burrito", testData{Value: 23, DataSize: 4}, []int64{}, nil) + + deletedEntry := cache.Erase("burrito") + + assert.Equal(t, int64(23), deletedEntry.(testData).Value) + assert.Nil(t, cache.LookUp("burrito")) +} + +func TestArenaRadixCache_EraseCacheWithGivenPrefix(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "a", testData{Value: 23, DataSize: 4}, []int64{}, nil) + insertAndAssert(t, cache, "a/b", testData{Value: 26, DataSize: 5}, []int64{}, nil) + insertAndAssert(t, cache, "a/b/d", testData{Value: 22, DataSize: 6}, []int64{}, nil) + insertAndAssert(t, cache, "a/c", testData{Value: 20, DataSize: 6}, []int64{}, nil) + insertAndAssert(t, cache, "b", testData{Value: 21, DataSize: 2}, []int64{}, nil) + + cache.EraseEntriesWithGivenPrefix("a") + + assert.Nil(t, cache.LookUp("a")) + assert.Nil(t, cache.LookUp("a/b")) + assert.Nil(t, cache.LookUp("a/b/d")) + assert.Nil(t, cache.LookUp("a/c")) + assert.Equal(t, uint64(2), cache.LookUp("b").Size()) +} + +func TestArenaRadixCache_EraseCacheWithEmptyPrefix(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + + insertAndAssert(t, cache, "a", testData{Value: 23, DataSize: 4}, []int64{}, nil) + insertAndAssert(t, cache, "a/b", testData{Value: 26, DataSize: 5}, []int64{}, nil) + insertAndAssert(t, cache, "b", testData{Value: 21, DataSize: 2}, []int64{}, nil) + + cache.EraseEntriesWithGivenPrefix("") + + assert.Nil(t, cache.LookUp("a")) + assert.Nil(t, cache.LookUp("a/b")) + assert.Nil(t, cache.LookUp("b")) +} + +func TestArenaRadixCache_EraseCacheWhereNoEntriesExistWithGivenPrefix(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "a", testData{Value: 23, DataSize: 4}, []int64{}, nil) + insertAndAssert(t, cache, "a/b", testData{Value: 26, DataSize: 5}, []int64{}, nil) + insertAndAssert(t, cache, "b", testData{Value: 21, DataSize: 2}, []int64{}, nil) + + cache.EraseEntriesWithGivenPrefix("c") + + assert.Equal(t, uint64(4), cache.LookUp("a").Size()) + assert.Equal(t, uint64(5), cache.LookUp("a/b").Size()) + assert.Equal(t, uint64(2), cache.LookUp("b").Size()) +} + +func TestArenaRadixCache_EraseCacheWithGivenPrefixWithSomeEntriesEvictedDueToCacheSize(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "a", testData{Value: 23, DataSize: 20}, []int64{}, nil) + insertAndAssert(t, cache, "a/b", testData{Value: 26, DataSize: 10}, []int64{}, nil) + insertAndAssert(t, cache, "a/b/d", testData{Value: 22, DataSize: 5}, []int64{}, nil) + insertAndAssert(t, cache, "a/c", testData{Value: 20, DataSize: 10}, []int64{}, nil) + insertAndAssert(t, cache, "b", testData{Value: 21, DataSize: 15}, []int64{23}, nil) + + // As entry "a" was already evicted by the insertion of "b", only three entries will be removed. + cache.EraseEntriesWithGivenPrefix("a") + + assert.Nil(t, cache.LookUp("a")) + assert.Nil(t, cache.LookUp("a/b")) + assert.Nil(t, cache.LookUp("a/b/d")) + assert.Nil(t, cache.LookUp("a/c")) + assert.Equal(t, uint64(15), cache.LookUp("b").Size()) +} + +func TestArenaRadixCache_EraseWhenKeyNotPresent(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "burrito", testData{Value: 23, DataSize: 4}, []int64{}, nil) + + deletedEntry := cache.Erase("taco") + + assert.Nil(t, deletedEntry) + assert.Equal(t, int64(23), cache.LookUp("burrito").(testData).Value) +} + +func TestArenaRadixCache_UpdateSize(t *testing.T) { + t.Run("NonExistentKey", func(t *testing.T) { + cache := lru.NewArenaRadixCache(100) + + err := cache.UpdateSize("key1", 20) + + assert.ErrorIs(t, err, lru.ErrEntryNotExist) + }) + + t.Run("Immediate Eviction", func(t *testing.T) { + cache := lru.NewArenaRadixCache(100) + data1 := testData{Value: 1, DataSize: 10} + data2 := testData{Value: 2, DataSize: 70} + _, _ = cache.Insert("key1", data1) + _, _ = cache.Insert("key2", data2) + + errUpdate := cache.UpdateSize("key1", 30) + + assert.NoError(t, errUpdate) + assert.Nil(t, cache.LookUp("key1")) + assert.NotNil(t, cache.LookUp("key2")) + }) +} + +func TestArenaRadixCache_UpdateSize_ExceedsMaxSize(t *testing.T) { + cache := lru.NewArenaRadixCache(100) + data := &testData{Value: 1, DataSize: 50} + _, err := cache.Insert("file.txt", data) + require.NoError(t, err) + + data.DataSize = 150 + err = cache.UpdateSize("file.txt", 100) + + assert.NoError(t, err) + assert.Nil(t, cache.LookUp("file.txt")) +} + +func TestArenaRadixCache_UpdateWhenKeyPresent(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + key := "burrito" + data := testData{Value: 23, DataSize: 4} + insertAndAssert(t, cache, key, data, []int64{}, nil) + newData := testData{Value: 2, DataSize: 4} + + err := cache.UpdateWithoutChangingOrder(key, newData) + + assert.Nil(t, err) + assert.Equal(t, int64(2), cache.LookUp(key).(testData).Value) +} + +func TestArenaRadixCache_UpdateWhenKeyNotPresent(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + key := "burrito" + data := testData{Value: 23, DataSize: 4} + + err := cache.UpdateWithoutChangingOrder(key, data) + + assert.ErrorIs(t, err, lru.ErrEntryNotExist) +} + +func TestArenaRadixCache_UpdateWhenSizeIsDifferent(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + key := "burrito" + data := testData{Value: 23, DataSize: 4} + insertAndAssert(t, cache, key, data, []int64{}, nil) + newData := testData{Value: 2, DataSize: 3} + + err := cache.UpdateWithoutChangingOrder(key, newData) + + assert.ErrorIs(t, err, lru.ErrInvalidUpdateEntrySize) +} + +func TestArenaRadixCache_UpdateNotChangeOrder(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + key1 := "burrito1" + data1 := testData{Value: 23, DataSize: 10} + insertAndAssert(t, cache, key1, data1, []int64{}, nil) + key2 := "burrito2" + data2 := testData{Value: 2, DataSize: 40} + insertAndAssert(t, cache, key2, data2, []int64{}, nil) + + newData := testData{Value: 7, DataSize: 10} + err := cache.UpdateWithoutChangingOrder(key1, newData) + + assert.Nil(t, err) + // inserting again which should evict key1 because key1 is updated without + // changing order + key3 := "burrito3" + data3 := testData{Value: 3, DataSize: 5} + insertAndAssert(t, cache, key3, data3, []int64{7}, nil) +} +func TestArenaRadixCache_UpdateSize_DoubleCountingDivergence(t *testing.T) { + const maxSize = 100 + const initialSize = 50 + const sizeDelta = 50 // New total size will be 50 + 50 = 100 (exactly at maxSize) + + mapCache := lru.NewCache(maxSize) + radixCache := lru.NewArenaRadixCache(maxSize) + + mapVal := &testData{Value: 23, DataSize: initialSize} + radixVal := &testData{Value: 2, DataSize: initialSize} + + // 1. Insert initial entries into both caches + _, err := mapCache.Insert("file.txt", mapVal) + assert.NoError(t, err) + _, err = radixCache.Insert("file.txt", radixVal) + assert.NoError(t, err) + + // 2. Simulate incremental file growth in memory (e.g., downloading a 50-byte chunk) + // Both objects now report Size() == 100. + mapVal.DataSize += sizeDelta + radixVal.DataSize += sizeDelta + + // 3. Notify mapCache of the growth. + // mapCache simply adds sizeDelta to c.currentSize without double counting. + err = mapCache.UpdateSize("file.txt", sizeDelta) + assert.NoError(t, err, "mapCache should succeed when updated size (100) <= maxSize (100)") + + // 4. Notify radixCache of the growth. + // radixCache evaluates node.value.Size() (100) + sizeDelta (50) = 150 > maxSize (100). + // This double-counts sizeDelta and incorrectly rejects a valid update. + err = radixCache.UpdateSize("file.txt", sizeDelta) + assert.NoError(t, err) +} + +func TestArenaRadixCache_LookUpWithoutChangingOrder_WhenKeyPresent(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + key := "burrito" + data := testData{Value: 23, DataSize: 4} + insertAndAssert(t, cache, key, data, []int64{}, nil) + + value := cache.LookUpWithoutChangingOrder(key) + + assert.Equal(t, int64(23), value.(testData).Value) +} + +func TestArenaRadixCache_LookUpWithoutChangingOrder_WhenKeyNotPresent(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + key := "burrito" + + value := cache.LookUpWithoutChangingOrder(key) + + assert.Nil(t, value) +} + +func TestArenaRadixCache_LookUpWithoutChangingOrder_NotChangeOrder(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + key1 := "burrito1" + data1 := testData{Value: 23, DataSize: 10} + insertAndAssert(t, cache, key1, data1, []int64{}, nil) + key2 := "burrito2" + data2 := testData{Value: 2, DataSize: 40} + insertAndAssert(t, cache, key2, data2, []int64{}, nil) + + value := cache.LookUpWithoutChangingOrder(key1) + + assert.Equal(t, int64(23), value.(testData).Value) + // inserting again which should evict key1 because key1 is looked up without + // changing order + key3 := "burrito3" + data3 := testData{Value: 3, DataSize: 5} + insertAndAssert(t, cache, key3, data3, []int64{23}, nil) +} + +// This will detect race if we run the test with `-race` flag. +// We get the race condition failure if we remove lock from Insert or Erase method. +func TestArenaRadixCache_RaceCondition(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + var wg sync.WaitGroup + wg.Add(6) + + go func() { + defer wg.Done() + for range testOperationCount { + cache.EraseEntriesWithGivenPrefix("k") + } + }() + + go func() { + defer wg.Done() + for i := range testOperationCount { + _, err := cache.Insert("key", testData{ + Value: int64(i), + DataSize: uint64(rand.Intn(testMaxSize)), + }) + assert.NoError(t, err) + } + }() + + go func() { + defer wg.Done() + for range testOperationCount { + cache.Erase("key") + } + }() + + go func() { + defer wg.Done() + for range testOperationCount { + cache.LookUp("key") + } + }() + + go func() { + defer wg.Done() + for range testOperationCount { + cache.LookUpWithoutChangingOrder("key") + } + }() + + go func() { + defer wg.Done() + for i := range testOperationCount { + _ = cache.UpdateWithoutChangingOrder("key", testData{ + Value: int64(i), + DataSize: uint64(rand.Intn(testMaxSize)), + }) + } + }() + + wg.Wait() +} + +func TestArenaRadixCache_EraseEntriesWithGivenPrefix_Concurrent(t *testing.T) { + c := lru.NewArenaRadixCache(100000) + + // Pre-fill the cache + for i := range 1000 { + _, _ = c.Insert(fmt.Sprintf("dir1/file%d", i), testData{10, 10}) + } + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for i := 1000; i < 2000; i++ { + _, _ = c.Insert(fmt.Sprintf("dir2/file%d", i), testData{10, 10}) + } + }() + + go func() { + defer wg.Done() + c.EraseEntriesWithGivenPrefix("dir1/") + }() + + wg.Wait() +} diff --git a/internal/cache/metadata/stat_cache_test.go b/internal/cache/metadata/stat_cache_test.go index 38210b5a80..ec91e2fcbc 100644 --- a/internal/cache/metadata/stat_cache_test.go +++ b/internal/cache/metadata/stat_cache_test.go @@ -27,8 +27,18 @@ import ( ) func TestStatCache(testSuite *testing.T) { - suite.Run(testSuite, new(StatCacheTest)) - suite.Run(testSuite, new(MultiBucketStatCacheTest)) + factories := map[string]func(uint64) lru.Cache{ + "MapCache": lru.NewCache, + "RadixCache": lru.NewRadixCache, + "ArenaRadixCache": lru.NewArenaRadixCache, + } + + for name, factory := range factories { + testSuite.Run(name, func(t *testing.T) { + suite.Run(t, &StatCacheTest{cacheFactory: factory}) + suite.Run(t, &MultiBucketStatCacheTest{cacheFactory: factory}) + }) + } } //////////////////////////////////////////////////////////////////////// @@ -114,16 +124,21 @@ type StatCacheTest struct { //added stat cache to test metadata.StatCache directly, removing unnecessary wrappers for accurate unit testing. //Changing every test will increase the scope and actual hns work will be affected, so taking cautious call to just add new test in refactored way in first go, //we can update rest of tests slowly later. - statCache metadata.StatCache + statCache metadata.StatCache + cacheFactory func(uint64) lru.Cache } type MultiBucketStatCacheTest struct { suite.Suite multiBucketCache testMultiBucketCacheHelper + cacheFactory func(uint64) lru.Cache } func (t *StatCacheTest) SetupTest() { - cache := lru.NewCache(uint64((cfg.AverageSizeOfPositiveStatCacheEntry + cfg.AverageSizeOfNegativeStatCacheEntry) * capacity)) + if t.cacheFactory == nil { + t.cacheFactory = lru.NewCache + } + cache := t.cacheFactory(uint64((cfg.AverageSizeOfPositiveStatCacheEntry + cfg.AverageSizeOfNegativeStatCacheEntry) * capacity)) t.cache.wrapped = metadata.NewStatCacheBucketView(cache, "") // this demonstrates t.statCache = metadata.NewStatCacheBucketView(cache, "") // this demonstrates // that if you are using a cache for a single bucket, then @@ -131,7 +146,10 @@ func (t *StatCacheTest) SetupTest() { } func (t *MultiBucketStatCacheTest) SetupTest() { - sharedCache := lru.NewCache(uint64((cfg.AverageSizeOfPositiveStatCacheEntry + cfg.AverageSizeOfNegativeStatCacheEntry) * capacity)) + if t.cacheFactory == nil { + t.cacheFactory = lru.NewCache + } + sharedCache := t.cacheFactory(uint64((cfg.AverageSizeOfPositiveStatCacheEntry + cfg.AverageSizeOfNegativeStatCacheEntry) * capacity)) t.multiBucketCache.fruits = testHelperCache{wrapped: metadata.NewStatCacheBucketView(sharedCache, "fruits")} t.multiBucketCache.spices = testHelperCache{wrapped: metadata.NewStatCacheBucketView(sharedCache, "spices")} } @@ -547,7 +565,7 @@ func (t *StatCacheTest) Test_ShouldReturnHitTrueWhenOnlyObjectAlreadyHasEntry() } func (t *StatCacheTest) Test_ShouldEvictEntryOnFullCapacityIncludingFolderSize() { - localCache := lru.NewCache(uint64(2600)) + localCache := t.cacheFactory(uint64(2600)) t.statCache = metadata.NewStatCacheBucketView(localCache, "local_bucket") objectEntry1 := &gcs.MinObject{Name: "1"} objectEntry2 := &gcs.MinObject{Name: "2"} @@ -580,7 +598,7 @@ func (t *StatCacheTest) Test_ShouldEvictEntryOnFullCapacityIncludingFolderSize() } func (t *StatCacheTest) Test_ShouldEvictAllEntriesWithPrefixFolder() { - localCache := lru.NewCache(uint64(10000)) + localCache := t.cacheFactory(uint64(10000)) t.statCache = metadata.NewStatCacheBucketView(localCache, "local_bucket") folderEntry1 := &gcs.Folder{ Name: "a",