feat: add LRU cache for parsed profiles
This commit is contained in:
parent
392c0f71aa
commit
8d41653be4
2 changed files with 133 additions and 0 deletions
75
internal/cache/lru.go
vendored
Normal file
75
internal/cache/lru.go
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind"
|
||||
)
|
||||
|
||||
const DefaultCapacity = 2
|
||||
|
||||
type entry struct {
|
||||
key string
|
||||
profile *cachegrind.Profile
|
||||
modTime time.Time
|
||||
elem *list.Element
|
||||
}
|
||||
|
||||
// Cache is a thread-safe LRU cache keyed by absolute file path.
|
||||
type Cache struct {
|
||||
mu sync.Mutex
|
||||
capacity int
|
||||
items map[string]*entry
|
||||
order list.List
|
||||
}
|
||||
|
||||
func New(capacity int) *Cache {
|
||||
return &Cache{
|
||||
capacity: capacity,
|
||||
items: make(map[string]*entry),
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the cached Profile if key exists and modTime matches.
|
||||
func (c *Cache) Get(key string, modTime time.Time) (*cachegrind.Profile, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
e, ok := c.items[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if !e.modTime.Equal(modTime) {
|
||||
c.evict(e)
|
||||
return nil, false
|
||||
}
|
||||
c.order.MoveToFront(e.elem)
|
||||
return e.profile, true
|
||||
}
|
||||
|
||||
// Set stores a Profile, evicting the least-recently-used entry if at capacity.
|
||||
func (c *Cache) Set(key string, profile *cachegrind.Profile, modTime time.Time) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if e, ok := c.items[key]; ok {
|
||||
c.evict(e)
|
||||
}
|
||||
for len(c.items) >= c.capacity {
|
||||
back := c.order.Back()
|
||||
if back == nil {
|
||||
break
|
||||
}
|
||||
c.evict(back.Value.(*entry))
|
||||
}
|
||||
e := &entry{key: key, profile: profile, modTime: modTime}
|
||||
e.elem = c.order.PushFront(e)
|
||||
c.items[key] = e
|
||||
}
|
||||
|
||||
func (c *Cache) evict(e *entry) {
|
||||
c.order.Remove(e.elem)
|
||||
delete(c.items, e.key)
|
||||
}
|
||||
58
internal/cache/lru_test.go
vendored
Normal file
58
internal/cache/lru_test.go
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package cache_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"forge.lclr.dev/AI/xdebug-mcp/internal/cache"
|
||||
"forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind"
|
||||
)
|
||||
|
||||
func TestLRU_GetSet(t *testing.T) {
|
||||
c := cache.New(2)
|
||||
t0 := time.Now()
|
||||
p := &cachegrind.Profile{Cmd: "p1"}
|
||||
|
||||
c.Set("/a", p, t0)
|
||||
got, ok := c.Get("/a", t0)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, p, got)
|
||||
}
|
||||
|
||||
func TestLRU_Eviction(t *testing.T) {
|
||||
c := cache.New(2)
|
||||
t0 := time.Now()
|
||||
|
||||
p1 := &cachegrind.Profile{Cmd: "p1"}
|
||||
p2 := &cachegrind.Profile{Cmd: "p2"}
|
||||
p3 := &cachegrind.Profile{Cmd: "p3"}
|
||||
|
||||
c.Set("/a", p1, t0)
|
||||
c.Set("/b", p2, t0)
|
||||
c.Get("/a", t0) // access /a → /b becomes LRU
|
||||
c.Set("/c", p3, t0) // should evict /b
|
||||
|
||||
_, ok := c.Get("/b", t0)
|
||||
assert.False(t, ok, "b should have been evicted")
|
||||
|
||||
got, ok := c.Get("/a", t0)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, p1, got)
|
||||
|
||||
got, ok = c.Get("/c", t0)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, p3, got)
|
||||
}
|
||||
|
||||
func TestLRU_ModtimeInvalidation(t *testing.T) {
|
||||
c := cache.New(2)
|
||||
t1 := time.Now()
|
||||
t2 := t1.Add(time.Second)
|
||||
|
||||
c.Set("/a", &cachegrind.Profile{Cmd: "old"}, t1)
|
||||
_, ok := c.Get("/a", t2) // newer modtime → stale
|
||||
assert.False(t, ok)
|
||||
}
|
||||
Loading…
Reference in a new issue