From 4d8f3f0efb092a982ccfc3c1e287372801a14a69 Mon Sep 17 00:00:00 2001 From: Vedant Das Date: Tue, 14 Jul 2026 06:16:06 +0000 Subject: [PATCH 1/8] feat: add initial arena radix trie operations --- internal/cache/lru/arena_radix.go | 375 ++++++++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 internal/cache/lru/arena_radix.go diff --git a/internal/cache/lru/arena_radix.go b/internal/cache/lru/arena_radix.go new file mode 100644 index 00000000000..5100f386cb0 --- /dev/null +++ b/internal/cache/lru/arena_radix.go @@ -0,0 +1,375 @@ +// 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" + "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 + // LRU Linked List pointers + prev uint32 + next uint32 +} + +// arenaRadix encapsulates the core tree structure and implements the lru.Cache interface. +type arenaRadix struct { + ///////////////////////// + // Constant data + ///////////////////////// + + // INVARIANT: maxSize > 0 + maxSize uint64 + + ///////////////////////// + // Mutable state + ///////////////////////// + + // Sum of Size() of all the entries in the cache. + currentSize uint64 + + nodes []arenaRadixNode + freeHead uint32 + + nodeMap map[uint64]uint32 + + root uint32 + + // Head and tail of the LRU Doubly Linked List + head uint32 + tail uint32 + + // len is the number of nodes currently linked in the LRU list. + len int + + // All public methods of this Cache uses this RW mutex based locker while + // accessing/updating Cache's data. + mu locker.RWLocker +} + +// FNV-1a 64-bit hash +func hashString(s string) uint64 { + var h uint64 = 14695981039346656037 + for i := 0; i < len(s); i++ { + h ^= uint64(s[i]) + h *= 1099511628211 + } + return h +} + +func (c *arenaRadix) getFullKey(nodeID uint32) string { + var parts []string + curr := nodeID + for curr != c.root && curr != nilNode { + parts = append(parts, c.nodes[curr].prefix) + curr = c.nodes[curr].parent + } + var sb strings.Builder + for i := len(parts) - 1; i >= 0; i-- { + sb.WriteString(parts[i]) + } + return sb.String() +} + +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)) + 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 +} + +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)) + } +} + +// 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 { + *pcurr = c.nodes[childToRemoveID].sibling + + c.nodes[childToRemoveID].sibling = nilNode + c.nodes[childToRemoveID].parent = nilNode + + return + } + } +} + +// 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 { + c.nodes[newChildID].sibling = c.nodes[oldChildID].sibling + *pcurr = newChildID + + c.nodes[oldChildID].sibling = nilNode + c.nodes[oldChildID].parent = nilNode + + return + } + } +} + +// 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 { + // clone the substring to prevent memory leaks otherwise, the slice pins the entire original string in memory + 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 +} + +// 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 + } + } + 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 + } +} From cc83e4a789c3efbaca30b151e3d18d69b40f9aa6 Mon Sep 17 00:00:00 2001 From: Vedant Das Date: Tue, 14 Jul 2026 06:26:22 +0000 Subject: [PATCH 2/8] added tests for arena radix trie --- internal/cache/lru/arena_radix.go | 493 ++++++++++++++++---- internal/cache/lru/arena_radix_test.go | 611 +++++++++++++++++++++++++ 2 files changed, 1009 insertions(+), 95 deletions(-) create mode 100644 internal/cache/lru/arena_radix_test.go diff --git a/internal/cache/lru/arena_radix.go b/internal/cache/lru/arena_radix.go index 5100f386cb0..f2aa93ed6fb 100644 --- a/internal/cache/lru/arena_radix.go +++ b/internal/cache/lru/arena_radix.go @@ -17,6 +17,7 @@ import ( "fmt" "math" "strings" + "sync" "github.com/googlecloudplatform/gcsfuse/v3/internal/locker" ) @@ -31,25 +32,13 @@ type arenaRadixNode struct { parent uint32 child uint32 sibling uint32 - // LRU Linked List pointers - prev uint32 - next uint32 + prev uint32 + next uint32 } // arenaRadix encapsulates the core tree structure and implements the lru.Cache interface. type arenaRadix struct { - ///////////////////////// - // Constant data - ///////////////////////// - - // INVARIANT: maxSize > 0 - maxSize uint64 - - ///////////////////////// - // Mutable state - ///////////////////////// - - // Sum of Size() of all the entries in the cache. + maxSize uint64 currentSize uint64 nodes []arenaRadixNode @@ -59,36 +48,58 @@ type arenaRadix struct { root uint32 - // Head and tail of the LRU Doubly Linked List head uint32 tail uint32 - // len is the number of nodes currently linked in the LRU list. len int - // All public methods of this Cache uses this RW mutex based locker while - // accessing/updating Cache's data. mu locker.RWLocker } +// 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 = 14695981039346656037 - for i := 0; i < len(s); i++ { + var h uint64 = offset64 + for i := range len(s) { h ^= uint64(s[i]) - h *= 1099511628211 + h *= prime64 } return h } +var fullKeyPartsPool = sync.Pool{ + New: func() any { + parts := make([]string, 0, 32) + return &parts + }, +} + func (c *arenaRadix) getFullKey(nodeID uint32) string { - var parts []string + ptr := fullKeyPartsPool.Get().(*[]string) + parts := (*ptr)[:0] + defer func() { + *ptr = parts + fullKeyPartsPool.Put(ptr) + }() + curr := nodeID + totalLen := 0 for curr != c.root && curr != nilNode { - parts = append(parts, c.nodes[curr].prefix) + prefix := c.nodes[curr].prefix + parts = append(parts, prefix) + totalLen += len(prefix) curr = c.nodes[curr].parent } + var sb strings.Builder + sb.Grow(totalLen) for i := len(parts) - 1; i >= 0; i-- { sb.WriteString(parts[i]) } @@ -109,6 +120,9 @@ func (c *arenaRadix) allocateNode() uint32 { 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, @@ -132,61 +146,6 @@ func (c *arenaRadix) freeNode(id uint32) { c.freeHead = id } -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)) - } -} - // 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] @@ -216,30 +175,29 @@ func (c *arenaRadix) addChild(nID uint32, newChildID uint32) { // 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 { - *pcurr = c.nodes[childToRemoveID].sibling - - c.nodes[childToRemoveID].sibling = nilNode - c.nodes[childToRemoveID].parent = nilNode - - return + 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 { - c.nodes[newChildID].sibling = c.nodes[oldChildID].sibling - *pcurr = newChildID - - c.nodes[oldChildID].sibling = nilNode - c.nodes[oldChildID].parent = nilNode - - return + 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 } } @@ -261,7 +219,6 @@ func (c *arenaRadix) insertNode(key string, value ValueType) (uint32, ValueType) childID := c.getChild(nodeID, search[0]) if childID == nilNode { - // clone the substring to prevent memory leaks otherwise, the slice pins the entire original string in memory newLeafID := c.allocateNode() c.nodes[newLeafID].prefix = strings.Clone(search) c.nodes[newLeafID].value = value @@ -373,3 +330,349 @@ func (c *arenaRadix) compressPathUpwards(currID uint32) { break } } + +// 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)) + } +} + +// --- 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) 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 + for c.currentSize > c.maxSize && c.tail != nilNode { + evictedValues = append(evictedValues, c.evictOne()) + } + + return evictedValues, nil +} + +func (c *arenaRadix) eraseInternal(nodeID uint32) (value ValueType) { + deletedEntry := c.nodes[nodeID].value + c.currentSize -= deletedEntry.Size() + delete(c.nodeMap, hashString(c.getFullKey(nodeID))) + + c.remove(nodeID) + c.deleteNode(nodeID) + + return deletedEntry +} + +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) +} + +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 +} + +func (c *arenaRadix) LookUpWithoutChangingOrder(key string) (value ValueType) { + c.mu.RLock() + defer c.mu.RUnlock() + + nodeID, ok := c.getNodeKey(key) + if !ok { + return nil + } + + return c.nodes[nodeID].value +} + +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 +} + +func (c *arenaRadix) UpdateSize(key string, sizeDelta uint64) error { + c.mu.Lock() + defer c.mu.Unlock() + + _, ok := c.getNodeKey(key) + if !ok { + return ErrEntryNotExist + } + + c.currentSize += sizeDelta + + 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 + } + + lcp := longestCommonPrefix(search, c.nodes[childID].prefix) + + if lcp == len(search) { + c.removeChild(nodeID, childID) + 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 + } +} + +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) + delete(c.nodeMap, hashString(c.getFullKey(currID))) + 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/arena_radix_test.go b/internal/cache/lru/arena_radix_test.go new file mode 100644 index 00000000000..553b347bec8 --- /dev/null +++ b/internal/cache/lru/arena_radix_test.go @@ -0,0 +1,611 @@ +// 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" + "math/rand" + "sync" + "testing" + + "github.com/googlecloudplatform/gcsfuse/v3/internal/locker" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testData struct { + Value int64 + DataSize uint64 +} + +func (td testData) Size() uint64 { + return td.DataSize +} + +func insertAndAssert(t *testing.T, cache Cache, key string, val ValueType, evictedValues []int64, expectedError error) { + ret, err := cache.Insert(key, val) + + require.ErrorIs(t, err, expectedError) + require.Equal(t, len(evictedValues), len(ret)) + for index, value := range ret { + assert.Equal(t, evictedValues[index], value.(testData).Value) + } +} + +func setupArenaRadix() *arenaRadix { + c := &arenaRadix{ + freeHead: nilNode, + } + c.root = c.allocateNode() + return c +} + +func TestArenaRadix_AllocateAndFree(t *testing.T) { + c := setupArenaRadix() + + // Initial root node is allocated + assert.Equal(t, 1, len(c.nodes)) + assert.Equal(t, nilNode, c.freeHead) + + // Allocate a new node + id1 := c.allocateNode() + assert.Equal(t, uint32(1), id1) + assert.Equal(t, 2, len(c.nodes)) + + // Free the node + c.freeNode(id1) + assert.Equal(t, id1, c.freeHead) + + // Allocate again, should reuse + id2 := c.allocateNode() + assert.Equal(t, id1, id2) + assert.Equal(t, nilNode, c.freeHead) +} + +func TestArenaRadix_ChildOperations(t *testing.T) { + c := setupArenaRadix() + + // Create nodes + idA := c.allocateNode() + c.nodes[idA].prefix = "apple" + + idB := c.allocateNode() + c.nodes[idB].prefix = "banana" + + idC := c.allocateNode() + c.nodes[idC].prefix = "cherry" + + // Add children out of order, they should be sorted by prefix[0] + c.addChild(c.root, idC) + c.addChild(c.root, idA) + c.addChild(c.root, idB) + + // Verify order: apple (A) -> banana (B) -> cherry (C) + child1 := c.nodes[c.root].child + assert.Equal(t, idA, child1) + + child2 := c.nodes[child1].sibling + assert.Equal(t, idB, child2) + + child3 := c.nodes[child2].sibling + assert.Equal(t, idC, child3) + + assert.Equal(t, nilNode, c.nodes[child3].sibling) + + // Verify getChild + assert.Equal(t, idA, c.getChild(c.root, 'a')) + assert.Equal(t, idB, c.getChild(c.root, 'b')) + assert.Equal(t, idC, c.getChild(c.root, 'c')) + assert.Equal(t, nilNode, c.getChild(c.root, 'z')) + + // Replace B with new node D ("berry") + idD := c.allocateNode() + c.nodes[idD].prefix = "berry" + c.replaceChild(c.root, idB, idD) + + // verify old node is detached + assert.Equal(t, nilNode, c.nodes[idB].sibling) + assert.Equal(t, nilNode, c.nodes[idB].parent) + + // verify new node is linked correctly + assert.Equal(t, idD, c.nodes[idA].sibling) + assert.Equal(t, idC, c.nodes[idD].sibling) + + // Remove A + c.removeChild(c.root, idA) + assert.Equal(t, nilNode, c.nodes[idA].sibling) + assert.Equal(t, nilNode, c.nodes[idA].parent) + + assert.Equal(t, idD, c.nodes[c.root].child) +} + +func TestArenaRadix_HashString(t *testing.T) { + hash1 := hashString("hello") + hash2 := hashString("hello") + assert.Equal(t, hash1, hash2) + + hash3 := hashString("world") + assert.NotEqual(t, hash1, hash3) + + hashEmpty := hashString("") + assert.NotEqual(t, uint64(0), hashEmpty) +} + +func TestArenaRadix_GetFullKey(t *testing.T) { + c := setupArenaRadix() + + id1 := c.allocateNode() + c.nodes[id1].prefix = "a/" + c.nodes[id1].parent = c.root + + id2 := c.allocateNode() + c.nodes[id2].prefix = "b/" + c.nodes[id2].parent = id1 + + id3 := c.allocateNode() + c.nodes[id3].prefix = "c.txt" + c.nodes[id3].parent = id2 + + assert.Equal(t, "a/b/c.txt", c.getFullKey(id3)) + assert.Equal(t, "a/", c.getFullKey(id1)) + assert.Equal(t, "", c.getFullKey(c.root)) +} + +const testMaxSize = 50 +const testOperationCount = 100 + +func setupArenaRadixCacheTest(t *testing.T) Cache { + locker.EnableInvariantsCheck() + return 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{}, 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{}, 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 := NewArenaRadixCache(100) + + err := cache.UpdateSize("key1", 20) + + assert.ErrorIs(t, err, ErrEntryNotExist) + }) + + t.Run("Immediate Eviction", func(t *testing.T) { + cache := 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 := 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, 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, 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 TestUpdateSize_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 := NewCache(maxSize) + ArenaRadixCache := 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 = ArenaRadixCache.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 ArenaRadixCache of the growth. + // ArenaRadixCache evaluates node.value.Size() (100) + sizeDelta (50) = 150 > maxSize (100). + // This double-counts sizeDelta and incorrectly rejects a valid update. + err = ArenaRadixCache.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(7) + + go func() { + defer wg.Done() + for range testOperationCount { + _ = cache.UpdateSize("key", uint64(rand.Intn(testMaxSize))) + } + }() + + 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 := 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() +} From c84e985ccf3ea35017b3faa40b27aa08cb441500 Mon Sep 17 00:00:00 2001 From: Vedant Das Date: Thu, 16 Jul 2026 16:42:55 +0000 Subject: [PATCH 3/8] minor fixes --- internal/cache/lru/arena_radix.go | 271 +------------- internal/cache/lru/arena_radix_test.go | 472 ------------------------- 2 files changed, 7 insertions(+), 736 deletions(-) diff --git a/internal/cache/lru/arena_radix.go b/internal/cache/lru/arena_radix.go index f2aa93ed6fb..4ee39ababea 100644 --- a/internal/cache/lru/arena_radix.go +++ b/internal/cache/lru/arena_radix.go @@ -14,7 +14,6 @@ package lru import ( - "fmt" "math" "strings" "sync" @@ -199,6 +198,7 @@ func (c *arenaRadix) replaceChild(nID uint32, oldChildID uint32, newChildID uint 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. @@ -331,81 +331,6 @@ func (c *arenaRadix) compressPathUpwards(currID uint32) { } } -// 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)) - } -} - // --- LRU Logic --- func (c *arenaRadix) moveToFront(nodeID uint32) { if c.head == nodeID { @@ -479,200 +404,18 @@ func (c *arenaRadix) evictOne() ValueType { // Cache interface //////////////////////////////////////////////////////////////////////// -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 - for c.currentSize > c.maxSize && c.tail != nilNode { - evictedValues = append(evictedValues, c.evictOne()) - } - - return evictedValues, nil -} - func (c *arenaRadix) eraseInternal(nodeID uint32) (value ValueType) { deletedEntry := c.nodes[nodeID].value c.currentSize -= deletedEntry.Size() - delete(c.nodeMap, hashString(c.getFullKey(nodeID))) + + // Prevent hash collision cross-deletions + hash := hashString(c.getFullKey(nodeID)) + if c.nodeMap[hash] == nodeID { + delete(c.nodeMap, hash) + } c.remove(nodeID) c.deleteNode(nodeID) return deletedEntry } - -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) -} - -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 -} - -func (c *arenaRadix) LookUpWithoutChangingOrder(key string) (value ValueType) { - c.mu.RLock() - defer c.mu.RUnlock() - - nodeID, ok := c.getNodeKey(key) - if !ok { - return nil - } - - return c.nodes[nodeID].value -} - -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 -} - -func (c *arenaRadix) UpdateSize(key string, sizeDelta uint64) error { - c.mu.Lock() - defer c.mu.Unlock() - - _, ok := c.getNodeKey(key) - if !ok { - return ErrEntryNotExist - } - - c.currentSize += sizeDelta - - 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 - } - - lcp := longestCommonPrefix(search, c.nodes[childID].prefix) - - if lcp == len(search) { - c.removeChild(nodeID, childID) - 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 - } -} - -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) - delete(c.nodeMap, hashString(c.getFullKey(currID))) - 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/arena_radix_test.go b/internal/cache/lru/arena_radix_test.go index 553b347bec8..e8835bdb4cc 100644 --- a/internal/cache/lru/arena_radix_test.go +++ b/internal/cache/lru/arena_radix_test.go @@ -14,35 +14,11 @@ package lru import ( - "fmt" - "math/rand" - "sync" "testing" - "github.com/googlecloudplatform/gcsfuse/v3/internal/locker" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -type testData struct { - Value int64 - DataSize uint64 -} - -func (td testData) Size() uint64 { - return td.DataSize -} - -func insertAndAssert(t *testing.T, cache Cache, key string, val ValueType, evictedValues []int64, expectedError error) { - ret, err := cache.Insert(key, val) - - require.ErrorIs(t, err, expectedError) - require.Equal(t, len(evictedValues), len(ret)) - for index, value := range ret { - assert.Equal(t, evictedValues[index], value.(testData).Value) - } -} - func setupArenaRadix() *arenaRadix { c := &arenaRadix{ freeHead: nilNode, @@ -161,451 +137,3 @@ func TestArenaRadix_GetFullKey(t *testing.T) { assert.Equal(t, "a/", c.getFullKey(id1)) assert.Equal(t, "", c.getFullKey(c.root)) } - -const testMaxSize = 50 -const testOperationCount = 100 - -func setupArenaRadixCacheTest(t *testing.T) Cache { - locker.EnableInvariantsCheck() - return 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{}, 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{}, 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 := NewArenaRadixCache(100) - - err := cache.UpdateSize("key1", 20) - - assert.ErrorIs(t, err, ErrEntryNotExist) - }) - - t.Run("Immediate Eviction", func(t *testing.T) { - cache := 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 := 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, 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, 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 TestUpdateSize_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 := NewCache(maxSize) - ArenaRadixCache := 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 = ArenaRadixCache.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 ArenaRadixCache of the growth. - // ArenaRadixCache evaluates node.value.Size() (100) + sizeDelta (50) = 150 > maxSize (100). - // This double-counts sizeDelta and incorrectly rejects a valid update. - err = ArenaRadixCache.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(7) - - go func() { - defer wg.Done() - for range testOperationCount { - _ = cache.UpdateSize("key", uint64(rand.Intn(testMaxSize))) - } - }() - - 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 := 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() -} From a67675cde91e9e836b9b12aec9a7a15af452de5d Mon Sep 17 00:00:00 2001 From: Vedant Das Date: Thu, 16 Jul 2026 16:48:16 +0000 Subject: [PATCH 4/8] added tests for lru operations --- internal/cache/lru/arena_radix.go | 5 - internal/cache/lru/arena_radix_test.go | 230 ++++++++++++++++++++++++- 2 files changed, 222 insertions(+), 13 deletions(-) diff --git a/internal/cache/lru/arena_radix.go b/internal/cache/lru/arena_radix.go index 4ee39ababea..a30765766f2 100644 --- a/internal/cache/lru/arena_radix.go +++ b/internal/cache/lru/arena_radix.go @@ -17,8 +17,6 @@ import ( "math" "strings" "sync" - - "github.com/googlecloudplatform/gcsfuse/v3/internal/locker" ) const nilNode uint32 = math.MaxUint32 @@ -37,7 +35,6 @@ type arenaRadixNode struct { // arenaRadix encapsulates the core tree structure and implements the lru.Cache interface. type arenaRadix struct { - maxSize uint64 currentSize uint64 nodes []arenaRadixNode @@ -51,8 +48,6 @@ type arenaRadix struct { tail uint32 len int - - mu locker.RWLocker } // FNV-1a 64-bit constants diff --git a/internal/cache/lru/arena_radix_test.go b/internal/cache/lru/arena_radix_test.go index e8835bdb4cc..9d4535daa5a 100644 --- a/internal/cache/lru/arena_radix_test.go +++ b/internal/cache/lru/arena_radix_test.go @@ -49,10 +49,9 @@ func TestArenaRadix_AllocateAndFree(t *testing.T) { assert.Equal(t, nilNode, c.freeHead) } -func TestArenaRadix_ChildOperations(t *testing.T) { +func setupArenaRadixChildren() (*arenaRadix, uint32, uint32, uint32) { c := setupArenaRadix() - // Create nodes idA := c.allocateNode() c.nodes[idA].prefix = "apple" @@ -62,11 +61,17 @@ func TestArenaRadix_ChildOperations(t *testing.T) { idC := c.allocateNode() c.nodes[idC].prefix = "cherry" - // Add children out of order, they should be sorted by prefix[0] + // Add out of order to ensure sorting logic executes c.addChild(c.root, idC) c.addChild(c.root, idA) c.addChild(c.root, idB) + return c, idA, idB, idC +} + +func TestArenaRadix_AddChild(t *testing.T) { + c, idA, idB, idC := setupArenaRadixChildren() + // Verify order: apple (A) -> banana (B) -> cherry (C) child1 := c.nodes[c.root].child assert.Equal(t, idA, child1) @@ -78,32 +83,51 @@ func TestArenaRadix_ChildOperations(t *testing.T) { assert.Equal(t, idC, child3) assert.Equal(t, nilNode, c.nodes[child3].sibling) + assert.Equal(t, c.root, c.nodes[idA].parent) + assert.Equal(t, c.root, c.nodes[idB].parent) + assert.Equal(t, c.root, c.nodes[idC].parent) +} + +func TestArenaRadix_GetChild(t *testing.T) { + c, idA, idB, idC := setupArenaRadixChildren() - // Verify getChild assert.Equal(t, idA, c.getChild(c.root, 'a')) assert.Equal(t, idB, c.getChild(c.root, 'b')) assert.Equal(t, idC, c.getChild(c.root, 'c')) assert.Equal(t, nilNode, c.getChild(c.root, 'z')) +} + +func TestArenaRadix_ReplaceChild(t *testing.T) { + c, idA, idB, idC := setupArenaRadixChildren() // Replace B with new node D ("berry") idD := c.allocateNode() c.nodes[idD].prefix = "berry" c.replaceChild(c.root, idB, idD) - // verify old node is detached + // verify old node is completely detached assert.Equal(t, nilNode, c.nodes[idB].sibling) assert.Equal(t, nilNode, c.nodes[idB].parent) - // verify new node is linked correctly + // verify new node is linked correctly in the middle of A and C assert.Equal(t, idD, c.nodes[idA].sibling) assert.Equal(t, idC, c.nodes[idD].sibling) +} + +func TestArenaRadix_RemoveChild(t *testing.T) { + c, idA, idB, idC := setupArenaRadixChildren() - // Remove A + // Remove A (the first child) c.removeChild(c.root, idA) assert.Equal(t, nilNode, c.nodes[idA].sibling) assert.Equal(t, nilNode, c.nodes[idA].parent) + assert.Equal(t, idB, c.nodes[c.root].child) - assert.Equal(t, idD, c.nodes[c.root].child) + // Remove C (the last child) + c.removeChild(c.root, idC) + assert.Equal(t, nilNode, c.nodes[idC].sibling) + assert.Equal(t, nilNode, c.nodes[idC].parent) + assert.Equal(t, nilNode, c.nodes[idB].sibling) // B is now the last and only child } func TestArenaRadix_HashString(t *testing.T) { @@ -137,3 +161,193 @@ func TestArenaRadix_GetFullKey(t *testing.T) { assert.Equal(t, "a/", c.getFullKey(id1)) assert.Equal(t, "", c.getFullKey(c.root)) } + +type testValue struct { + size uint64 +} + +func (tv testValue) Size() uint64 { return tv.size } + +func setupArenaRadixLRU() (*arenaRadix, uint32, uint32, uint32) { + c := setupArenaRadix() + c.head = nilNode + c.tail = nilNode + c.len = 0 + c.nodeMap = make(map[uint64]uint32) + + id1 := c.allocateNode() + c.nodes[id1].value = testValue{size: 10} + c.nodes[id1].prefix = "node1" + c.addChild(c.root, id1) + + id2 := c.allocateNode() + c.nodes[id2].value = testValue{size: 20} + c.nodes[id2].prefix = "node2" + c.addChild(c.root, id2) + + id3 := c.allocateNode() + c.nodes[id3].value = testValue{size: 30} + c.nodes[id3].prefix = "node3" + c.addChild(c.root, id3) + + return c, id1, id2, id3 +} + +func TestArenaRadix_PushFront(t *testing.T) { + c, id1, id2, _ := setupArenaRadixLRU() + + c.pushFront(id1) + assert.Equal(t, id1, c.head) + assert.Equal(t, id1, c.tail) + assert.Equal(t, 1, c.len) + + c.pushFront(id2) + assert.Equal(t, id2, c.head) + assert.Equal(t, id1, c.tail) + assert.Equal(t, id1, c.nodes[id2].next) + assert.Equal(t, id2, c.nodes[id1].prev) + assert.Equal(t, 2, c.len) +} + +func TestArenaRadix_MoveToFront(t *testing.T) { + c, id1, id2, id3 := setupArenaRadixLRU() + c.pushFront(id1) + c.pushFront(id2) + c.pushFront(id3) + // Order: id3 <-> id2 <-> id1 + + c.moveToFront(id2) // Move from middle + assert.Equal(t, id2, c.head) + assert.Equal(t, id1, c.tail) + assert.Equal(t, id3, c.nodes[id2].next) + // Order: id2 <-> id3 <-> id1 + + c.moveToFront(id1) // Move from tail + assert.Equal(t, id1, c.head) + assert.Equal(t, id3, c.tail) + assert.Equal(t, id2, c.nodes[id1].next) + // Order: id1 <-> id2 <-> id3 + + c.moveToFront(id1) // Move already at head + assert.Equal(t, id1, c.head) +} + +func TestArenaRadix_Remove(t *testing.T) { + c, id1, id2, id3 := setupArenaRadixLRU() + c.pushFront(id1) + c.pushFront(id2) + c.pushFront(id3) + // Order: id3 <-> id2 <-> id1 + + c.remove(id2) // Remove from middle + assert.Equal(t, 2, c.len) + assert.Equal(t, id1, c.nodes[id3].next) + assert.Equal(t, id3, c.nodes[id1].prev) + assert.Equal(t, nilNode, c.nodes[id2].next) + assert.Equal(t, nilNode, c.nodes[id2].prev) + + c.remove(id3) // Remove from head + assert.Equal(t, id1, c.head) + assert.Equal(t, 1, c.len) + + c.remove(id1) // Remove from tail + assert.Equal(t, nilNode, c.head) + assert.Equal(t, nilNode, c.tail) + assert.Equal(t, 0, c.len) +} + +func TestArenaRadix_EvictOne(t *testing.T) { + c, id1, id2, id3 := setupArenaRadixLRU() + c.pushFront(id1) + c.pushFront(id2) + c.pushFront(id3) + // Order: id3 <-> id2 <-> id1 (id1 is tail) + + c.currentSize = 60 + c.nodeMap[hashString("node1")] = id1 + + val := c.evictOne() + assert.Equal(t, uint64(10), val.Size()) + + // id1 should be removed + assert.Equal(t, 2, c.len) + assert.Equal(t, id2, c.tail) + assert.Equal(t, uint64(50), c.currentSize) + + // verify id1 is deleted from tree and nodeMap + _, exists := c.nodeMap[hashString("node1")] + assert.False(t, exists) + assert.Nil(t, c.nodes[id1].value) +} + +func TestArenaRadix_InsertNode(t *testing.T) { + c := setupArenaRadix() + c.nodeMap = make(map[uint64]uint32) + + // Insert "apple" + id1, old1 := c.insertNode("apple", testValue{size: 10}) + assert.Nil(t, old1) + assert.Equal(t, testValue{size: 10}, c.nodes[id1].value) + + // Insert "app" (causes a split in "apple") + id2, old2 := c.insertNode("app", testValue{size: 5}) + assert.Nil(t, old2) + assert.Equal(t, testValue{size: 5}, c.nodes[id2].value) + + // Update "app" + id3, old3 := c.insertNode("app", testValue{size: 15}) + assert.Equal(t, id2, id3) + assert.Equal(t, testValue{size: 5}, old3) + assert.Equal(t, testValue{size: 15}, c.nodes[id2].value) +} + +func TestArenaRadix_GetNodeKey(t *testing.T) { + c := setupArenaRadix() + c.nodeMap = make(map[uint64]uint32) + + id1, _ := c.insertNode("a/b/c", testValue{size: 10}) + c.nodeMap[hashString("a/b/c")] = id1 + + id2, _ := c.insertNode("a/b/d", testValue{size: 20}) + c.nodeMap[hashString("a/b/d")] = id2 + + // Test getNodeKey + foundId, ok := c.getNodeKey("a/b/c") + assert.True(t, ok) + assert.Equal(t, id1, foundId) + + foundId, ok = c.getNodeKey("a/b/d") + assert.True(t, ok) + assert.Equal(t, id2, foundId) + + _, ok = c.getNodeKey("a/b/e") + assert.False(t, ok) + + // Test verifyKey directly + assert.True(t, c.verifyKey(id1, "a/b/c")) + assert.False(t, c.verifyKey(id1, "a/b/d")) + assert.False(t, c.verifyKey(id1, "a/b")) +} + +func TestArenaRadix_DeleteNodeAndCompress(t *testing.T) { + c := setupArenaRadix() + + // Insert "a/b/c.txt" + id1, _ := c.insertNode("a/b/c.txt", testValue{size: 10}) + + // Insert "a/b/d.txt" to force a split + // Tree becomes: root -> "a/b/" (routing node) -> "c.txt", "d.txt" + id2, _ := c.insertNode("a/b/d.txt", testValue{size: 20}) + + // Delete "c.txt" + c.deleteNode(id1) + + // "a/b/" now only has one child ("d.txt"). + // compressPathUpwards should automatically merge "a/b/" and "d.txt" into "a/b/d.txt" + // id2 should now be a direct child of root with the full prefix! + child1 := c.nodes[c.root].child + assert.Equal(t, id2, child1) + assert.Equal(t, "a/b/d.txt", c.nodes[id2].prefix) + assert.Equal(t, c.root, c.nodes[id2].parent) + assert.Equal(t, nilNode, c.nodes[id2].sibling) +} From 2e4a6310a7098ea3bc86bdc718f17a59ffa41e86 Mon Sep 17 00:00:00 2001 From: Vedant Das Date: Fri, 17 Jul 2026 11:40:43 +0000 Subject: [PATCH 5/8] added complete cache implementation --- internal/cache/lru/arena_radix.go | 33 +- internal/cache/lru/arena_radix_lru.go | 313 ++++++++++++ internal/cache/lru/arena_radix_test.go | 631 +++++++++++++++---------- internal/cache/lru/lru_memory_test.go | 6 +- 4 files changed, 698 insertions(+), 285 deletions(-) create mode 100644 internal/cache/lru/arena_radix_lru.go diff --git a/internal/cache/lru/arena_radix.go b/internal/cache/lru/arena_radix.go index a30765766f2..d413ec0cf67 100644 --- a/internal/cache/lru/arena_radix.go +++ b/internal/cache/lru/arena_radix.go @@ -16,7 +16,8 @@ package lru import ( "math" "strings" - "sync" + + "github.com/googlecloudplatform/gcsfuse/v3/internal/locker" ) const nilNode uint32 = math.MaxUint32 @@ -35,7 +36,9 @@ type arenaRadixNode struct { // 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 @@ -68,36 +71,14 @@ func hashString(s string) uint64 { return h } -var fullKeyPartsPool = sync.Pool{ - New: func() any { - parts := make([]string, 0, 32) - return &parts - }, -} - func (c *arenaRadix) getFullKey(nodeID uint32) string { - ptr := fullKeyPartsPool.Get().(*[]string) - parts := (*ptr)[:0] - defer func() { - *ptr = parts - fullKeyPartsPool.Put(ptr) - }() - + key := "" curr := nodeID - totalLen := 0 for curr != c.root && curr != nilNode { - prefix := c.nodes[curr].prefix - parts = append(parts, prefix) - totalLen += len(prefix) + key = c.nodes[curr].prefix + key curr = c.nodes[curr].parent } - - var sb strings.Builder - sb.Grow(totalLen) - for i := len(parts) - 1; i >= 0; i-- { - sb.WriteString(parts[i]) - } - return sb.String() + return key } func (c *arenaRadix) allocateNode() uint32 { diff --git a/internal/cache/lru/arena_radix_lru.go b/internal/cache/lru/arena_radix_lru.go new file mode 100644 index 00000000000..fb45b8700e1 --- /dev/null +++ b/internal/cache/lru/arena_radix_lru.go @@ -0,0 +1,313 @@ +// 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: Because this look up doesn't change the order, it only acquires and +// releases read lock. +func (c *arenaRadix) LookUpWithoutChangingOrder(key string) (value ValueType) { + c.mu.RLock() + defer c.mu.RUnlock() + + 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) + + // 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) + delete(c.nodeMap, hashString(c.getFullKey(currID))) + 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/arena_radix_test.go b/internal/cache/lru/arena_radix_test.go index 9d4535daa5a..32e34fed4e1 100644 --- a/internal/cache/lru/arena_radix_test.go +++ b/internal/cache/lru/arena_radix_test.go @@ -11,343 +11,462 @@ // See the License for the specific language governing permissions and // limitations under the License. -package lru +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 setupArenaRadix() *arenaRadix { - c := &arenaRadix{ - freeHead: nilNode, - } - c.root = c.allocateNode() - return c +func setupArenaRadixCacheTest(t *testing.T) lru.Cache { + locker.EnableInvariantsCheck() + return lru.NewArenaRadixCache(testMaxSize) } -func TestArenaRadix_AllocateAndFree(t *testing.T) { - c := setupArenaRadix() +func TestArenaRadixCache_LookUpInEmptyCache(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + assert.Nil(t, cache.LookUp("")) + assert.Nil(t, cache.LookUp("taco")) +} - // Initial root node is allocated - assert.Equal(t, 1, len(c.nodes)) - assert.Equal(t, nilNode, c.freeHead) +func TestArenaRadixCache_InsertNilValue(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "taco", nil, []int64{}, lru.ErrInvalidEntry) +} - // Allocate a new node - id1 := c.allocateNode() - assert.Equal(t, uint32(1), id1) - assert.Equal(t, 2, len(c.nodes)) +func TestArenaRadixCache_InsertEmptyKey(t *testing.T) { + cache := setupArenaRadixCacheTest(t) - // Free the node - c.freeNode(id1) - assert.Equal(t, id1, c.freeHead) + insertAndAssert(t, cache, "", testData{Value: 42, DataSize: 10}, []int64{}, nil) - // Allocate again, should reuse - id2 := c.allocateNode() - assert.Equal(t, id1, id2) - assert.Equal(t, nilNode, c.freeHead) + assert.Equal(t, int64(42), cache.LookUp("").(testData).Value) + assert.Nil(t, cache.LookUp("taco")) } -func setupArenaRadixChildren() (*arenaRadix, uint32, uint32, uint32) { - c := setupArenaRadix() +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) - idA := c.allocateNode() - c.nodes[idA].prefix = "apple" - - idB := c.allocateNode() - c.nodes[idB].prefix = "banana" - - idC := c.allocateNode() - c.nodes[idC].prefix = "cherry" + assert.Nil(t, cache.LookUp("")) + assert.Nil(t, cache.LookUp("enchilada")) +} - // Add out of order to ensure sorting logic executes - c.addChild(c.root, idC) - c.addChild(c.root, idA) - c.addChild(c.root, idB) +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) - return c, idA, idB, idC + 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 TestArenaRadix_AddChild(t *testing.T) { - c, idA, idB, idC := setupArenaRadixChildren() +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) - // Verify order: apple (A) -> banana (B) -> cherry (C) - child1 := c.nodes[c.root].child - assert.Equal(t, idA, child1) + // Second most recent. + insertAndAssert(t, cache, "enchilada", testData{Value: 28, DataSize: 26}, []int64{}, nil) - child2 := c.nodes[child1].sibling - assert.Equal(t, idB, child2) + assert.Equal(t, int64(23), cache.LookUp("burrito").(testData).Value) // Most recent - child3 := c.nodes[child2].sibling - assert.Equal(t, idC, child3) + // Insert another. + insertAndAssert(t, cache, "queso", testData{Value: 34, DataSize: 5}, []int64{26}, nil) - assert.Equal(t, nilNode, c.nodes[child3].sibling) - assert.Equal(t, c.root, c.nodes[idA].parent) - assert.Equal(t, c.root, c.nodes[idB].parent) - assert.Equal(t, c.root, c.nodes[idC].parent) + // 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 TestArenaRadix_GetChild(t *testing.T) { - c, idA, idB, idC := setupArenaRadixChildren() +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) - assert.Equal(t, idA, c.getChild(c.root, 'a')) - assert.Equal(t, idB, c.getChild(c.root, 'b')) - assert.Equal(t, idC, c.getChild(c.root, 'c')) - assert.Equal(t, nilNode, c.getChild(c.root, 'z')) -} + // Increase the DataSize while modifying, so eviction should happen + insertAndAssert(t, cache, "burrito", testData{Value: 33, DataSize: 12}, []int64{26}, nil) -func TestArenaRadix_ReplaceChild(t *testing.T) { - c, idA, idB, idC := setupArenaRadixChildren() + 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) +} - // Replace B with new node D ("berry") - idD := c.allocateNode() - c.nodes[idD].prefix = "berry" - c.replaceChild(c.root, idB, idD) +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) - // verify old node is completely detached - assert.Equal(t, nilNode, c.nodes[idB].sibling) - assert.Equal(t, nilNode, c.nodes[idB].parent) + // Increase the DataSize while modifying, so eviction should happen + insertAndAssert(t, cache, "large_data", testData{Value: 33, DataSize: 45}, []int64{23, 26, 28}, nil) - // verify new node is linked correctly in the middle of A and C - assert.Equal(t, idD, c.nodes[idA].sibling) - assert.Equal(t, idC, c.nodes[idD].sibling) + 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 TestArenaRadix_RemoveChild(t *testing.T) { - c, idA, idB, idC := setupArenaRadixChildren() +func TestArenaRadixCache_WhenEntrySizeMoreThanCacheMaxSize(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "burrito", testData{Value: 23, DataSize: 4}, []int64{}, nil) - // Remove A (the first child) - c.removeChild(c.root, idA) - assert.Equal(t, nilNode, c.nodes[idA].sibling) - assert.Equal(t, nilNode, c.nodes[idA].parent) - assert.Equal(t, idB, c.nodes[c.root].child) + // Insert entry with size greater than maxSize of cache. + insertAndAssert(t, cache, "taco", testData{Value: 26, DataSize: testMaxSize + 1}, []int64{}, lru.ErrInvalidEntrySize) - // Remove C (the last child) - c.removeChild(c.root, idC) - assert.Equal(t, nilNode, c.nodes[idC].sibling) - assert.Equal(t, nilNode, c.nodes[idC].parent) - assert.Equal(t, nilNode, c.nodes[idB].sibling) // B is now the last and only child + assert.Equal(t, int64(23), cache.LookUp("burrito").(testData).Value) } -func TestArenaRadix_HashString(t *testing.T) { - hash1 := hashString("hello") - hash2 := hashString("hello") - assert.Equal(t, hash1, hash2) +func TestArenaRadixCache_EraseWhenKeyPresent(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "burrito", testData{Value: 23, DataSize: 4}, []int64{}, nil) - hash3 := hashString("world") - assert.NotEqual(t, hash1, hash3) + deletedEntry := cache.Erase("burrito") - hashEmpty := hashString("") - assert.NotEqual(t, uint64(0), hashEmpty) + assert.Equal(t, int64(23), deletedEntry.(testData).Value) + assert.Nil(t, cache.LookUp("burrito")) } -func TestArenaRadix_GetFullKey(t *testing.T) { - c := setupArenaRadix() +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()) +} - id1 := c.allocateNode() - c.nodes[id1].prefix = "a/" - c.nodes[id1].parent = c.root +func TestArenaRadixCache_EraseCacheWithEmptyPrefix(t *testing.T) { + cache := setupArenaRadixCacheTest(t) - id2 := c.allocateNode() - c.nodes[id2].prefix = "b/" - c.nodes[id2].parent = id1 + 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) - id3 := c.allocateNode() - c.nodes[id3].prefix = "c.txt" - c.nodes[id3].parent = id2 + cache.EraseEntriesWithGivenPrefix("") - assert.Equal(t, "a/b/c.txt", c.getFullKey(id3)) - assert.Equal(t, "a/", c.getFullKey(id1)) - assert.Equal(t, "", c.getFullKey(c.root)) + assert.Nil(t, cache.LookUp("a")) + assert.Nil(t, cache.LookUp("a/b")) + assert.Nil(t, cache.LookUp("b")) } -type testValue struct { - size uint64 -} +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) -func (tv testValue) Size() uint64 { return tv.size } + cache.EraseEntriesWithGivenPrefix("c") -func setupArenaRadixLRU() (*arenaRadix, uint32, uint32, uint32) { - c := setupArenaRadix() - c.head = nilNode - c.tail = nilNode - c.len = 0 - c.nodeMap = make(map[uint64]uint32) + 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()) +} - id1 := c.allocateNode() - c.nodes[id1].value = testValue{size: 10} - c.nodes[id1].prefix = "node1" - c.addChild(c.root, id1) +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()) +} - id2 := c.allocateNode() - c.nodes[id2].value = testValue{size: 20} - c.nodes[id2].prefix = "node2" - c.addChild(c.root, id2) +func TestArenaRadixCache_EraseWhenKeyNotPresent(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + insertAndAssert(t, cache, "burrito", testData{Value: 23, DataSize: 4}, []int64{}, nil) - id3 := c.allocateNode() - c.nodes[id3].value = testValue{size: 30} - c.nodes[id3].prefix = "node3" - c.addChild(c.root, id3) + deletedEntry := cache.Erase("taco") - return c, id1, id2, id3 + assert.Nil(t, deletedEntry) + assert.Equal(t, int64(23), cache.LookUp("burrito").(testData).Value) } -func TestArenaRadix_PushFront(t *testing.T) { - c, id1, id2, _ := setupArenaRadixLRU() +func TestArenaRadixCache_UpdateSize(t *testing.T) { + t.Run("NonExistentKey", func(t *testing.T) { + cache := lru.NewArenaRadixCache(100) + + err := cache.UpdateSize("key1", 20) - c.pushFront(id1) - assert.Equal(t, id1, c.head) - assert.Equal(t, id1, c.tail) - assert.Equal(t, 1, c.len) + assert.ErrorIs(t, err, lru.ErrEntryNotExist) + }) - c.pushFront(id2) - assert.Equal(t, id2, c.head) - assert.Equal(t, id1, c.tail) - assert.Equal(t, id1, c.nodes[id2].next) - assert.Equal(t, id2, c.nodes[id1].prev) - assert.Equal(t, 2, c.len) + 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 TestArenaRadix_MoveToFront(t *testing.T) { - c, id1, id2, id3 := setupArenaRadixLRU() - c.pushFront(id1) - c.pushFront(id2) - c.pushFront(id3) - // Order: id3 <-> id2 <-> id1 - - c.moveToFront(id2) // Move from middle - assert.Equal(t, id2, c.head) - assert.Equal(t, id1, c.tail) - assert.Equal(t, id3, c.nodes[id2].next) - // Order: id2 <-> id3 <-> id1 - - c.moveToFront(id1) // Move from tail - assert.Equal(t, id1, c.head) - assert.Equal(t, id3, c.tail) - assert.Equal(t, id2, c.nodes[id1].next) - // Order: id1 <-> id2 <-> id3 - - c.moveToFront(id1) // Move already at head - assert.Equal(t, id1, c.head) +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 TestArenaRadix_Remove(t *testing.T) { - c, id1, id2, id3 := setupArenaRadixLRU() - c.pushFront(id1) - c.pushFront(id2) - c.pushFront(id3) - // Order: id3 <-> id2 <-> id1 - - c.remove(id2) // Remove from middle - assert.Equal(t, 2, c.len) - assert.Equal(t, id1, c.nodes[id3].next) - assert.Equal(t, id3, c.nodes[id1].prev) - assert.Equal(t, nilNode, c.nodes[id2].next) - assert.Equal(t, nilNode, c.nodes[id2].prev) - - c.remove(id3) // Remove from head - assert.Equal(t, id1, c.head) - assert.Equal(t, 1, c.len) - - c.remove(id1) // Remove from tail - assert.Equal(t, nilNode, c.head) - assert.Equal(t, nilNode, c.tail) - assert.Equal(t, 0, c.len) +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 TestArenaRadix_EvictOne(t *testing.T) { - c, id1, id2, id3 := setupArenaRadixLRU() - c.pushFront(id1) - c.pushFront(id2) - c.pushFront(id3) - // Order: id3 <-> id2 <-> id1 (id1 is tail) +func TestArenaRadixCache_UpdateWhenKeyNotPresent(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + key := "burrito" + data := testData{Value: 23, DataSize: 4} - c.currentSize = 60 - c.nodeMap[hashString("node1")] = id1 + err := cache.UpdateWithoutChangingOrder(key, data) + + assert.ErrorIs(t, err, lru.ErrEntryNotExist) +} - val := c.evictOne() - assert.Equal(t, uint64(10), val.Size()) +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} - // id1 should be removed - assert.Equal(t, 2, c.len) - assert.Equal(t, id2, c.tail) - assert.Equal(t, uint64(50), c.currentSize) + err := cache.UpdateWithoutChangingOrder(key, newData) - // verify id1 is deleted from tree and nodeMap - _, exists := c.nodeMap[hashString("node1")] - assert.False(t, exists) - assert.Nil(t, c.nodes[id1].value) + assert.ErrorIs(t, err, lru.ErrInvalidUpdateEntrySize) } -func TestArenaRadix_InsertNode(t *testing.T) { - c := setupArenaRadix() - c.nodeMap = make(map[uint64]uint32) - - // Insert "apple" - id1, old1 := c.insertNode("apple", testValue{size: 10}) - assert.Nil(t, old1) - assert.Equal(t, testValue{size: 10}, c.nodes[id1].value) - - // Insert "app" (causes a split in "apple") - id2, old2 := c.insertNode("app", testValue{size: 5}) - assert.Nil(t, old2) - assert.Equal(t, testValue{size: 5}, c.nodes[id2].value) - - // Update "app" - id3, old3 := c.insertNode("app", testValue{size: 15}) - assert.Equal(t, id2, id3) - assert.Equal(t, testValue{size: 5}, old3) - assert.Equal(t, testValue{size: 15}, c.nodes[id2].value) +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 TestArenaRadix_GetNodeKey(t *testing.T) { - c := setupArenaRadix() - c.nodeMap = make(map[uint64]uint32) +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) - id1, _ := c.insertNode("a/b/c", testValue{size: 10}) - c.nodeMap[hashString("a/b/c")] = id1 + value := cache.LookUpWithoutChangingOrder(key) - id2, _ := c.insertNode("a/b/d", testValue{size: 20}) - c.nodeMap[hashString("a/b/d")] = id2 + assert.Equal(t, int64(23), value.(testData).Value) +} - // Test getNodeKey - foundId, ok := c.getNodeKey("a/b/c") - assert.True(t, ok) - assert.Equal(t, id1, foundId) +func TestArenaRadixCache_LookUpWithoutChangingOrder_WhenKeyNotPresent(t *testing.T) { + cache := setupArenaRadixCacheTest(t) + key := "burrito" - foundId, ok = c.getNodeKey("a/b/d") - assert.True(t, ok) - assert.Equal(t, id2, foundId) + value := cache.LookUpWithoutChangingOrder(key) - _, ok = c.getNodeKey("a/b/e") - assert.False(t, ok) + assert.Nil(t, value) +} - // Test verifyKey directly - assert.True(t, c.verifyKey(id1, "a/b/c")) - assert.False(t, c.verifyKey(id1, "a/b/d")) - assert.False(t, c.verifyKey(id1, "a/b")) +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) } -func TestArenaRadix_DeleteNodeAndCompress(t *testing.T) { - c := setupArenaRadix() +// 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(7) + + go func() { + defer wg.Done() + for range testOperationCount { + _ = cache.UpdateSize("key", uint64(rand.Intn(testMaxSize))) + } + }() + + 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}) + } - // Insert "a/b/c.txt" - id1, _ := c.insertNode("a/b/c.txt", testValue{size: 10}) + var wg sync.WaitGroup + wg.Add(2) - // Insert "a/b/d.txt" to force a split - // Tree becomes: root -> "a/b/" (routing node) -> "c.txt", "d.txt" - id2, _ := c.insertNode("a/b/d.txt", testValue{size: 20}) + go func() { + defer wg.Done() + for i := 1000; i < 2000; i++ { + _, _ = c.Insert(fmt.Sprintf("dir2/file%d", i), testData{10, 10}) + } + }() - // Delete "c.txt" - c.deleteNode(id1) + go func() { + defer wg.Done() + c.EraseEntriesWithGivenPrefix("dir1/") + }() - // "a/b/" now only has one child ("d.txt"). - // compressPathUpwards should automatically merge "a/b/" and "d.txt" into "a/b/d.txt" - // id2 should now be a direct child of root with the full prefix! - child1 := c.nodes[c.root].child - assert.Equal(t, id2, child1) - assert.Equal(t, "a/b/d.txt", c.nodes[id2].prefix) - assert.Equal(t, c.root, c.nodes[id2].parent) - assert.Equal(t, nilNode, c.nodes[id2].sibling) + wg.Wait() } diff --git a/internal/cache/lru/lru_memory_test.go b/internal/cache/lru/lru_memory_test.go index 4c55c7fb2c3..e9a6ea8cf37 100644 --- a/internal/cache/lru/lru_memory_test.go +++ b/internal/cache/lru/lru_memory_test.go @@ -74,13 +74,13 @@ func (d dummyValue) Size() uint64 { return 10 } -func TestMapCacheWorkloads(t *testing.T) { +func BenchmarkMapCacheWorkloads(b *testing.B) { count := 1000000 // 1 Million files capacity := uint64(count * 1000) workloads := []string{"flat", "nested", "deeply_nested"} for _, w := range workloads { - t.Logf("=== WORKLOAD: %s (%d files) ===", w, count) + b.Logf("=== WORKLOAD: %s (%d files) ===", w, count) // 1. Pure MapLRU paths := generatePaths(w, count) @@ -95,6 +95,6 @@ func TestMapCacheWorkloads(t *testing.T) { runtime.KeepAlive(pureCache) runtime.KeepAlive(paths) - t.Logf("%-20s Heap Used: %10.2f MB\n", "MapLRU", float64(pureMem)/(1024*1024)) + b.Logf("%-20s Heap Used: %10.2f MB\n", "MapLRU", float64(pureMem)/(1024*1024)) } } From ea9b2c839803aca2792776d441ec0eae6f96ea6a Mon Sep 17 00:00:00 2001 From: Vedant Das Date: Fri, 17 Jul 2026 18:22:44 +0000 Subject: [PATCH 6/8] fixed hash collision bug --- internal/cache/lru/lru_memory_test.go | 6 ++-- .../lru/{arena_radix.go => radix_arena.go} | 28 ++++++++++++++++++- ...{arena_radix_lru.go => radix_arena_lru.go} | 3 ++ ...rena_radix_test.go => radix_arena_test.go} | 9 +----- 4 files changed, 34 insertions(+), 12 deletions(-) rename internal/cache/lru/{arena_radix.go => radix_arena.go} (95%) rename internal/cache/lru/{arena_radix_lru.go => radix_arena_lru.go} (98%) rename internal/cache/lru/{arena_radix_test.go => radix_arena_test.go} (99%) diff --git a/internal/cache/lru/lru_memory_test.go b/internal/cache/lru/lru_memory_test.go index e9a6ea8cf37..4c55c7fb2c3 100644 --- a/internal/cache/lru/lru_memory_test.go +++ b/internal/cache/lru/lru_memory_test.go @@ -74,13 +74,13 @@ func (d dummyValue) Size() uint64 { return 10 } -func BenchmarkMapCacheWorkloads(b *testing.B) { +func TestMapCacheWorkloads(t *testing.T) { count := 1000000 // 1 Million files capacity := uint64(count * 1000) workloads := []string{"flat", "nested", "deeply_nested"} for _, w := range workloads { - b.Logf("=== WORKLOAD: %s (%d files) ===", w, count) + t.Logf("=== WORKLOAD: %s (%d files) ===", w, count) // 1. Pure MapLRU paths := generatePaths(w, count) @@ -95,6 +95,6 @@ func BenchmarkMapCacheWorkloads(b *testing.B) { runtime.KeepAlive(pureCache) runtime.KeepAlive(paths) - b.Logf("%-20s Heap Used: %10.2f MB\n", "MapLRU", float64(pureMem)/(1024*1024)) + t.Logf("%-20s Heap Used: %10.2f MB\n", "MapLRU", float64(pureMem)/(1024*1024)) } } diff --git a/internal/cache/lru/arena_radix.go b/internal/cache/lru/radix_arena.go similarity index 95% rename from internal/cache/lru/arena_radix.go rename to internal/cache/lru/radix_arena.go index d413ec0cf67..31805fbedd2 100644 --- a/internal/cache/lru/arena_radix.go +++ b/internal/cache/lru/radix_arena.go @@ -252,7 +252,7 @@ func (c *arenaRadix) verifyKey(nodeID uint32, key string) bool { curr = c.nodes[curr].parent } - return end == 0 + return end == 0 && curr == c.root } // getNodeKey finds a leaf node ID by key. @@ -263,6 +263,32 @@ func (c *arenaRadix) getNodeKey(key string) (uint32, bool) { return nodeID, true } } + + curr := c.root + search := key + + for curr != nilNode { + if len(search) == 0 { + if c.nodes[curr].value != nil { + 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 } diff --git a/internal/cache/lru/arena_radix_lru.go b/internal/cache/lru/radix_arena_lru.go similarity index 98% rename from internal/cache/lru/arena_radix_lru.go rename to internal/cache/lru/radix_arena_lru.go index fb45b8700e1..909857990ac 100644 --- a/internal/cache/lru/arena_radix_lru.go +++ b/internal/cache/lru/radix_arena_lru.go @@ -260,6 +260,9 @@ func (c *arenaRadix) EraseEntriesWithGivenPrefix(prefix string) { // 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) diff --git a/internal/cache/lru/arena_radix_test.go b/internal/cache/lru/radix_arena_test.go similarity index 99% rename from internal/cache/lru/arena_radix_test.go rename to internal/cache/lru/radix_arena_test.go index 32e34fed4e1..36cf8612e9e 100644 --- a/internal/cache/lru/arena_radix_test.go +++ b/internal/cache/lru/radix_arena_test.go @@ -384,14 +384,7 @@ func TestArenaRadixCache_LookUpWithoutChangingOrder_NotChangeOrder(t *testing.T) func TestArenaRadixCache_RaceCondition(t *testing.T) { cache := setupArenaRadixCacheTest(t) var wg sync.WaitGroup - wg.Add(7) - - go func() { - defer wg.Done() - for range testOperationCount { - _ = cache.UpdateSize("key", uint64(rand.Intn(testMaxSize))) - } - }() + wg.Add(6) go func() { defer wg.Done() From 51b8316fbe413c684ea19f3c63b37bba7842444d Mon Sep 17 00:00:00 2001 From: Vedant Das Date: Fri, 17 Jul 2026 21:03:59 +0000 Subject: [PATCH 7/8] fix failing tests --- internal/cache/lru/radix_arena.go | 24 ++++++++++++--- internal/cache/lru/radix_arena_lru.go | 13 +++++--- internal/cache/metadata/stat_cache_test.go | 36 ++++++++++++++++------ 3 files changed, 54 insertions(+), 19 deletions(-) diff --git a/internal/cache/lru/radix_arena.go b/internal/cache/lru/radix_arena.go index 31805fbedd2..c2d856f07ec 100644 --- a/internal/cache/lru/radix_arena.go +++ b/internal/cache/lru/radix_arena.go @@ -71,14 +71,27 @@ func hashString(s string) uint64 { return h } -func (c *arenaRadix) getFullKey(nodeID uint32) string { - key := "" +// 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 { - key = c.nodes[curr].prefix + key + stack = append(stack, curr) curr = c.nodes[curr].parent } - return key + + 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 { @@ -270,6 +283,7 @@ func (c *arenaRadix) getNodeKey(key string) (uint32, bool) { for curr != nilNode { if len(search) == 0 { if c.nodes[curr].value != nil { + c.nodeMap[hashString(key)] = curr return curr, true } return nilNode, false @@ -411,7 +425,7 @@ func (c *arenaRadix) eraseInternal(nodeID uint32) (value ValueType) { c.currentSize -= deletedEntry.Size() // Prevent hash collision cross-deletions - hash := hashString(c.getFullKey(nodeID)) + hash := c.hashNodeKey(nodeID) if c.nodeMap[hash] == nodeID { delete(c.nodeMap, hash) } diff --git a/internal/cache/lru/radix_arena_lru.go b/internal/cache/lru/radix_arena_lru.go index 909857990ac..5ad159b7636 100644 --- a/internal/cache/lru/radix_arena_lru.go +++ b/internal/cache/lru/radix_arena_lru.go @@ -165,11 +165,11 @@ func (c *arenaRadix) LookUp(key string) (value ValueType) { // without changing the order of entries in the cache. Return nil if no value // is present. // -// Note: Because this look up doesn't change the order, it only acquires and -// releases read lock. +// 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.RLock() - defer c.mu.RUnlock() + c.mu.Lock() + defer c.mu.Unlock() nodeID, ok := c.getNodeKey(key) if !ok { @@ -289,7 +289,10 @@ func (c *arenaRadix) freeSubtree(nodeID uint32) { if c.nodes[currID].value != nil { c.currentSize -= c.nodes[currID].value.Size() c.remove(currID) - delete(c.nodeMap, hashString(c.getFullKey(currID))) + hash := c.hashNodeKey(currID) + if c.nodeMap[hash] == currID { + delete(c.nodeMap, hash) + } c.nodes[currID].value = nil } diff --git a/internal/cache/metadata/stat_cache_test.go b/internal/cache/metadata/stat_cache_test.go index 762f47a4fcf..ec91e2fcbcf 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")} } @@ -435,9 +453,9 @@ func (t *MultiBucketStatCacheTest) Test_ExpiresLeastRecentlyUsed() { // Insert another. saffron := &gcs.MinObject{Name: "saffron"} spices.Insert(saffron, expiration) // size = 1424 bytes (cumulative = 5688 bytes) - + saffron2 := &gcs.MinObject{Name: "saffron2"} - spices.Insert(saffron2, expiration) + spices.Insert(saffron2, expiration) // This will evict the least recent entry, i.e. orange. @@ -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", From 9c2779d0d84734471e1cc9ffdcec9b2d76481d7a Mon Sep 17 00:00:00 2001 From: Vedant Das Date: Tue, 21 Jul 2026 04:21:53 +0000 Subject: [PATCH 8/8] added logic for eviction on reaching uint32 limit --- internal/cache/lru/radix_arena.go | 26 ++++++-------------------- internal/cache/lru/radix_arena_lru.go | 15 ++++++++++++--- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/internal/cache/lru/radix_arena.go b/internal/cache/lru/radix_arena.go index c2d856f07ec..ce53758d1f2 100644 --- a/internal/cache/lru/radix_arena.go +++ b/internal/cache/lru/radix_arena.go @@ -38,7 +38,7 @@ type arenaRadixNode struct { type arenaRadix struct { maxSize uint64 currentSize uint64 - mu locker.RWLocker + mu locker.Locker nodes []arenaRadixNode freeHead uint32 @@ -71,27 +71,14 @@ func hashString(s string) uint64 { 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] - +func (c *arenaRadix) getFullKey(nodeID uint32) string { + key := "" curr := nodeID for curr != c.root && curr != nilNode { - stack = append(stack, curr) + key = c.nodes[curr].prefix + key 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 + return key } func (c *arenaRadix) allocateNode() uint32 { @@ -283,7 +270,6 @@ func (c *arenaRadix) getNodeKey(key string) (uint32, bool) { for curr != nilNode { if len(search) == 0 { if c.nodes[curr].value != nil { - c.nodeMap[hashString(key)] = curr return curr, true } return nilNode, false @@ -425,7 +411,7 @@ func (c *arenaRadix) eraseInternal(nodeID uint32) (value ValueType) { c.currentSize -= deletedEntry.Size() // Prevent hash collision cross-deletions - hash := c.hashNodeKey(nodeID) + hash := hashString(c.getFullKey(nodeID)) if c.nodeMap[hash] == nodeID { delete(c.nodeMap, hash) } diff --git a/internal/cache/lru/radix_arena_lru.go b/internal/cache/lru/radix_arena_lru.go index 5ad159b7636..d32d947b6b8 100644 --- a/internal/cache/lru/radix_arena_lru.go +++ b/internal/cache/lru/radix_arena_lru.go @@ -35,7 +35,7 @@ func NewArenaRadixCache(maxSize uint64) Cache { } c.root = c.allocateNode() - c.mu = locker.NewRW("ArenaRadixCache", c.checkInvariants) + c.mu = locker.New("ArenaRadixCache", c.checkInvariants) return c } @@ -114,6 +114,16 @@ func (c *arenaRadix) Insert(key string, value ValueType) ([]ValueType, error) { c.mu.Lock() defer c.mu.Unlock() + var evictedValues []ValueType + + // A single insert can allocate up to 2 nodes (one routing node, one leaf). + // If the slice has reached its physical uint32 maximum, we must rely entirely on the freelist. + // Since evicting one LRU item guarantees at least 1 leaf node (and potentially 1 routing node) + // is freed to the freelist, we proactively evict until we have at least 2 free nodes. + for uint32(len(c.nodes)) >= nilNode-2 && c.tail != nilNode && (c.freeHead == nilNode || c.nodes[c.freeHead].next == nilNode) { + evictedValues = append(evictedValues, c.evictOne()) + } + if nodeID, oldValue := c.insertNode(key, value); oldValue != nil { c.currentSize += valueSize - oldValue.Size() c.nodeMap[hashString(key)] = nodeID @@ -124,7 +134,6 @@ func (c *arenaRadix) Insert(key string, value ValueType) ([]ValueType, error) { 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()) @@ -289,7 +298,7 @@ func (c *arenaRadix) freeSubtree(nodeID uint32) { if c.nodes[currID].value != nil { c.currentSize -= c.nodes[currID].value.Size() c.remove(currID) - hash := c.hashNodeKey(currID) + hash := hashString(c.getFullKey(currID)) if c.nodeMap[hash] == currID { delete(c.nodeMap, hash) }