├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── cache.go ├── cache_benchmark_test.go ├── cache_test.go ├── example_test.go ├── go.mod ├── go.sum ├── helpers_test.go └── min-coverage /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | lint: 15 | name: Lint 16 | strategy: 17 | matrix: 18 | go: [ '1.20' ] 19 | fail-fast: true 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout Code 23 | uses: actions/checkout@v3 24 | 25 | - name: Setup Go ${{ matrix.go }} 26 | uses: actions/setup-go@v4 27 | with: 28 | go-version: ${{ matrix.go }} 29 | 30 | - name: Run GolangCI-Lint 31 | uses: golangci/golangci-lint-action@v6 32 | with: 33 | version: v1.63.4 34 | args: --timeout=5m 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | test: 15 | name: Test 16 | strategy: 17 | matrix: 18 | go: [ '1.20' ] 19 | fail-fast: true 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Go ${{ matrix.go }} 26 | uses: actions/setup-go@v4 27 | with: 28 | go-version: ${{ matrix.go }} 29 | 30 | - name: Run tests with coverage 31 | run: go test -race -cover -coverprofile="coverage.out" -covermode=atomic ./... 32 | 33 | - name: Check coverage 34 | run: | 35 | real_coverage=$(go tool cover -func=coverage.out | grep total | awk '{print substr($3, 1, length($3)-1)}') 36 | min_coverage=$(cat min-coverage) 37 | if (( $(echo "$real_coverage < $min_coverage" | bc -l) )); then 38 | echo "Coverage check failed: $real_coverage% is lower than the required $min_coverage%" 39 | exit 1 40 | else 41 | echo "Coverage check passed: $real_coverage% meets the minimum requirement of $min_coverage%" 42 | fi 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | vendor/ 3 | coverage.out 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Vasily Tsybenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LRU Tree Cache 2 | 3 | [![GoDoc Widget]][GoDoc] 4 | 5 | A hierarchical caching Go library that maintains parent-child relationships with an LRU (Least Recently Used) eviction policy. 6 | This library is designed for efficiently caching tree-structured data while maintaining relational integrity and operating within memory constraints. 7 | 8 | ## Installation 9 | 10 | ``` 11 | go get -u github.com/vasayxtx/go-lrutree 12 | ``` 13 | 14 | ## Key Features 15 | 16 | + **Hierarchical Structure**: Maintains parent-child relationships in a tree structure 17 | + **LRU Eviction Policy**: Automatically removes the least recently used leaf nodes when the maximum size is reached 18 | + **Memory-Constrained Caching**: Ideal for caching tree-structured data with limited memory 19 | + **Type Safety**: Built with Go generics for strong type safety 20 | + **Concurrent Access**: Thread-safe implementation 21 | + **Efficient Traversal**: Methods to traverse up to root or down through subtrees 22 | + **Integrity Guarantee**: Ensures a node's ancestors are always present in the cache 23 | 24 | ## Use Cases 25 | 26 | LRU Tree Cache is particularly useful for: 27 | 28 | + Hierarchical Data Caching: Organizations, file systems, taxonomies 29 | + Access Control Systems: Caching permission hierarchies 30 | + Geo-Data: Caching location hierarchies (country -> state -> city -> district) 31 | + E-commerce: Product categories and subcategories 32 | 33 | ## Usage 34 | 35 | ```go 36 | package main 37 | 38 | import ( 39 | "fmt" 40 | 41 | "github.com/vasayxtx/go-lrutree" 42 | ) 43 | 44 | type OrgItem struct { 45 | Name string 46 | } 47 | 48 | func main() { 49 | // Create a new cache with a maximum size of 4 entries and an eviction callback. 50 | cache := lrutree.NewCache[string, OrgItem](4, lrutree.WithOnEvict(func(node lrutree.CacheNode[string, OrgItem]) { 51 | fmt.Printf("Evicted: %s (key=%s, parent=%s)\n", node.Value.Name, node.Key, node.ParentKey) 52 | })) 53 | 54 | // Add nodes to the cache. 55 | _ = cache.AddRoot("company", OrgItem{"My Company"}) 56 | _ = cache.Add("engineering", OrgItem{"Engineering department"}, "company") 57 | _ = cache.Add("frontend", OrgItem{"Frontend team"}, "engineering") 58 | _ = cache.Add("backend", OrgItem{"Backend team"}, "engineering") 59 | 60 | // Get the value by key. 61 | // "frontend" node and all its ancestors ("engineering" and "company" nodes) are marked as recently used. 62 | if cacheNode, ok := cache.Get("frontend"); ok { 63 | fmt.Printf("Get: %s (key=%s, parent=%s)\n", cacheNode.Value.Name, cacheNode.Key, cacheNode.ParentKey) 64 | // Output: Get: Frontend team (key=frontend, parent=engineering) 65 | } 66 | 67 | // Get the full branch from the root to the node with key "backend". 68 | // "backend", "engineering", and "company" nodes are marked as recently used. 69 | branch := cache.GetBranch("backend") 70 | for i, node := range branch { 71 | fmt.Printf("GetBranch[%d]: %s (key=%s, parent=%s)\n", i, node.Value.Name, node.Key, node.ParentKey) 72 | } 73 | // Output: 74 | // GetBranch[0]: My Company (key=company, parent=) 75 | // GetBranch[1]: Engineering department (key=engineering, parent=company) 76 | // GetBranch[2]: Backend team (key=backend, parent=engineering) 77 | 78 | // Peek the value by key without updating the LRU order. 79 | if cacheNode, ok := cache.Peek("frontend"); ok { 80 | fmt.Printf("Peek: %s (key=%s, parent=%s)\n", cacheNode.Value.Name, cacheNode.Key, cacheNode.ParentKey) 81 | // Output: Peek: Frontend team (key=frontend, parent=engineering) 82 | } 83 | 84 | // Add a new node exceeding the cache's maximum size. 85 | // The least recently used leaf node ("frontend") is evicted. 86 | _ = cache.Add("architects", OrgItem{"Architects team"}, "engineering") 87 | // Output: Evicted: Frontend team (key=frontend, parent=engineering) 88 | } 89 | ``` 90 | 91 | More advanced usage example can be found in [example_test.go](./example_test.go). 92 | 93 | ## Behavior Notes 94 | 95 | + When a node is accessed, it and all its ancestors are marked as recently used. 96 | + The cache enforces a strict maximum size, automatically evicting the least recently used leaf nodes when the limit is reached. 97 | + When eviction occurs, only leaf nodes (nodes without children) can be removed. 98 | + The cache guarantees that if a node exists, all its ancestors up to the root also exist. 99 | 100 | ## Performance Considerations 101 | 102 | + The cache uses mutex locks for thread safety, which can impact performance under high concurrency. 103 | + For write-heavy workloads, consider using multiple caches with sharding. 104 | 105 | ### Benchmarking 106 | 107 | The package includes [benchmarks](./cache_benchmark_test.go) to evaluate the performance of the LRU tree cache under various conditions. 108 | The following results were obtained on Apple M1 Max with Go 1.20: 109 | ``` 110 | BenchmarkCache_Get/depth=5/elements=50001-10 13252944 89.38 ns/op 111 | BenchmarkCache_Get/depth=10/elements=100001-10 5915546 201.7 ns/op 112 | BenchmarkCache_Get/depth=50/elements=500001-10 847310 1331 ns/op 113 | BenchmarkCache_Peek/depth=5/elements=50001-10 15514975 78.23 ns/op 114 | BenchmarkCache_Peek/depth=10/elements=100001-10 14090122 95.83 ns/op 115 | BenchmarkCache_Peek/depth=50/elements=500001-10 7754596 200.7 ns/op 116 | BenchmarkCache_GetBranch/depth=5/elements=50001-10 7374289 161.4 ns/op 117 | BenchmarkCache_GetBranch/depth=10/elements=100001-10 3946384 318.8 ns/op 118 | BenchmarkCache_GetBranch/depth=50/elements=500001-10 620029 2325 ns/op 119 | BenchmarkCache_PeekBranch/depth=5/elements=50001-10 6649334 188.6 ns/op 120 | BenchmarkCache_PeekBranch/depth=10/elements=100001-10 3969432 305.5 ns/op 121 | BenchmarkCache_PeekBranch/depth=50/elements=500001-10 578042 2308 ns/op 122 | BenchmarkCache_Add/depth=5-10 1631062 698.9 ns/op 123 | BenchmarkCache_Add/depth=10-10 1587628 734.6 ns/op 124 | BenchmarkCache_Add/depth=50-10 769306 1714 ns/op 125 | 126 | BenchmarkCache_Get_Concurrent/depth=5/goroutines=32-10 5557310 195.9 ns/op 127 | BenchmarkCache_Get_Concurrent/depth=5/goroutines=64-10 5450746 208.9 ns/op 128 | BenchmarkCache_Get_Concurrent/depth=5/goroutines=128-10 4809602 226.0 ns/op 129 | BenchmarkCache_Get_Concurrent/depth=10/goroutines=32-10 4208766 288.0 ns/op 130 | BenchmarkCache_Get_Concurrent/depth=10/goroutines=64-10 4313493 284.3 ns/op 131 | BenchmarkCache_Get_Concurrent/depth=10/goroutines=128-10 4019263 316.7 ns/op 132 | BenchmarkCache_Get_Concurrent/depth=50/goroutines=32-10 849674 1223 ns/op 133 | BenchmarkCache_Get_Concurrent/depth=50/goroutines=64-10 1033851 1174 ns/op 134 | BenchmarkCache_Get_Concurrent/depth=50/goroutines=128-10 950826 1231 ns/op 135 | BenchmarkCache_Peek_Concurrent/depth=5/goroutines=32-10 6772576 179.3 ns/op 136 | BenchmarkCache_Peek_Concurrent/depth=5/goroutines=64-10 6705830 177.4 ns/op 137 | BenchmarkCache_Peek_Concurrent/depth=5/goroutines=128-10 8474424 151.9 ns/op 138 | BenchmarkCache_Peek_Concurrent/depth=10/goroutines=32-10 7283060 142.4 ns/op 139 | BenchmarkCache_Peek_Concurrent/depth=10/goroutines=64-10 6966217 170.5 ns/op 140 | BenchmarkCache_Peek_Concurrent/depth=10/goroutines=128-10 6767965 150.1 ns/op 141 | BenchmarkCache_Peek_Concurrent/depth=50/goroutines=32-10 7615438 134.0 ns/op 142 | BenchmarkCache_Peek_Concurrent/depth=50/goroutines=64-10 9349428 158.0 ns/op 143 | BenchmarkCache_Peek_Concurrent/depth=50/goroutines=128-10 7689594 156.4 ns/op 144 | ``` 145 | 146 | ## License 147 | 148 | MIT License - see [LICENSE](./LICENSE) file for details. 149 | 150 | [GoDoc]: https://pkg.go.dev/github.com/vasayxtx/go-lrutree 151 | [GoDoc Widget]: https://godoc.org/github.com/vasayxtx/go-lrutree?status.svg -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package lrutree 2 | 3 | import ( 4 | "container/list" 5 | "errors" 6 | "sync" 7 | ) 8 | 9 | var ( 10 | ErrRootAlreadyExists = errors.New("root node already exists") 11 | ErrParentNotExist = errors.New("parent node does not exist") 12 | ErrAlreadyExists = errors.New("node already exists") 13 | ErrCycleDetected = errors.New("cycle detected") 14 | ) 15 | 16 | // StatsCollector is an interface for collecting cache metrics and statistics. 17 | type StatsCollector interface { 18 | // SetAmount sets the total number of entries in the cache. 19 | SetAmount(int) 20 | 21 | // IncHits increments the total number of successfully found keys in the cache. 22 | IncHits() 23 | 24 | // IncMisses increments the total number of not found keys in the cache. 25 | IncMisses() 26 | 27 | // AddEvictions increments the total number of evicted entries. 28 | AddEvictions(int) 29 | } 30 | 31 | // Cache is a hierarchical cache with LRU (Least Recently Used) eviction policy. 32 | // 33 | // It maintains parent-child relationships between nodes in a tree structure while 34 | // ensuring that parent nodes remain in cache as long as their children exist. 35 | // 36 | // Key properties: 37 | // - Each node has a unique key and is identified by that key. 38 | // - Nodes form a tree structure with parent-child relationships. 39 | // - Each node (except root) has exactly one parent and possibly multiple children. 40 | // - When a node is accessed, both it and all its ancestors are marked as recently used. 41 | // - When eviction occurs, the least recently used node is removed. It guarantees the evicted node is a leaf. 42 | // - If a node is present in the cache, all its ancestors up to the root are guaranteed to be present. 43 | // 44 | // This cache is particularly useful for hierarchical data where accessing a child 45 | // implies that its ancestors are also valuable and should remain in cache. 46 | type Cache[K comparable, V any] struct { 47 | maxEntries int 48 | onEvict func(node CacheNode[K, V]) 49 | stats StatsCollector 50 | mu sync.RWMutex 51 | keysMap map[K]*treeNode[K, V] 52 | lruList *list.List 53 | root *treeNode[K, V] 54 | } 55 | 56 | // CacheNode represents a node in the cache with its key, value, and parent key. 57 | // It is used to return node information from the Cache methods. 58 | type CacheNode[K comparable, V any] struct { 59 | Key K 60 | Value V 61 | ParentKey K 62 | } 63 | 64 | type treeNode[K comparable, V any] struct { 65 | key K 66 | val V 67 | parent *treeNode[K, V] 68 | children map[K]*treeNode[K, V] 69 | lruElem *list.Element 70 | } 71 | 72 | func newTreeNode[K comparable, V any](key K, val V, parent *treeNode[K, V]) *treeNode[K, V] { 73 | return &treeNode[K, V]{ 74 | key: key, 75 | val: val, 76 | parent: parent, 77 | children: make(map[K]*treeNode[K, V]), 78 | } 79 | } 80 | 81 | func (n *treeNode[K, V]) removeFromParent() { 82 | if n.parent == nil { 83 | return 84 | } 85 | delete(n.parent.children, n.key) 86 | n.parent = nil 87 | } 88 | 89 | func (n *treeNode[K, V]) parentKey() K { 90 | if n.parent != nil { 91 | return n.parent.key 92 | } 93 | var zeroKey K 94 | return zeroKey 95 | } 96 | 97 | type CacheOption[K comparable, V any] func(*Cache[K, V]) 98 | 99 | func WithOnEvict[K comparable, V any](onEvict func(node CacheNode[K, V])) CacheOption[K, V] { 100 | return func(c *Cache[K, V]) { 101 | c.onEvict = onEvict 102 | } 103 | } 104 | 105 | // WithStatsCollector sets a stats collector for the cache. 106 | func WithStatsCollector[K comparable, V any](stats StatsCollector) CacheOption[K, V] { 107 | return func(c *Cache[K, V]) { 108 | c.stats = stats 109 | } 110 | } 111 | 112 | // NewCache creates a new cache with the given maximum number of entries and eviction callback. 113 | func NewCache[K comparable, V any](maxEntries int, options ...CacheOption[K, V]) *Cache[K, V] { 114 | c := &Cache[K, V]{ 115 | maxEntries: maxEntries, 116 | keysMap: make(map[K]*treeNode[K, V]), 117 | lruList: list.New(), 118 | stats: nullStats{}, // Use null object by default 119 | } 120 | for _, opt := range options { 121 | opt(c) 122 | } 123 | return c 124 | } 125 | 126 | // Peek returns the value of the node with the given key without updating the LRU order. 127 | // 128 | // This is useful for checking if a value exists without affecting its position in the eviction order. 129 | // Unlike Get(), this method doesn't mark the node as recently used. 130 | func (c *Cache[K, V]) Peek(key K) (CacheNode[K, V], bool) { 131 | c.mu.RLock() 132 | defer c.mu.RUnlock() 133 | 134 | node, exists := c.keysMap[key] 135 | if !exists { 136 | c.stats.IncMisses() 137 | return CacheNode[K, V]{}, false 138 | } 139 | 140 | c.stats.IncHits() 141 | return CacheNode[K, V]{Key: key, Value: node.val, ParentKey: node.parentKey()}, true 142 | } 143 | 144 | // Get retrieves a value from the cache and updates LRU order. 145 | // 146 | // This method has a side effect of marking the node and all its ancestors as recently used, 147 | // moving them to the front of the LRU list and protecting them from immediate eviction. 148 | func (c *Cache[K, V]) Get(key K) (CacheNode[K, V], bool) { 149 | c.mu.Lock() 150 | defer c.mu.Unlock() 151 | 152 | node, exists := c.keysMap[key] 153 | if !exists { 154 | c.stats.IncMisses() 155 | return CacheNode[K, V]{}, false 156 | } 157 | 158 | // Update LRU order for the node and all its ancestors. 159 | for n := node; n != nil; n = n.parent { 160 | c.lruList.MoveToFront(n.lruElem) 161 | } 162 | 163 | c.stats.IncHits() 164 | return CacheNode[K, V]{Key: key, Value: node.val, ParentKey: node.parentKey()}, true 165 | } 166 | 167 | // Len returns the number of items currently stored in the cache. 168 | func (c *Cache[K, V]) Len() int { 169 | c.mu.RLock() 170 | defer c.mu.RUnlock() 171 | 172 | return len(c.keysMap) 173 | } 174 | 175 | // AddRoot initializes the cache with a root node. 176 | // 177 | // The root node serves as the ancestor for all other nodes in the cache. 178 | // Only one root node is allowed per cache instance. 179 | // Attempting to add a second root will result in an error. 180 | func (c *Cache[K, V]) AddRoot(key K, val V) error { 181 | c.mu.Lock() 182 | defer c.mu.Unlock() 183 | 184 | if c.root != nil { 185 | return ErrRootAlreadyExists 186 | } 187 | c.root = newTreeNode(key, val, nil) 188 | c.root.lruElem = c.lruList.PushFront(c.root) 189 | c.keysMap[key] = c.root 190 | 191 | c.stats.SetAmount(len(c.keysMap)) 192 | return nil 193 | } 194 | 195 | // Add inserts a new node into the cache as a child of the specified parent. 196 | // 197 | // This method creates a parent-child relationship between the new node and 198 | // the existing parent node. It also updates the LRU order of the parent chain 199 | // to protect ancestors from eviction. If adding the new node exceeds the cache 200 | // capacity, the least recently used node will be evicted. 201 | // 202 | // If parentKey is not found in the cache, ErrParentNotExist is returned. 203 | // If the node with the given key already exists, ErrAlreadyExists is returned. 204 | func (c *Cache[K, V]) Add(key K, val V, parentKey K) error { 205 | var evictedNode CacheNode[K, V] 206 | var evicted bool 207 | defer func() { 208 | if evicted && c.onEvict != nil { 209 | c.onEvict(evictedNode) 210 | } 211 | }() 212 | 213 | c.mu.Lock() 214 | defer c.mu.Unlock() 215 | 216 | parent, parentExists := c.keysMap[parentKey] 217 | if !parentExists { 218 | return ErrParentNotExist 219 | } 220 | 221 | if _, exists := c.keysMap[key]; exists { 222 | return ErrAlreadyExists 223 | } 224 | 225 | node := newTreeNode(key, val, parent) 226 | c.keysMap[key] = node 227 | node.lruElem = c.lruList.PushFront(node) 228 | parent.children[key] = node 229 | 230 | for n := node.parent; n != nil; n = n.parent { 231 | c.lruList.MoveToFront(n.lruElem) 232 | } 233 | 234 | if c.maxEntries > 0 && c.lruList.Len() > c.maxEntries { 235 | evictedNode, evicted = c.evict() 236 | } 237 | 238 | c.stats.SetAmount(len(c.keysMap)) 239 | 240 | return nil 241 | } 242 | 243 | // AddOrUpdate adds a new node or updates an existing node in the cache. 244 | // 245 | // This method is more flexible than Add() because it handles both insertion and 246 | // update scenarios. If the node already exists, it can be reparented to a new parent 247 | // and its value can be updated. This method includes cycle detection to prevent 248 | // creating loops in the tree structure (ErrCycleDetected is returned in such cases). 249 | // If parentKey is not found in the cache, ErrParentNotExist is returned. 250 | func (c *Cache[K, V]) AddOrUpdate(key K, val V, parentKey K) error { 251 | var evictedNode CacheNode[K, V] 252 | var evicted bool 253 | defer func() { 254 | if evicted && c.onEvict != nil { 255 | c.onEvict(evictedNode) 256 | } 257 | }() 258 | 259 | c.mu.Lock() 260 | defer c.mu.Unlock() 261 | 262 | parent, parentExists := c.keysMap[parentKey] 263 | if !parentExists { 264 | return ErrParentNotExist 265 | } 266 | 267 | node, exists := c.keysMap[key] 268 | if exists { 269 | if node.parent != parent { 270 | // We need to check for cycles before moving the node to the new parent. 271 | for par := parent; par != nil; par = par.parent { 272 | if par == node { 273 | return ErrCycleDetected 274 | } 275 | } 276 | // Before updating the parent, remove the node from the current parent's children. 277 | node.removeFromParent() 278 | node.parent = parent 279 | parent.children[key] = node 280 | } 281 | node.val = val 282 | c.lruList.MoveToFront(node.lruElem) 283 | } else { 284 | // Add the new node to the cache. 285 | node = newTreeNode(key, val, parent) 286 | c.keysMap[key] = node 287 | node.lruElem = c.lruList.PushFront(node) 288 | parent.children[key] = node 289 | } 290 | 291 | for n := node.parent; n != nil; n = n.parent { 292 | c.lruList.MoveToFront(n.lruElem) 293 | } 294 | 295 | if c.maxEntries > 0 && c.lruList.Len() > c.maxEntries { 296 | evictedNode, evicted = c.evict() 297 | } 298 | 299 | c.stats.SetAmount(len(c.keysMap)) 300 | 301 | return nil 302 | } 303 | 304 | // PeekBranch returns the path from the root to the specified key as a slice of CacheNodes 305 | // without updating the LRU order. 306 | // 307 | // The returned slice is ordered from root (index 0) to the target node (last index). 308 | // If the key does not exist, an empty slice is returned. 309 | // Unlike GetBranch(), this method doesn't mark the nodes as recently used. 310 | func (c *Cache[K, V]) PeekBranch(key K) []CacheNode[K, V] { 311 | c.mu.RLock() 312 | defer c.mu.RUnlock() 313 | 314 | node, exists := c.keysMap[key] 315 | if !exists { 316 | c.stats.IncMisses() 317 | return nil 318 | } 319 | 320 | depth := 0 321 | for n := node; n != nil; n = n.parent { 322 | depth++ 323 | } 324 | branch := make([]CacheNode[K, V], depth) 325 | i := depth 326 | for n := node; n != nil; n = n.parent { 327 | i-- 328 | branch[i] = CacheNode[K, V]{Key: n.key, Value: n.val, ParentKey: n.parentKey()} 329 | } 330 | 331 | c.stats.IncHits() 332 | 333 | return branch 334 | } 335 | 336 | // GetBranch returns the path from the root to the specified key as a slice of BranchNodes. 337 | // 338 | // The returned slice is ordered from root (index 0) to the target node (last index). 339 | // If the key does not exist, an empty slice is returned. 340 | // Method updates LRU order for all nodes in the branch. 341 | func (c *Cache[K, V]) GetBranch(key K) []CacheNode[K, V] { 342 | c.mu.Lock() 343 | defer c.mu.Unlock() 344 | 345 | node, exists := c.keysMap[key] 346 | if !exists { 347 | c.stats.IncMisses() 348 | return nil 349 | } 350 | 351 | depth := 0 352 | for n := node; n != nil; n = n.parent { 353 | depth++ 354 | } 355 | branch := make([]CacheNode[K, V], depth) 356 | i := depth 357 | for n := node; n != nil; n = n.parent { 358 | i-- 359 | branch[i] = CacheNode[K, V]{Key: n.key, Value: n.val, ParentKey: n.parentKey()} 360 | c.lruList.MoveToFront(n.lruElem) 361 | } 362 | 363 | c.stats.IncHits() 364 | 365 | return branch 366 | } 367 | 368 | // TraverseToRoot walks the path from the specified node up to the root node, 369 | // calling the provided function for each node along the way. 370 | // 371 | // This method traverses the ancestor chain starting from the given node and 372 | // proceeding upward to the root. Each node visited is marked as recently used. 373 | // The provided callback function receives the node's key, value, and its parent's key. 374 | // 375 | // Note: This operation is performed under a lock and will block other cache operations. 376 | // The callback should execute quickly to avoid holding the lock for too long. 377 | func (c *Cache[K, V]) TraverseToRoot(key K, f func(key K, val V, parentKey K)) { 378 | c.mu.Lock() 379 | defer c.mu.Unlock() 380 | 381 | node, exists := c.keysMap[key] 382 | if !exists { 383 | c.stats.IncMisses() 384 | return 385 | } 386 | 387 | defer func() { 388 | // We need to update LRU in defer to ensure that the order is correct even if f panics. 389 | for n := node; n != nil; n = n.parent { 390 | c.lruList.MoveToFront(n.lruElem) 391 | } 392 | }() 393 | 394 | for n := node; n != nil; n = n.parent { 395 | var parentKey K 396 | if n.parent != nil { 397 | parentKey = n.parent.key 398 | } 399 | f(n.key, n.val, parentKey) 400 | } 401 | 402 | c.stats.IncHits() 403 | } 404 | 405 | // TraverseSubtreeOption represents options for the TraverseSubtree method. 406 | type TraverseSubtreeOption func(*traverseOptions) 407 | 408 | // WithMaxDepth limits the depth of traversal in TraverseSubtree. 409 | // A depth of -1 means unlimited depth (traverse the entire subtree). 410 | // A depth of 0 means only the specified node (no children). 411 | // A depth of 1 means the node and its immediate children, and so on. 412 | func WithMaxDepth(depth int) TraverseSubtreeOption { 413 | return func(opts *traverseOptions) { 414 | opts.maxDepth = depth 415 | } 416 | } 417 | 418 | type traverseOptions struct { 419 | maxDepth int // -1 means unlimited 420 | } 421 | 422 | // TraverseSubtree performs a depth-first traversal of all nodes in the subtree 423 | // rooted at the specified node, with optional depth limitation. 424 | // 425 | // This method visits the specified node and all its descendants in a pre-order depth-first traversal. 426 | // Each node visited is marked as recently used. 427 | // The provided callback function receives the node's key, value, and its parent's key. 428 | // 429 | // Options: 430 | // - WithMaxDepth(n): Limits traversal to n levels deep. 431 | // 432 | // Note: This operation is performed under a lock and will block other cache operations. 433 | // For large subtrees, this can have performance implications. 434 | func (c *Cache[K, V]) TraverseSubtree(key K, f func(key K, val V, parentKey K), options ...TraverseSubtreeOption) { 435 | c.mu.Lock() 436 | defer c.mu.Unlock() 437 | 438 | node, exists := c.keysMap[key] 439 | if !exists { 440 | c.stats.IncMisses() 441 | return 442 | } 443 | 444 | opts := traverseOptions{ 445 | maxDepth: -1, // Default: unlimited depth 446 | } 447 | for _, opt := range options { 448 | opt(&opts) 449 | } 450 | 451 | defer func() { 452 | // We need to update LRU in defer to ensure that the order is correct even if f panics. 453 | for n := node.parent; n != nil; n = n.parent { 454 | c.lruList.MoveToFront(n.lruElem) 455 | } 456 | }() 457 | 458 | var traverse func(n *treeNode[K, V], currentDepth int) 459 | traverse = func(n *treeNode[K, V], currentDepth int) { 460 | defer c.lruList.MoveToFront(n.lruElem) 461 | var parentKey K 462 | if n.parent != nil { 463 | parentKey = n.parent.key 464 | } 465 | f(n.key, n.val, parentKey) 466 | 467 | // Check if we need to continue traversing deeper 468 | if opts.maxDepth >= 0 && currentDepth >= opts.maxDepth { 469 | return 470 | } 471 | 472 | for _, child := range n.children { 473 | traverse(child, currentDepth+1) 474 | } 475 | } 476 | traverse(node, 0) // Start at depth 0 (root of subtree) 477 | 478 | c.stats.IncHits() 479 | } 480 | 481 | // Remove deletes a node and all its descendants from the cache. 482 | // 483 | // This method performs a recursive removal of the specified node and its entire subtree. 484 | // It returns the total number of nodes removed from the cache. 485 | func (c *Cache[K, V]) Remove(key K) (removedCount int) { 486 | c.mu.Lock() 487 | defer c.mu.Unlock() 488 | 489 | node, exists := c.keysMap[key] 490 | if !exists { 491 | return 0 492 | } 493 | 494 | var removeRecursively func(n *treeNode[K, V]) 495 | removeRecursively = func(n *treeNode[K, V]) { 496 | delete(c.keysMap, n.key) 497 | n.parent = nil 498 | removedCount++ 499 | c.lruList.Remove(n.lruElem) 500 | for _, child := range n.children { 501 | removeRecursively(child) 502 | } 503 | n.children = nil 504 | } 505 | removeRecursively(node) 506 | 507 | node.removeFromParent() 508 | 509 | c.stats.SetAmount(len(c.keysMap)) 510 | 511 | return removedCount 512 | } 513 | 514 | func (c *Cache[K, V]) evict() (CacheNode[K, V], bool) { 515 | tailElem := c.lruList.Back() 516 | if tailElem == nil { 517 | return CacheNode[K, V]{}, false 518 | } 519 | 520 | c.lruList.Remove(tailElem) 521 | node := tailElem.Value.(*treeNode[K, V]) 522 | parentKey := node.parentKey() 523 | delete(c.keysMap, node.key) 524 | node.removeFromParent() 525 | 526 | return CacheNode[K, V]{Key: node.key, Value: node.val, ParentKey: parentKey}, true 527 | } 528 | 529 | // nullStats is a null object implementation of the StatsCollector interface. 530 | type nullStats struct{} 531 | 532 | func (ns nullStats) SetAmount(int) {} 533 | func (ns nullStats) IncHits() {} 534 | func (ns nullStats) IncMisses() {} 535 | func (ns nullStats) AddEvictions(int) {} 536 | -------------------------------------------------------------------------------- /cache_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package lrutree 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "testing" 7 | ) 8 | 9 | func BenchmarkCache_Get(b *testing.B) { 10 | const chainsNum = 10_000 11 | depths := []int{5, 10, 50} // root is the 1st level 12 | for _, depth := range depths { 13 | cache, leaves := generateTreeForBench(b, depth, chainsNum, 0) 14 | b.Run(fmt.Sprintf("depth=%d/elements=%d", depth, depth*chainsNum+1), func(b *testing.B) { 15 | for i := 0; i < b.N; i++ { 16 | nodeIdx := i % len(leaves) 17 | key := leaves[nodeIdx] 18 | if _, found := cache.Get(key); !found { 19 | b.Fatalf("key %s not found in cache", key) 20 | } 21 | } 22 | }) 23 | } 24 | } 25 | 26 | func BenchmarkCache_Peek(b *testing.B) { 27 | const chainsNum = 10_000 28 | depths := []int{5, 10, 50} // root is the 1st level 29 | for _, depth := range depths { 30 | cache, leaves := generateTreeForBench(b, depth, chainsNum, 0) 31 | b.Run(fmt.Sprintf("depth=%d/elements=%d", depth, depth*chainsNum+1), func(b *testing.B) { 32 | for i := 0; i < b.N; i++ { 33 | nodeIdx := i % len(leaves) 34 | key := leaves[nodeIdx] 35 | if _, found := cache.Peek(key); !found { 36 | b.Fatalf("key %s not found in cache", key) 37 | } 38 | } 39 | }) 40 | } 41 | } 42 | 43 | func BenchmarkCache_GetBranch(b *testing.B) { 44 | const chainsNum = 10_000 45 | depths := []int{5, 10, 50} // root is the 1st level 46 | for _, depth := range depths { 47 | cache, leaves := generateTreeForBench(b, depth, chainsNum, 0) 48 | b.Run(fmt.Sprintf("depth=%d/elements=%d", depth, depth*chainsNum+1), func(b *testing.B) { 49 | for i := 0; i < b.N; i++ { 50 | nodeIdx := i % len(leaves) 51 | key := leaves[nodeIdx] 52 | if branch := cache.GetBranch(key); len(branch) != depth { 53 | b.Fatalf("branch length %d, expected %d", len(branch), depth) 54 | } 55 | } 56 | }) 57 | } 58 | } 59 | 60 | func BenchmarkCache_PeekBranch(b *testing.B) { 61 | const chainsNum = 10_000 62 | depths := []int{5, 10, 50} // root is the 1st level 63 | for _, depth := range depths { 64 | cache, leaves := generateTreeForBench(b, depth, chainsNum, 0) 65 | b.Run(fmt.Sprintf("depth=%d/elements=%d", depth, depth*chainsNum+1), func(b *testing.B) { 66 | for i := 0; i < b.N; i++ { 67 | nodeIdx := i % len(leaves) 68 | key := leaves[nodeIdx] 69 | if branch := cache.PeekBranch(key); len(branch) != depth { 70 | b.Fatalf("branch length %d, expected %d", len(branch), depth) 71 | } 72 | } 73 | }) 74 | } 75 | } 76 | 77 | func BenchmarkCache_Add(b *testing.B) { 78 | const chainsNum = 10_000 79 | depths := []int{5, 10, 50} // root is the 1st level 80 | for _, depth := range depths { 81 | b.Run(fmt.Sprintf("depth=%d", depth), func(b *testing.B) { 82 | // Create a cache with large enough capacity to avoid evictions 83 | cache, parentNodes := generateTreeForBench(b, depth, chainsNum, (depth*chainsNum+1)+b.N) 84 | 85 | initialSize := cache.Len() 86 | b.ResetTimer() 87 | for i := 0; i < b.N; i++ { 88 | chainIdx := i % chainsNum 89 | parentKey := parentNodes[chainIdx] 90 | newKey := fmt.Sprintf("bench-node-%d-%d", chainIdx+1, i) 91 | newVal := i 92 | if err := cache.Add(newKey, newVal, parentKey); err != nil { 93 | b.Fatalf("Failed to add node %s: %v", newKey, err) 94 | } 95 | } 96 | 97 | // Verify no evictions occurred 98 | if cache.Len() != initialSize+b.N { 99 | b.Fatalf("Unexpected cache size. Expected %d, got %d", 100 | initialSize+b.N, cache.Len()) 101 | } 102 | }) 103 | } 104 | } 105 | 106 | func BenchmarkCache_Get_Concurrent(b *testing.B) { 107 | const chainsNum = 10_000 108 | depths := []int{5, 10, 50} // root is the 1st level 109 | goroutineCounts := []int{32, 64, 128} 110 | for _, depth := range depths { 111 | cache, leaves := generateTreeForBench(b, depth, chainsNum, 0) 112 | for _, numGoroutines := range goroutineCounts { 113 | b.Run(fmt.Sprintf("depth=%d/goroutines=%d", depth, numGoroutines), func(b *testing.B) { 114 | opsPerGoroutine := b.N / numGoroutines 115 | var wg sync.WaitGroup 116 | wg.Add(numGoroutines) 117 | b.ResetTimer() 118 | for g := 0; g < numGoroutines; g++ { 119 | go func(goroutineID int) { 120 | defer wg.Done() 121 | for i := 0; i < opsPerGoroutine; i++ { 122 | nodeIdx := (goroutineID*opsPerGoroutine + i) % len(leaves) 123 | key := leaves[nodeIdx] 124 | if _, found := cache.Get(key); !found { 125 | // Using panic instead of b.Fatalf because b.Fatalf isn't goroutine-safe 126 | panic(fmt.Sprintf("key %s not found in cache", key)) 127 | } 128 | } 129 | }(g) 130 | } 131 | wg.Wait() 132 | }) 133 | } 134 | } 135 | } 136 | 137 | func BenchmarkCache_Peek_Concurrent(b *testing.B) { 138 | const chainsNum = 10_000 139 | depths := []int{5, 10, 50} // root is the 1st level 140 | goroutineCounts := []int{32, 64, 128} 141 | for _, depth := range depths { 142 | cache, leaves := generateTreeForBench(b, depth, chainsNum, 0) 143 | for _, numGoroutines := range goroutineCounts { 144 | b.Run(fmt.Sprintf("depth=%d/goroutines=%d", depth, numGoroutines), func(b *testing.B) { 145 | opsPerGoroutine := b.N / numGoroutines 146 | var wg sync.WaitGroup 147 | wg.Add(numGoroutines) 148 | b.ResetTimer() 149 | for g := 0; g < numGoroutines; g++ { 150 | go func(goroutineID int) { 151 | defer wg.Done() 152 | for i := 0; i < opsPerGoroutine; i++ { 153 | nodeIdx := (goroutineID*opsPerGoroutine + i) % len(leaves) 154 | key := leaves[nodeIdx] 155 | if _, found := cache.Peek(key); !found { 156 | // Using panic instead of b.Fatalf because b.Fatalf isn't goroutine-safe 157 | panic(fmt.Sprintf("key %s not found in cache", key)) 158 | } 159 | } 160 | }(g) 161 | } 162 | wg.Wait() 163 | }) 164 | } 165 | } 166 | } 167 | 168 | // generateTreeForBench creates a tree with multiple linear chains from a common root. 169 | // 170 | // Tree structure: 171 | // This function generates a tree with multiple parallel branches (chains) all starting from 172 | // the common root. Each branch is a straight line until the maximum depth is reached. 173 | // The function returns only the leaf nodes at the maximum depth level. 174 | // 175 | // ASCII example for maxDepth=4 and chainsNum=3: 176 | // 177 | // root 178 | // / | \ 179 | // / | \ 180 | // n-1-1 n-2-1 n-3-1 (depth 2) 181 | // | | | 182 | // n-1-2 n-2-2 n-3-2 (depth 3) 183 | // | | | 184 | // n-1-3 n-2-3 n-3-3 (depth 4, leaves) 185 | // 186 | // Where n-x-y means node-chainID-depth 187 | // The return value would be the slice ["n-1-3", "n-2-3", "n-3-3"] 188 | // 189 | // This structure is specifically designed to test the cache's performance for 190 | // retrieving nodes at various depths, with a focus on measuring ancestor traversal. 191 | // 192 | // Parameters: 193 | // - maxDepth: The maximum depth of the tree (including root) 194 | // - chainsNum: The number of parallel chains to create 195 | // - maxEntries: The maximum capacity of the cache. If 0, defaults to (maxDepth*chainsNum + 1) 196 | // 197 | // Returns: 198 | // - The initialized cache 199 | // - A slice containing the leaf node keys (nodes at maxDepth-1) 200 | func generateTreeForBench(b *testing.B, maxDepth int, chainsNum int, maxEntries int) (*Cache[string, int], []string) { 201 | b.Helper() 202 | 203 | if maxEntries == 0 { 204 | maxEntries = maxDepth*chainsNum + 1 205 | } 206 | cache := NewCache[string, int](maxEntries) 207 | rootKey := "root" 208 | if err := cache.AddRoot(rootKey, 0); err != nil { 209 | b.Fatal(err) 210 | } 211 | leaves := make([]string, 0, chainsNum) 212 | for chainIdx := 0; chainIdx < chainsNum; chainIdx++ { 213 | parentKey := rootKey 214 | for depth := 1; depth < maxDepth; depth++ { 215 | nodeKey := fmt.Sprintf("node-%d-%d", chainIdx+1, depth) 216 | nodeVal := chainIdx*maxDepth + depth 217 | if err := cache.Add(nodeKey, nodeVal, parentKey); err != nil { 218 | b.Fatal(err) 219 | } 220 | if depth == maxDepth-1 { 221 | leaves = append(leaves, nodeKey) 222 | } 223 | parentKey = nodeKey 224 | } 225 | } 226 | return cache, leaves 227 | } 228 | -------------------------------------------------------------------------------- /cache_test.go: -------------------------------------------------------------------------------- 1 | package lrutree 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "sync" 7 | "sync/atomic" 8 | "testing" 9 | ) 10 | 11 | func TestCache_AddRoot(t *testing.T) { 12 | cache := NewCache[string, int](10) 13 | err := cache.AddRoot("root", 42) 14 | assertNoError(t, err) 15 | assertEqual(t, 1, cache.Len()) 16 | assertEqual(t, []string{"root"}, getLRUOrder(cache)) 17 | 18 | // Verify the root node is added correctly. 19 | cacheNode, ok := cache.Peek("root") 20 | assertTrue(t, ok) 21 | assertEqual(t, CacheNode[string, int]{Key: "root", Value: 42}, cacheNode) 22 | 23 | // Verify that adding a root node again returns an error. 24 | err = cache.AddRoot("root", 2) 25 | assertErrorIs(t, err, ErrRootAlreadyExists) 26 | } 27 | 28 | func TestCache_Add(t *testing.T) { 29 | var lastEvicted *CacheNode[string, int] 30 | onEvict := func(node CacheNode[string, int]) { 31 | lastEvicted = &node 32 | } 33 | cache := NewCache[string, int](12, WithOnEvict(onEvict)) 34 | 35 | assertNoError(t, cache.AddRoot("root", 1)) 36 | 37 | // Add a child node to the root. 38 | lastEvicted = nil 39 | err := cache.Add("sub-root-1", 2, "root") 40 | assertNoError(t, err) 41 | assertNil(t, lastEvicted) 42 | assertEqual(t, []string{"root", "sub-root-1"}, getLRUOrder(cache)) 43 | 44 | // Verify the child node is added correctly. 45 | cacheNode, ok := cache.Peek("sub-root-1") 46 | assertTrue(t, ok) 47 | assertEqual(t, CacheNode[string, int]{Key: "sub-root-1", Value: 2, ParentKey: "root"}, cacheNode) 48 | assertEqual(t, 2, cache.Len()) 49 | 50 | // Verify that adding a node with an existing key returns an error. 51 | err = cache.Add("sub-root-1", 3, "root") 52 | assertErrorIs(t, err, ErrAlreadyExists) 53 | 54 | // Verify that adding a node with a non-existent parent returns an error. 55 | err = cache.Add("sub-root-2", 3, "nonexistent") 56 | assertErrorIs(t, err, ErrParentNotExist) 57 | 58 | // Verify eviction on adding a new node. 59 | lastEvicted = nil 60 | for i := 1; i <= 5; i++ { 61 | partnerKey := "partner-" + strconv.Itoa(i) 62 | assertNoError(t, cache.Add(partnerKey, 10+i, "sub-root-1")) 63 | assertNoError(t, cache.Add("customer-"+strconv.Itoa(i), 100+i, partnerKey)) 64 | } 65 | assertNil(t, lastEvicted) 66 | assertEqual(t, []string{ 67 | "root", "sub-root-1", "partner-5", "customer-5", "partner-4", "customer-4", "partner-3", "customer-3", 68 | "partner-2", "customer-2", "partner-1", "customer-1", 69 | }, getLRUOrder(cache)) 70 | // Tree: 71 | // root 72 | // sub-root-1 73 | // partner-1 74 | // customer-1 75 | // partner-2 76 | // customer-2 77 | // partner-3 78 | // customer-3 79 | // partner-4 80 | // customer-4 81 | // partner-5 82 | // customer-5 83 | 84 | assertNoError(t, cache.Add("partner-6", 16, "sub-root-1")) 85 | assertEqual(t, &CacheNode[string, int]{Key: "customer-1", Value: 101, ParentKey: "partner-1"}, lastEvicted) 86 | assertEqual(t, []string{ 87 | "root", "sub-root-1", "partner-6", "partner-5", "customer-5", "partner-4", "customer-4", 88 | "partner-3", "customer-3", "partner-2", "customer-2", "partner-1", 89 | }, getLRUOrder(cache)) 90 | // Tree: 91 | // root 92 | // sub-root-1 93 | // partner-1 94 | // partner-2 95 | // customer-2 96 | // partner-3 97 | // customer-3 98 | // partner-4 99 | // customer-4 100 | // partner-5 101 | // customer-5 102 | // partner-6 103 | 104 | assertNoError(t, cache.Add("customer-6", 106, "partner-6")) 105 | assertEqual(t, &CacheNode[string, int]{Key: "partner-1", Value: 11, ParentKey: "sub-root-1"}, lastEvicted) 106 | assertEqual(t, []string{ 107 | "root", "sub-root-1", "partner-6", "customer-6", "partner-5", "customer-5", "partner-4", "customer-4", 108 | "partner-3", "customer-3", "partner-2", "customer-2", 109 | }, getLRUOrder(cache)) 110 | // Tree: 111 | // root 112 | // sub-root-1 113 | // partner-2 114 | // customer-2 115 | // partner-3 116 | // customer-3 117 | // partner-4 118 | // customer-4 119 | // partner-5 120 | // customer-5 121 | // partner-6 122 | // customer-6 123 | 124 | // Touch the customer-2 node to move it to the front of the LRU list. 125 | cacheNode, ok = cache.Get("customer-2") 126 | assertTrue(t, ok) 127 | assertEqual(t, CacheNode[string, int]{Key: "customer-2", Value: 102, ParentKey: "partner-2"}, cacheNode) 128 | assertEqual(t, []string{ 129 | "root", "sub-root-1", "partner-2", "customer-2", "partner-6", "customer-6", "partner-5", "customer-5", 130 | "partner-4", "customer-4", "partner-3", "customer-3", 131 | }, getLRUOrder(cache)) 132 | 133 | // Verify eviction on adding a new node after touching an existing node. 134 | assertNoError(t, cache.Add("partner-7", 17, "sub-root-1")) 135 | assertEqual(t, &CacheNode[string, int]{Key: "customer-3", Value: 103, ParentKey: "partner-3"}, lastEvicted) 136 | assertEqual(t, []string{ 137 | "root", "sub-root-1", "partner-7", "partner-2", "customer-2", "partner-6", "customer-6", "partner-5", 138 | "customer-5", "partner-4", "customer-4", "partner-3", 139 | }, getLRUOrder(cache)) 140 | // Tree: 141 | // root 142 | // sub-root-1 143 | // partner-2 144 | // customer-2 145 | // partner-3 146 | // partner-4 147 | // customer-4 148 | // partner-5 149 | // customer-5 150 | // partner-6 151 | // customer-6 152 | // partner-7 153 | } 154 | 155 | func TestCache_Remove(t *testing.T) { 156 | t.Run("removing leaf node", func(t *testing.T) { 157 | cache := NewCache[string, int](10) 158 | assertNoError(t, cache.AddRoot("root", 1)) 159 | assertNoError(t, cache.Add("sub-root", 2, "root")) 160 | assertEqual(t, []string{"root", "sub-root"}, getLRUOrder(cache)) 161 | 162 | removedCount := cache.Remove("sub-root") 163 | assertEqual(t, 1, removedCount) 164 | assertEqual(t, 1, cache.Len()) 165 | _, ok := cache.Peek("sub-root") 166 | assertFalse(t, ok) 167 | assertEqual(t, []string{"root"}, getLRUOrder(cache)) 168 | }) 169 | 170 | t.Run("removing non-leaf node", func(t *testing.T) { 171 | cache := NewCache[string, int](10) 172 | assertNoError(t, cache.AddRoot("root", 1)) 173 | assertNoError(t, cache.Add("sub-root", 2, "root")) 174 | assertNoError(t, cache.Add("partner-1", 3, "sub-root")) 175 | assertNoError(t, cache.Add("customer-1", 4, "partner-1")) 176 | assertNoError(t, cache.Add("partner-2", 5, "sub-root")) 177 | assertNoError(t, cache.Add("customer-2", 6, "partner-2")) 178 | assertEqual(t, []string{ 179 | "root", "sub-root", "partner-2", "customer-2", "partner-1", "customer-1", 180 | }, getLRUOrder(cache)) 181 | 182 | removedCount := cache.Remove("sub-root") 183 | assertEqual(t, 5, removedCount) 184 | assertEqual(t, 1, cache.Len()) 185 | for _, key := range []string{"sub-root", "partner-1", "customer-1", "partner-2", "customer-2"} { 186 | _, ok := cache.Peek(key) 187 | assertFalse(t, ok) 188 | } 189 | assertEqual(t, []string{"root"}, getLRUOrder(cache)) 190 | }) 191 | 192 | t.Run("removing root node", func(t *testing.T) { 193 | cache := NewCache[string, int](10) 194 | assertNoError(t, cache.AddRoot("root", 1)) 195 | assertNoError(t, cache.Add("sub-root", 2, "root")) 196 | assertNoError(t, cache.Add("partner-1", 3, "sub-root")) 197 | assertNoError(t, cache.Add("customer-1", 4, "partner-1")) 198 | assertNoError(t, cache.Add("partner-2", 5, "sub-root")) 199 | assertNoError(t, cache.Add("customer2", 6, "partner-2")) 200 | 201 | removedCount := cache.Remove("root") 202 | assertEqual(t, 6, removedCount) 203 | assertEqual(t, 0, cache.Len()) 204 | for _, key := range []string{"root", "sub-root", "partner-1", "customer-1", "partner-2", "customer-2"} { 205 | _, ok := cache.Peek(key) 206 | assertFalse(t, ok) 207 | } 208 | assertEqual(t, 0, len(getLRUOrder(cache))) 209 | }) 210 | 211 | t.Run("removing non-existent node", func(t *testing.T) { 212 | cache := NewCache[string, int](10) 213 | assertNoError(t, cache.AddRoot("root", 1)) 214 | assertNoError(t, cache.Add("sub-root", 2, "root")) 215 | 216 | removedCount := cache.Remove("nonexistent") 217 | assertEqual(t, 0, removedCount) 218 | assertEqual(t, 2, cache.Len()) 219 | }) 220 | } 221 | 222 | func TestCache_AddOrUpdate(t *testing.T) { 223 | t.Run("new node", func(t *testing.T) { 224 | cache := NewCache[string, int](10) 225 | assertNoError(t, cache.AddRoot("root", 1)) 226 | // Add a new node using AddOrUpdate. 227 | assertNoError(t, cache.AddOrUpdate("child", 2, "root")) 228 | assertEqual(t, []string{"root", "child"}, getLRUOrder(cache)) 229 | cacheNode, ok := cache.Peek("child") 230 | assertTrue(t, ok) 231 | assertEqual(t, CacheNode[string, int]{Key: "child", Value: 2, ParentKey: "root"}, cacheNode) 232 | }) 233 | 234 | t.Run("update value with same parent", func(t *testing.T) { 235 | cache := NewCache[string, int](10) 236 | assertNoError(t, cache.AddRoot("root", 1)) 237 | assertNoError(t, cache.Add("child", 2, "root")) 238 | // Update the value while keeping the same parent. 239 | assertNoError(t, cache.AddOrUpdate("child", 20, "root")) 240 | assertEqual(t, []string{"root", "child"}, getLRUOrder(cache)) 241 | cacheNode, ok := cache.Peek("child") 242 | assertTrue(t, ok) 243 | assertEqual(t, CacheNode[string, int]{Key: "child", Value: 20, ParentKey: "root"}, cacheNode) 244 | }) 245 | 246 | t.Run("update parent", func(t *testing.T) { 247 | cache := NewCache[string, int](10) 248 | assertNoError(t, cache.AddRoot("root", 1)) 249 | assertNoError(t, cache.Add("child1", 2, "root")) 250 | assertNoError(t, cache.Add("child2", 3, "root")) 251 | // Reparent child1 from "root" to "child2" using AddOrUpdate. 252 | assertNoError(t, cache.AddOrUpdate("child1", 22, "child2")) 253 | assertEqual(t, []string{"root", "child2", "child1"}, getLRUOrder(cache)) 254 | var traversed []CacheNode[string, int] 255 | cache.TraverseToRoot("child1", func(key string, val int, parentKey string) { 256 | traversed = append(traversed, CacheNode[string, int]{Key: key, Value: val, ParentKey: parentKey}) 257 | }) 258 | expected := []CacheNode[string, int]{ 259 | {Key: "child1", Value: 22, ParentKey: "child2"}, 260 | {Key: "child2", Value: 3, ParentKey: "root"}, 261 | {Key: "root", Value: 1, ParentKey: ""}, 262 | } 263 | assertEqual(t, expected, traversed) 264 | }) 265 | 266 | t.Run("cycle detection", func(t *testing.T) { 267 | cache := NewCache[string, int](10) 268 | assertNoError(t, cache.AddRoot("root", 1)) 269 | assertNoError(t, cache.Add("child", 2, "root")) 270 | assertNoError(t, cache.Add("grandchild", 3, "child")) 271 | // Attempt to update "root" to be a child of "grandchild", which should create a cycle. 272 | err := cache.AddOrUpdate("root", 10, "grandchild") 273 | assertErrorIs(t, err, ErrCycleDetected) 274 | }) 275 | 276 | t.Run("invalid parent", func(t *testing.T) { 277 | cache := NewCache[string, int](10) 278 | assertNoError(t, cache.AddRoot("root", 1)) 279 | // Trying to add/update a node with a non-existent parent should return an error. 280 | err := cache.AddOrUpdate("child", 2, "nonexistent") 281 | assertErrorIs(t, err, ErrParentNotExist) 282 | }) 283 | } 284 | 285 | func TestCache_GetBranch(t *testing.T) { 286 | t.Run("key exists", func(t *testing.T) { 287 | cache := NewCache[string, int](10) 288 | assertNoError(t, cache.AddRoot("root", 10)) 289 | assertNoError(t, cache.Add("child1", 20, "root")) 290 | assertNoError(t, cache.Add("grandchild1", 30, "child1")) 291 | assertNoError(t, cache.Add("child2", 40, "root")) 292 | assertNoError(t, cache.Add("grandchild2", 50, "child2")) 293 | assertEqual(t, []string{"root", "child2", "grandchild2", "child1", "grandchild1"}, getLRUOrder(cache)) 294 | 295 | assertEqual(t, []CacheNode[string, int]{ 296 | {Key: "root", Value: 10}, 297 | {Key: "child1", Value: 20, ParentKey: "root"}, 298 | {Key: "grandchild1", Value: 30, ParentKey: "child1"}, 299 | }, cache.GetBranch("grandchild1")) 300 | assertEqual(t, []string{"root", "child1", "grandchild1", "child2", "grandchild2"}, getLRUOrder(cache)) 301 | }) 302 | 303 | t.Run("key is root", func(t *testing.T) { 304 | cache := NewCache[string, int](10) 305 | assertNoError(t, cache.AddRoot("root", 10)) 306 | assertNoError(t, cache.Add("level1", 20, "root")) 307 | assertEqual(t, []CacheNode[string, int]{{Key: "root", Value: 10}}, cache.GetBranch("root")) 308 | }) 309 | 310 | t.Run("key doesn't exist", func(t *testing.T) { 311 | cache := NewCache[string, int](10) 312 | assertNoError(t, cache.AddRoot("root", 1)) 313 | assertEqual(t, 0, len(cache.GetBranch("nonexistent"))) 314 | }) 315 | 316 | t.Run("empty cache", func(t *testing.T) { 317 | cache := NewCache[string, int](10) 318 | assertEqual(t, 0, len(cache.GetBranch("anything"))) 319 | }) 320 | } 321 | 322 | func TestCache_PeekBranch(t *testing.T) { 323 | t.Run("key exists - lru order unchanged", func(t *testing.T) { 324 | cache := NewCache[string, int](10) 325 | assertNoError(t, cache.AddRoot("root", 10)) 326 | assertNoError(t, cache.Add("child1", 20, "root")) 327 | assertNoError(t, cache.Add("grandchild1", 30, "child1")) 328 | assertNoError(t, cache.Add("child2", 40, "root")) 329 | assertNoError(t, cache.Add("grandchild2", 50, "child2")) 330 | 331 | // Capture initial LRU order 332 | initialOrder := getLRUOrder(cache) 333 | 334 | // Peek the branch to grandchild1 335 | branch := cache.PeekBranch("grandchild1") 336 | 337 | // Verify branch content 338 | assertEqual(t, []CacheNode[string, int]{ 339 | {Key: "root", Value: 10}, 340 | {Key: "child1", Value: 20, ParentKey: "root"}, 341 | {Key: "grandchild1", Value: 30, ParentKey: "child1"}, 342 | }, branch) 343 | 344 | // Verify LRU order is unchanged 345 | assertEqual(t, initialOrder, getLRUOrder(cache)) 346 | }) 347 | 348 | t.Run("key is root", func(t *testing.T) { 349 | cache := NewCache[string, int](10) 350 | assertNoError(t, cache.AddRoot("root", 10)) 351 | assertNoError(t, cache.Add("level1", 20, "root")) 352 | 353 | // Capture initial LRU order 354 | initialOrder := getLRUOrder(cache) 355 | 356 | // Peek the branch to root 357 | branch := cache.PeekBranch("root") 358 | 359 | // Verify branch content 360 | assertEqual(t, []CacheNode[string, int]{{Key: "root", Value: 10}}, branch) 361 | 362 | // Verify LRU order is unchanged 363 | assertEqual(t, initialOrder, getLRUOrder(cache)) 364 | }) 365 | 366 | t.Run("key doesn't exist", func(t *testing.T) { 367 | cache := NewCache[string, int](10) 368 | assertNoError(t, cache.AddRoot("root", 1)) 369 | 370 | // Capture initial LRU order 371 | initialOrder := getLRUOrder(cache) 372 | 373 | // Peek non-existent branch 374 | branch := cache.PeekBranch("nonexistent") 375 | 376 | // Verify branch is empty 377 | assertEqual(t, 0, len(branch)) 378 | 379 | // Verify LRU order is unchanged 380 | assertEqual(t, initialOrder, getLRUOrder(cache)) 381 | }) 382 | 383 | t.Run("empty cache", func(t *testing.T) { 384 | cache := NewCache[string, int](10) 385 | 386 | // Peek branch in empty cache 387 | branch := cache.PeekBranch("anything") 388 | 389 | // Verify branch is empty 390 | assertEqual(t, 0, len(branch)) 391 | }) 392 | 393 | t.Run("stats tracking", func(t *testing.T) { 394 | stats := &mockStats{} 395 | cache := NewCache[string, int](10, WithStatsCollector[string, int](stats)) 396 | assertNoError(t, cache.AddRoot("root", 10)) 397 | assertNoError(t, cache.Add("child1", 20, "root")) 398 | 399 | // Initial stats counts should be 0 400 | assertEqual(t, int32(0), stats.hits.Load()) 401 | assertEqual(t, int32(0), stats.misses.Load()) 402 | 403 | // Test hit - peek a branch that exists 404 | _ = cache.PeekBranch("child1") 405 | assertEqual(t, int32(1), stats.hits.Load()) 406 | assertEqual(t, int32(0), stats.misses.Load()) 407 | 408 | // Test miss - peek a branch that doesn't exist 409 | _ = cache.PeekBranch("nonexistent") 410 | assertEqual(t, int32(1), stats.hits.Load()) 411 | assertEqual(t, int32(1), stats.misses.Load()) 412 | }) 413 | } 414 | 415 | func TestCache_TraverseToRoot(t *testing.T) { 416 | t.Run("key not found", func(t *testing.T) { 417 | cache := NewCache[string, int](10) 418 | _, ok := cache.Get("nonexistent") 419 | assertFalse(t, ok) 420 | 421 | var traversed []CacheNode[string, int] 422 | cache.TraverseToRoot("nonexistent", func(key string, val int, parentKey string) { 423 | traversed = append(traversed, CacheNode[string, int]{Key: key, Value: val, ParentKey: parentKey}) 424 | }) 425 | assertEqual(t, 0, len(traversed)) 426 | }) 427 | 428 | t.Run("key found, LRU list updated", func(t *testing.T) { 429 | var lastEvicted *CacheNode[string, int] 430 | onEvict := func(node CacheNode[string, int]) { 431 | lastEvicted = &node 432 | } 433 | cache := NewCache[string, int](3, WithOnEvict(onEvict)) 434 | assertNoError(t, cache.AddRoot("root", 1)) 435 | assertNoError(t, cache.Add("sub-root", 2, "root")) 436 | assertNoError(t, cache.Add("partner-1", 3, "sub-root")) 437 | assertNil(t, lastEvicted) 438 | 439 | // Get the "partner" node. 440 | cacheNode, ok := cache.Get("partner-1") 441 | assertTrue(t, ok) 442 | assertEqual(t, CacheNode[string, int]{Key: "partner-1", Value: 3, ParentKey: "sub-root"}, cacheNode) 443 | 444 | // Traverse to the root node. 445 | var traversed []CacheNode[string, int] 446 | cache.TraverseToRoot("partner-1", func(key string, val int, parentKey string) { 447 | traversed = append(traversed, CacheNode[string, int]{Key: key, Value: val, ParentKey: parentKey}) 448 | }) 449 | expected := []CacheNode[string, int]{ 450 | {Key: "partner-1", Value: 3, ParentKey: "sub-root"}, 451 | {Key: "sub-root", Value: 2, ParentKey: "root"}, 452 | {Key: "root", Value: 1, ParentKey: ""}, 453 | } 454 | assertEqual(t, expected, traversed) 455 | 456 | // Verify the LRU list is updated properly. The customer node will be evicted immediately. 457 | assertNoError(t, cache.Add("customer-1", 4, "partner-1")) 458 | _, ok = cache.Get("customer-1") 459 | assertFalse(t, ok) 460 | for _, key := range []string{"root", "sub-root", "partner-1"} { 461 | _, ok = cache.Get(key) 462 | assertTrue(t, ok) 463 | } 464 | assertEqual(t, &CacheNode[string, int]{Key: "customer-1", Value: 4, ParentKey: "partner-1"}, lastEvicted) 465 | assertEqual(t, 3, cache.Len()) 466 | 467 | // Add a new node to the cache under the "sub-root" node. Partner-1 node will be evicted. 468 | assertNoError(t, cache.Add("customer-2", 5, "sub-root")) 469 | _, ok = cache.Get("partner-1") 470 | assertFalse(t, ok) 471 | for _, key := range []string{"root", "sub-root", "customer-2"} { 472 | _, ok = cache.Get(key) 473 | assertTrue(t, ok) 474 | } 475 | assertEqual(t, CacheNode[string, int]{Key: "partner-1", Value: 3, ParentKey: "sub-root"}, cacheNode) 476 | assertEqual(t, 3, cache.Len()) 477 | }) 478 | 479 | t.Run("panicking callback", func(t *testing.T) { 480 | cache := NewCache[string, int](10) 481 | assertNoError(t, cache.AddRoot("root", 1)) 482 | assertNoError(t, cache.Add("child1", 2, "root")) 483 | assertNoError(t, cache.Add("grandchild1", 3, "child1")) 484 | assertNoError(t, cache.Add("child2", 4, "root")) 485 | assertNoError(t, cache.Add("grandchild2", 5, "child2")) 486 | assertEqual(t, []string{"root", "child2", "grandchild2", "child1", "grandchild1"}, getLRUOrder(cache)) 487 | 488 | panicFunc := func(key string, val int, parentKey string) { 489 | panic("intentional panic in callback") 490 | } 491 | 492 | defer func() { 493 | r := recover() 494 | assertEqual(t, "intentional panic in callback", r.(string)) 495 | 496 | // Verify that LRU order was updated in case of panic 497 | assertEqual(t, []string{"root", "child1", "grandchild1", "child2", "grandchild2"}, getLRUOrder(cache)) 498 | 499 | // Verify that the cache is still usable after panic 500 | cacheNode, ok := cache.Get("grandchild2") 501 | assertTrue(t, ok) 502 | assertEqual(t, CacheNode[string, int]{Key: "grandchild2", Value: 5, ParentKey: "child2"}, cacheNode) 503 | assertEqual(t, []string{"root", "child2", "grandchild2", "child1", "grandchild1"}, getLRUOrder(cache)) 504 | }() 505 | 506 | cache.TraverseToRoot("grandchild1", panicFunc) 507 | }) 508 | } 509 | 510 | func TestCache_TraverseSubtree(t *testing.T) { 511 | t.Run("simple", func(t *testing.T) { 512 | cache := NewCache[string, int](10) 513 | assertNoError(t, cache.AddRoot("root", 1)) 514 | assertNoError(t, cache.Add("child1", 2, "root")) 515 | assertNoError(t, cache.Add("child2", 3, "root")) 516 | assertNoError(t, cache.Add("grandchild1", 4, "child1")) 517 | 518 | var traversed []CacheNode[string, int] 519 | cache.TraverseSubtree("child1", func(key string, val int, parentKey string) { 520 | traversed = append(traversed, CacheNode[string, int]{Key: key, Value: val, ParentKey: parentKey}) 521 | }) 522 | // Expected pre-order: starting at "child1" then "grandchild1" 523 | assertEqual(t, []CacheNode[string, int]{ 524 | {Key: "child1", Value: 2, ParentKey: "root"}, 525 | {Key: "grandchild1", Value: 4, ParentKey: "child1"}, 526 | }, traversed) 527 | assertEqual(t, []string{"root", "child1", "grandchild1", "child2"}, getLRUOrder(cache)) 528 | }) 529 | 530 | t.Run("multiple children", func(t *testing.T) { 531 | cache := NewCache[string, int](10) 532 | assertNoError(t, cache.AddRoot("root", 1)) 533 | assertNoError(t, cache.Add("child1", 2, "root")) 534 | assertNoError(t, cache.Add("child2", 3, "root")) 535 | assertNoError(t, cache.Add("grandchild1", 4, "child1")) 536 | assertNoError(t, cache.Add("grandchild2", 5, "child2")) 537 | 538 | var traversed []CacheNode[string, int] 539 | // Iterating from the root should traverse the whole tree in depth-first order. 540 | cache.TraverseSubtree("root", func(key string, val int, parentKey string) { 541 | traversed = append(traversed, CacheNode[string, int]{Key: key, Value: val, ParentKey: parentKey}) 542 | }) 543 | 544 | assertEqual(t, 5, len(traversed)) 545 | if traversed[len(traversed)-1].Key == "grandchild2" { 546 | assertEqual(t, []CacheNode[string, int]{ 547 | {Key: "root", Value: 1, ParentKey: ""}, 548 | {Key: "child1", Value: 2, ParentKey: "root"}, 549 | {Key: "grandchild1", Value: 4, ParentKey: "child1"}, 550 | {Key: "child2", Value: 3, ParentKey: "root"}, 551 | {Key: "grandchild2", Value: 5, ParentKey: "child2"}, 552 | }, traversed) 553 | assertEqual(t, []string{"root", "child2", "grandchild2", "child1", "grandchild1"}, getLRUOrder(cache)) 554 | } else { 555 | assertEqual(t, []CacheNode[string, int]{ 556 | {Key: "root", Value: 1, ParentKey: ""}, 557 | {Key: "child2", Value: 3, ParentKey: "root"}, 558 | {Key: "grandchild2", Value: 5, ParentKey: "child2"}, 559 | {Key: "child1", Value: 2, ParentKey: "root"}, 560 | {Key: "grandchild1", Value: 4, ParentKey: "child1"}, 561 | }, traversed) 562 | assertEqual(t, []string{"root", "child1", "grandchild1", "child2", "grandchild2"}, getLRUOrder(cache)) 563 | } 564 | }) 565 | 566 | t.Run("non-existent key", func(t *testing.T) { 567 | cache := NewCache[string, int](10) 568 | var iterated []string 569 | // Calling TraverseSubtree on a non-existent key should not invoke the callback. 570 | cache.TraverseSubtree("nonexistent", func(key string, val int, parentKey string) { 571 | iterated = append(iterated, key) 572 | }) 573 | // Calling TraverseSubtree on a non-existent key should not invoke the callback. 574 | assertEqual(t, 0, len(iterated)) 575 | }) 576 | 577 | t.Run("panicking callback", func(t *testing.T) { 578 | cache := NewCache[string, int](10) 579 | assertNoError(t, cache.AddRoot("root", 1)) 580 | assertNoError(t, cache.Add("child1", 2, "root")) 581 | assertNoError(t, cache.Add("grandchild1", 3, "child1")) 582 | assertNoError(t, cache.Add("child2", 4, "root")) 583 | assertNoError(t, cache.Add("grandchild2", 5, "child2")) 584 | assertEqual(t, []string{"root", "child2", "grandchild2", "child1", "grandchild1"}, getLRUOrder(cache)) 585 | 586 | // Create a callback function that will panic 587 | panicFunc := func(key string, val int, parentKey string) { 588 | panic("intentional panic in subtree traversal") 589 | } 590 | 591 | // The panic should be recovered by the test 592 | defer func() { 593 | r := recover() 594 | assertEqual(t, "intentional panic in subtree traversal", r.(string)) 595 | 596 | assertEqual(t, []string{"root", "child1", "child2", "grandchild2", "grandchild1"}, getLRUOrder(cache)) 597 | 598 | // Verify that the cache is still usable after panic 599 | cacheNode, ok := cache.Get("grandchild2") 600 | assertTrue(t, ok) 601 | assertEqual(t, CacheNode[string, int]{Key: "grandchild2", Value: 5, ParentKey: "child2"}, cacheNode) 602 | assertEqual(t, []string{"root", "child2", "grandchild2", "child1", "grandchild1"}, getLRUOrder(cache)) 603 | }() 604 | 605 | cache.TraverseSubtree("child1", panicFunc) 606 | }) 607 | } 608 | 609 | func TestCache_TraverseSubtree_WithMaxDepth(t *testing.T) { 610 | nodes := map[string]CacheNode[string, int]{ 611 | "root": {Key: "root", Value: 11, ParentKey: ""}, 612 | "child1": {Key: "child1", Value: 12, ParentKey: "root"}, 613 | "child2": {Key: "child2", Value: 13, ParentKey: "root"}, 614 | "grandchild1": {Key: "grandchild1", Value: 14, ParentKey: "child1"}, 615 | "grandchild2": {Key: "grandchild2", Value: 15, ParentKey: "child2"}, 616 | "greatgrandchild1": {Key: "greatgrandchild1", Value: 16, ParentKey: "grandchild1"}, 617 | "greatgrandchild2": {Key: "greatgrandchild2", Value: 17, ParentKey: "grandchild2"}, 618 | } 619 | 620 | setupCache := func() *Cache[string, int] { 621 | cache := NewCache[string, int](10) 622 | for _, key := range []string{"root", "child1", "child2", "grandchild1", "grandchild2", "greatgrandchild1", "greatgrandchild2"} { 623 | node := nodes[key] 624 | if node.ParentKey == "" { 625 | assertNoError(t, cache.AddRoot(node.Key, node.Value)) 626 | } else { 627 | assertNoError(t, cache.Add(node.Key, node.Value, node.ParentKey)) 628 | } 629 | } 630 | return cache 631 | } 632 | 633 | makeNodes := func(keys ...string) []CacheNode[string, int] { 634 | result := make([]CacheNode[string, int], 0, len(keys)) 635 | for _, key := range keys { 636 | result = append(result, nodes[key]) 637 | } 638 | return result 639 | } 640 | 641 | t.Run("with unlimited depth", func(t *testing.T) { 642 | cache := setupCache() 643 | 644 | var traversed []CacheNode[string, int] 645 | cache.TraverseSubtree("root", func(key string, val int, parentKey string) { 646 | traversed = append(traversed, CacheNode[string, int]{Key: key, Value: val, ParentKey: parentKey}) 647 | }) 648 | 649 | assertEqual(t, 7, len(traversed)) 650 | if traversed[1].Key == "child1" { 651 | assertEqual(t, makeNodes("root", "child1", "grandchild1", "greatgrandchild1", "child2", "grandchild2", "greatgrandchild2"), traversed) 652 | assertEqual(t, []string{"root", "child2", "grandchild2", "greatgrandchild2", "child1", "grandchild1", "greatgrandchild1"}, getLRUOrder(cache)) 653 | } else { 654 | assertEqual(t, makeNodes("root", "child2", "grandchild2", "greatgrandchild2", "child1", "grandchild1", "greatgrandchild1"), traversed) 655 | assertEqual(t, []string{"root", "child1", "grandchild1", "greatgrandchild1", "child2", "grandchild2", "greatgrandchild2"}, getLRUOrder(cache)) 656 | } 657 | }) 658 | 659 | t.Run("with depth 0 - node only", func(t *testing.T) { 660 | cache := setupCache() 661 | 662 | var traversed []CacheNode[string, int] 663 | cache.TraverseSubtree("root", func(key string, val int, parentKey string) { 664 | traversed = append(traversed, CacheNode[string, int]{Key: key, Value: val, ParentKey: parentKey}) 665 | }, WithMaxDepth(0)) 666 | 667 | assertEqual(t, 1, len(traversed)) 668 | assertEqual(t, makeNodes("root"), traversed) 669 | assertEqual(t, []string{"root", "child2", "grandchild2", "greatgrandchild2", "child1", "grandchild1", "greatgrandchild1"}, getLRUOrder(cache)) 670 | }) 671 | 672 | t.Run("with depth 1 - node and immediate children", func(t *testing.T) { 673 | cache := setupCache() 674 | 675 | var traversed []CacheNode[string, int] 676 | cache.TraverseSubtree("root", func(key string, val int, parentKey string) { 677 | traversed = append(traversed, CacheNode[string, int]{Key: key, Value: val, ParentKey: parentKey}) 678 | }, WithMaxDepth(1)) 679 | 680 | assertEqual(t, 3, len(traversed)) 681 | if traversed[1].Key == "child1" { 682 | assertEqual(t, makeNodes("root", "child1", "child2"), traversed) 683 | assertEqual(t, []string{"root", "child2", "child1", "grandchild2", "greatgrandchild2", "grandchild1", "greatgrandchild1"}, getLRUOrder(cache)) 684 | } else { 685 | assertEqual(t, makeNodes("root", "child2", "child1"), traversed) 686 | assertEqual(t, []string{"root", "child1", "child2", "grandchild2", "greatgrandchild2", "grandchild1", "greatgrandchild1"}, getLRUOrder(cache)) 687 | } 688 | }) 689 | 690 | t.Run("depth 2 - node, children, and grandchildren", func(t *testing.T) { 691 | cache := setupCache() 692 | 693 | var traversed []CacheNode[string, int] 694 | cache.TraverseSubtree("root", func(key string, val int, parentKey string) { 695 | traversed = append(traversed, CacheNode[string, int]{Key: key, Value: val, ParentKey: parentKey}) 696 | }, WithMaxDepth(2)) 697 | 698 | assertEqual(t, 5, len(traversed)) 699 | if traversed[1].Key == "child1" { 700 | assertEqual(t, makeNodes("root", "child1", "grandchild1", "child2", "grandchild2"), traversed) 701 | assertEqual(t, []string{"root", "child2", "grandchild2", "child1", "grandchild1", "greatgrandchild2", "greatgrandchild1"}, getLRUOrder(cache)) 702 | } else { 703 | assertEqual(t, makeNodes("root", "child2", "grandchild2", "child1", "grandchild1"), traversed) 704 | assertEqual(t, []string{"root", "child1", "grandchild1", "child2", "grandchild2", "greatgrandchild2", "greatgrandchild1"}, getLRUOrder(cache)) 705 | } 706 | }) 707 | 708 | t.Run("traverse from middle node", func(t *testing.T) { 709 | cache := setupCache() 710 | 711 | var traversed []CacheNode[string, int] 712 | cache.TraverseSubtree("child1", func(key string, val int, parentKey string) { 713 | traversed = append(traversed, CacheNode[string, int]{Key: key, Value: val, ParentKey: parentKey}) 714 | }, WithMaxDepth(1)) 715 | 716 | assertEqual(t, makeNodes("child1", "grandchild1"), traversed) 717 | assertEqual(t, []string{"root", "child1", "grandchild1", "child2", "grandchild2", "greatgrandchild2", "greatgrandchild1"}, getLRUOrder(cache)) 718 | }) 719 | } 720 | 721 | func TestConcurrency(t *testing.T) { 722 | cache := NewCache[string, int](100_000) 723 | assertNoError(t, cache.AddRoot("root", 1)) 724 | 725 | // Create a deep tree 726 | for i := 1; i <= 100; i++ { 727 | for j := 1; j <= 100; j++ { 728 | parent := "root" 729 | if j > 1 { 730 | parent = fmt.Sprintf("node-%d-%d", i, j-1) 731 | } 732 | assertNoError(t, cache.Add(fmt.Sprintf("node-%d-%d", i, j), i*1000+j, parent)) 733 | } 734 | } 735 | 736 | errs := make(chan error, 10_000) 737 | 738 | // Run concurrent operations including some that will panic 739 | var wg sync.WaitGroup 740 | for i := 1; i <= 100; i++ { 741 | for j := 1; j <= 100; j++ { 742 | wg.Add(1) 743 | go func(i, j int) { 744 | defer wg.Done() 745 | defer func() { 746 | _ = recover() 747 | }() 748 | 749 | key := fmt.Sprintf("node-%d-%d", i, j) 750 | 751 | // Mix of operations, some will panic 752 | switch j % 5 { 753 | case 0: 754 | // Normal get 755 | cacheNode, ok := cache.Get(key) 756 | if !ok { 757 | errs <- fmt.Errorf("%s not found in cache", key) 758 | } 759 | if cacheNode.Value != i*1000+j { 760 | errs <- fmt.Errorf("unexpected value for %s, want %d, got %d", key, i*1000+j, cacheNode.Value) 761 | } 762 | case 1: 763 | // Traverse with panic possibility 764 | cache.TraverseToRoot(key, func(key string, val int, parentKey string) { 765 | if j%7 == 0 { 766 | panic("random panic in TraverseToRoot callback") 767 | } 768 | }) 769 | case 2: 770 | // Traverse subtree with panic possibility 771 | cache.TraverseSubtree("root", func(key string, val int, parentKey string) { 772 | if j%8 == 0 { 773 | panic("random panic in TraverseSubtree callback") 774 | } 775 | }) 776 | case 3: 777 | _ = cache.Add(fmt.Sprintf("temp-node-%d-%d", i, j), i*1000+j, key) 778 | case 4: 779 | cache.Remove(fmt.Sprintf("temp-node-%d-%d", i, j)) 780 | } 781 | }(i, j) 782 | } 783 | } 784 | wg.Wait() 785 | 786 | close(errs) 787 | for err := range errs { 788 | assertNoError(t, err) 789 | } 790 | 791 | // Verify the cache is still in a usable state 792 | cacheNode, ok := cache.Get("root") 793 | assertTrue(t, ok) 794 | assertEqual(t, CacheNode[string, int]{Key: "root", Value: 1}, cacheNode) 795 | 796 | // Verify we can still perform operations 797 | err := cache.Add("final-test", 999, "root") 798 | assertNoError(t, err) 799 | cacheNode, ok = cache.Get("final-test") 800 | assertTrue(t, ok) 801 | assertEqual(t, CacheNode[string, int]{Key: "final-test", Value: 999, ParentKey: "root"}, cacheNode) 802 | } 803 | 804 | func getLRUOrder[K comparable, V any](c *Cache[K, V]) []K { 805 | keys := make([]K, 0, c.Len()) 806 | for e := c.lruList.Front(); e != nil; e = e.Next() { 807 | keys = append(keys, e.Value.(*treeNode[K, V]).key) 808 | } 809 | return keys 810 | } 811 | 812 | // mockStats implements the StatsCollector interface for testing. 813 | type mockStats struct { 814 | amount atomic.Int32 815 | hits atomic.Int32 816 | misses atomic.Int32 817 | evictions atomic.Int32 818 | } 819 | 820 | func (m *mockStats) SetAmount(val int) { 821 | m.amount.Store(int32(val)) 822 | } 823 | 824 | func (m *mockStats) IncHits() { 825 | m.hits.Add(1) 826 | } 827 | 828 | func (m *mockStats) IncMisses() { 829 | m.misses.Add(1) 830 | } 831 | 832 | func (m *mockStats) AddEvictions(val int) { 833 | m.evictions.Add(int32(val)) 834 | } 835 | 836 | // panicingStats implements StatsCollector but panics on every 2nd call 837 | type panicingStats struct { 838 | calls atomic.Int32 839 | } 840 | 841 | func (p *panicingStats) SetAmount(val int) { 842 | if p.calls.Add(1)%2 == 0 { 843 | panic("SetAmount panic") 844 | } 845 | } 846 | 847 | func (p *panicingStats) IncHits() { 848 | if p.calls.Add(1)%2 == 0 { 849 | panic("IncHits panic") 850 | } 851 | } 852 | 853 | func (p *panicingStats) IncMisses() { 854 | if p.calls.Add(1)%2 == 0 { 855 | panic("IncMisses panic") 856 | } 857 | } 858 | 859 | func (p *panicingStats) AddEvictions(val int) { 860 | if p.calls.Add(1)%2 == 0 { 861 | panic("AddEvictions panic") 862 | } 863 | } 864 | 865 | func TestCache_Stats(t *testing.T) { 866 | t.Run("basic operations", func(t *testing.T) { 867 | stats := &mockStats{} 868 | cache := NewCache[string, int](5, WithStatsCollector[string, int](stats)) 869 | 870 | // Test adding root and child nodes 871 | assertNoError(t, cache.AddRoot("root", 1)) 872 | assertEqual(t, int32(1), stats.amount.Load()) 873 | 874 | assertNoError(t, cache.Add("child1", 2, "root")) 875 | assertNoError(t, cache.Add("child2", 3, "root")) 876 | assertEqual(t, int32(3), stats.amount.Load()) 877 | 878 | // Test hits and misses 879 | _, ok := cache.Get("child1") 880 | assertTrue(t, ok) 881 | assertEqual(t, int32(1), stats.hits.Load()) 882 | 883 | _, ok = cache.Get("nonexistent") 884 | assertFalse(t, ok) 885 | assertEqual(t, int32(1), stats.misses.Load()) 886 | 887 | // Test Peek 888 | _, ok = cache.Peek("child2") 889 | assertTrue(t, ok) 890 | assertEqual(t, int32(2), stats.hits.Load()) 891 | 892 | _, ok = cache.Peek("nonexistent2") 893 | assertFalse(t, ok) 894 | assertEqual(t, int32(2), stats.misses.Load()) 895 | }) 896 | 897 | t.Run("eviction", func(t *testing.T) { 898 | var lastEvicted *CacheNode[string, int] 899 | onEvict := func(node CacheNode[string, int]) { 900 | lastEvicted = &node 901 | } 902 | 903 | stats := &mockStats{} 904 | cache := NewCache[string, int](3, 905 | WithOnEvict(onEvict), 906 | WithStatsCollector[string, int](stats), 907 | ) 908 | 909 | // Setup the cache 910 | assertNoError(t, cache.AddRoot("root", 1)) 911 | assertNoError(t, cache.Add("child1", 2, "root")) 912 | assertNoError(t, cache.Add("child2", 3, "root")) 913 | assertEqual(t, int32(3), stats.amount.Load()) 914 | assertEqual(t, int32(0), stats.evictions.Load()) 915 | 916 | // This should cause eviction 917 | assertNoError(t, cache.Add("child3", 4, "root")) 918 | assertEqual(t, int32(3), stats.amount.Load()) // Still 3 items 919 | assertEqual(t, "child1", lastEvicted.Key) // child1 was evicted 920 | 921 | // Update LRU order and add another node to cause another eviction 922 | _, ok := cache.Get("child2") 923 | assertTrue(t, ok) 924 | assertNoError(t, cache.Add("child4", 5, "root")) 925 | assertEqual(t, "child3", lastEvicted.Key) // child3 should be evicted now 926 | }) 927 | 928 | t.Run("subtree operations", func(t *testing.T) { 929 | stats := &mockStats{} 930 | cache := NewCache[string, int](10, WithStatsCollector[string, int](stats)) 931 | 932 | // Create a tree structure 933 | assertNoError(t, cache.AddRoot("root", 1)) 934 | assertNoError(t, cache.Add("child1", 2, "root")) 935 | assertNoError(t, cache.Add("child2", 3, "root")) 936 | assertNoError(t, cache.Add("grandchild1", 4, "child1")) 937 | assertNoError(t, cache.Add("grandchild2", 5, "child1")) 938 | 939 | // Test branch traversal 940 | branch := cache.GetBranch("grandchild1") 941 | assertEqual(t, 3, len(branch)) // root -> child1 -> grandchild1 942 | assertEqual(t, int32(1), stats.hits.Load()) 943 | 944 | // Test TraverseToRoot 945 | cache.TraverseToRoot("grandchild2", func(key string, val int, parentKey string) {}) 946 | assertEqual(t, int32(2), stats.hits.Load()) 947 | 948 | // Test TraverseSubtree 949 | cache.TraverseSubtree("child1", func(key string, val int, parentKey string) {}) 950 | assertEqual(t, int32(3), stats.hits.Load()) 951 | 952 | // Remove a subtree 953 | removedCount := cache.Remove("child1") 954 | assertEqual(t, 3, removedCount) // child1, grandchild1, grandchild2 955 | assertEqual(t, int32(2), stats.amount.Load()) // root and child2 left 956 | }) 957 | 958 | t.Run("AddOrUpdate", func(t *testing.T) { 959 | stats := &mockStats{} 960 | cache := NewCache[string, int](5, WithStatsCollector[string, int](stats)) 961 | 962 | // Add root and child 963 | assertNoError(t, cache.AddRoot("root", 1)) 964 | assertNoError(t, cache.AddOrUpdate("child1", 2, "root")) 965 | assertEqual(t, int32(2), stats.amount.Load()) 966 | 967 | // Update existing node 968 | assertNoError(t, cache.AddOrUpdate("child1", 3, "root")) 969 | assertEqual(t, int32(2), stats.amount.Load()) // Count should stay the same 970 | 971 | // Add more nodes to test eviction 972 | for i := 2; i <= 5; i++ { 973 | assertNoError(t, cache.AddOrUpdate("child"+string(rune('0'+i)), i+1, "root")) 974 | } 975 | assertEqual(t, int32(5), stats.amount.Load()) // At capacity 976 | }) 977 | 978 | t.Run("null stats", func(t *testing.T) { 979 | // Create cache without explicit stats 980 | cache := NewCache[string, int](5) 981 | 982 | // These operations should not panic 983 | assertNoError(t, cache.AddRoot("root", 1)) 984 | assertNoError(t, cache.Add("child1", 2, "root")) 985 | _, _ = cache.Get("child1") 986 | _, _ = cache.Get("nonexistent") 987 | _, _ = cache.Peek("child1") 988 | _ = cache.GetBranch("child1") 989 | cache.TraverseToRoot("child1", func(key string, val int, parentKey string) {}) 990 | cache.TraverseSubtree("root", func(key string, val int, parentKey string) {}) 991 | _ = cache.Remove("child1") 992 | 993 | // Add nodes until eviction occurs 994 | for i := 0; i < 10; i++ { 995 | key := "node" + string(rune('0'+i)) 996 | _ = cache.Add(key, i, "root") 997 | } 998 | 999 | assertEqual(t, 5, cache.Len()) // Capacity is 5 1000 | }) 1001 | 1002 | t.Run("recovery from stats panic", func(t *testing.T) { 1003 | // Create a stats implementation that panics 1004 | panicStats := &panicingStats{} 1005 | cache := NewCache[string, int](10, WithStatsCollector[string, int](panicStats)) 1006 | 1007 | // Setup cache with some initial data 1008 | assertNoError(t, cache.AddRoot("root", 1)) 1009 | 1010 | // Test that we recover from panic in Add 1011 | func() { 1012 | defer func() { 1013 | r := recover() 1014 | assertEqual(t, "SetAmount panic", r.(string)) 1015 | }() 1016 | _ = cache.Add("child1", 2, "root") 1017 | }() 1018 | // Cache should still be usable 1019 | assertNoError(t, cache.Add("child2", 3, "root")) 1020 | 1021 | // Test we recover from panic in Get 1022 | func() { 1023 | defer func() { 1024 | r := recover() 1025 | assertEqual(t, "IncHits panic", r.(string)) 1026 | }() 1027 | _, _ = cache.Get("child1") 1028 | }() 1029 | // Cache should still be usable 1030 | node, exists := cache.Get("child1") 1031 | assertTrue(t, exists) 1032 | assertEqual(t, 2, node.Value) 1033 | 1034 | // Test we recover from panic in Get for non-existent key 1035 | func() { 1036 | defer func() { 1037 | r := recover() 1038 | assertEqual(t, "IncMisses panic", r.(string)) 1039 | }() 1040 | _, _ = cache.Get("non-existent") 1041 | }() 1042 | // Cache should still be usable 1043 | _, exists = cache.Get("non-existent") 1044 | assertFalse(t, exists) 1045 | 1046 | // Test that we recover from panic in GetBranch 1047 | func() { 1048 | defer func() { 1049 | r := recover() 1050 | assertEqual(t, "IncHits panic", r.(string)) 1051 | }() 1052 | _ = cache.GetBranch("child2") 1053 | }() 1054 | // Cache should still be usable 1055 | branch := cache.GetBranch("child2") 1056 | assertEqual(t, 2, len(branch)) 1057 | 1058 | // Test that we recover from panic in AddOrUpdate 1059 | func() { 1060 | defer func() { 1061 | r := recover() 1062 | assertEqual(t, "SetAmount panic", r.(string)) 1063 | }() 1064 | _ = cache.AddOrUpdate("child3", 4, "root") 1065 | }() 1066 | // Cache should still be usable 1067 | assertNoError(t, cache.AddOrUpdate("child3", 5, "root")) 1068 | 1069 | // Test we recover from panic in Remove 1070 | func() { 1071 | defer func() { 1072 | r := recover() 1073 | assertEqual(t, "SetAmount panic", r.(string)) 1074 | }() 1075 | _ = cache.Remove("child3") 1076 | }() 1077 | // Cache should still be usable 1078 | count := cache.Remove("child3") 1079 | assertEqual(t, 0, count) 1080 | }) 1081 | } 1082 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package lrutree_test 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/vasayxtx/go-lrutree" 8 | ) 9 | 10 | func Example() { 11 | // Create a new geographic service with cache size of 100 12 | geoService := NewGeoService(100) 13 | 14 | // Build geographic hierarchy 15 | _ = geoService.AddLocation("usa", GeoData{Name: "United States", Type: "country"}, "") 16 | _ = geoService.AddLocation("ca", GeoData{Name: "California", Type: "state", TaxRate: 7.25, EmergencyStatus: true}, "usa") 17 | _ = geoService.AddLocation("sf_county", GeoData{Name: "San Francisco County", Type: "county", TaxRate: 0.25}, "ca") 18 | _ = geoService.AddLocation("sf_city", GeoData{Name: "San Francisco", Type: "city", TaxRate: 0.5}, "sf_county") 19 | _ = geoService.AddLocation("mission", GeoData{Name: "Mission District", Type: "district"}, "sf_city") 20 | _ = geoService.AddLocation("tx", GeoData{Name: "Texas", Type: "state", TaxRate: 6.25}, "usa") 21 | _ = geoService.AddLocation("austin", GeoData{Name: "Austin", Type: "city", TaxRate: 2.0}, "tx") 22 | 23 | printLocationInfo := func(locationID string) { 24 | path, _ := geoService.GetLocationPath(locationID) 25 | fmt.Printf("Location: %s\n", path) 26 | 27 | taxRate, _ := geoService.GetEffectiveTaxRate(locationID) 28 | fmt.Printf("Effective tax rate: %.2f%%\n", taxRate) 29 | 30 | emergency, source, _ := geoService.GetEmergencyStatus(locationID) 31 | if emergency { 32 | fmt.Printf("Emergency status: ACTIVE (declared in %s)\n", source) 33 | } else { 34 | fmt.Printf("Emergency status: NORMAL\n") 35 | } 36 | } 37 | 38 | printLocationInfo("mission") // Should have CA emergency and SF+County+State tax 39 | fmt.Println("-------------") 40 | printLocationInfo("austin") // Should have no emergency and TX+City tax 41 | 42 | // Output: 43 | // Location: United States > California > San Francisco County > San Francisco > Mission District 44 | // Effective tax rate: 8.00% 45 | // Emergency status: ACTIVE (declared in California) 46 | // ------------- 47 | // Location: United States > Texas > Austin 48 | // Effective tax rate: 8.25% 49 | // Emergency status: NORMAL 50 | } 51 | 52 | // GeoData represents information about a geographic location 53 | type GeoData struct { 54 | Name string 55 | Type string // country, state, county, city, district 56 | TaxRate float64 // local tax rate (percentage) 57 | EmergencyStatus bool // whether area is under emergency declaration 58 | } 59 | 60 | // GeoService wraps the LRU tree cache to provide specialized geographic operations 61 | type GeoService struct { 62 | cache *lrutree.Cache[string, GeoData] 63 | } 64 | 65 | const rootID = "earth" 66 | 67 | func NewGeoService(cacheSize int) *GeoService { 68 | cache := lrutree.NewCache[string, GeoData](cacheSize) 69 | _ = cache.AddRoot(rootID, GeoData{Name: "Earth"}) 70 | return &GeoService{cache: cache} 71 | } 72 | 73 | func (g *GeoService) AddLocation(id string, data GeoData, parentID string) error { 74 | // New location may be added to the database or another source and then added to the cache. 75 | if parentID == "" { 76 | parentID = rootID 77 | } 78 | return g.cache.Add(id, data, parentID) 79 | } 80 | 81 | func (g *GeoService) GetLocationPath(id string) (string, error) { 82 | branch := g.cache.GetBranch(id) 83 | if len(branch) == 0 { 84 | // Location is not cached, may be loaded from the database or another source and added to the cache. 85 | return "", fmt.Errorf("not found") 86 | } 87 | var path []string 88 | for _, node := range branch[1:] { // Skip the root node 89 | path = append(path, node.Value.Name) 90 | } 91 | return strings.Join(path, " > "), nil 92 | } 93 | 94 | // GetEmergencyStatus checks if a location or any of its parent jurisdictions 95 | // has declared an emergency 96 | func (g *GeoService) GetEmergencyStatus(id string) (emergency bool, source string, err error) { 97 | branch := g.cache.GetBranch(id) 98 | if len(branch) == 0 { 99 | // Location is not cached, may be loaded from the database or another source and added to the cache. 100 | return false, "", fmt.Errorf("not found") 101 | } 102 | 103 | // We sure that all ancestors are presented in the cache too, so we can just calculate the emergency status 104 | for _, node := range branch { 105 | if node.Value.EmergencyStatus { 106 | return true, node.Value.Name, nil 107 | } 108 | } 109 | return false, "", nil 110 | } 111 | 112 | // GetEffectiveTaxRate calculates the total tax rate for a location 113 | // by summing the tax rates from all its parent jurisdictions 114 | func (g *GeoService) GetEffectiveTaxRate(key string) (float64, error) { 115 | branch := g.cache.GetBranch(key) 116 | if len(branch) == 0 { 117 | // Location is not cached, may be loaded from the database or another source and added to the cache 118 | return 0, fmt.Errorf("not found") 119 | } 120 | 121 | // We sure that all ancestors are presented in the cache too, so we can just sum their tax rates 122 | totalRate := 0.0 123 | for _, node := range branch { 124 | totalRate += node.Value.TaxRate 125 | } 126 | return totalRate, nil 127 | } 128 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vasayxtx/go-lrutree 2 | 3 | go 1.20 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vasayxtx/go-lrutree/af16cd1dfb09d0de1f61b49e3101d7cfca096991/go.sum -------------------------------------------------------------------------------- /helpers_test.go: -------------------------------------------------------------------------------- 1 | package lrutree 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func assertEqual(t *testing.T, expected, actual interface{}) { 10 | t.Helper() 11 | if !reflect.DeepEqual(expected, actual) { 12 | t.Fatalf("Not equal: \nexpected: %v\nactual : %v\n", expected, actual) 13 | } 14 | } 15 | 16 | func assertNoError(t *testing.T, err error) { 17 | t.Helper() 18 | if err != nil { 19 | t.Fatalf("Received unexpected error: %v\n", err) 20 | } 21 | } 22 | 23 | func assertErrorIs(t *testing.T, err, expectedErr error) { 24 | t.Helper() 25 | if err == nil { 26 | t.Fatalf("Expected error: %v, got nil\n", expectedErr) 27 | } 28 | if !errors.Is(err, expectedErr) { 29 | t.Fatalf("Expected error: %v, got: %v\n", expectedErr, err) 30 | } 31 | } 32 | 33 | func assertTrue(t *testing.T, value bool) { 34 | t.Helper() 35 | if !value { 36 | t.Fatalf("Expected true but got false\n") 37 | } 38 | } 39 | 40 | func assertFalse(t *testing.T, value bool) { 41 | t.Helper() 42 | if value { 43 | t.Fatalf("Expected false but got true\n") 44 | } 45 | } 46 | 47 | func assertNil(t *testing.T, value interface{}) { 48 | t.Helper() 49 | if value != nil && !reflect.ValueOf(value).IsNil() { 50 | t.Fatalf("Expected nil, got: %v\n", value) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /min-coverage: -------------------------------------------------------------------------------- 1 | 95 --------------------------------------------------------------------------------