Skip to content

Commit 581c401

Browse files
aeftrootfs
andauthored
feat: add basic cache eviction policy: LRU/LFU/FIFO (#166)
* feat: add basic cache eviction policy: LRU/LFU/FIFO Signed-off-by: Alex Wang <[email protected]> * use EvictionPolicyType Signed-off-by: Alex Wang <[email protected]> * update doc Signed-off-by: Alex Wang <[email protected]> --------- Signed-off-by: Alex Wang <[email protected]> Co-authored-by: Huamin Chen <[email protected]>
1 parent 87f2abd commit 581c401

File tree

11 files changed

+463
-53
lines changed

11 files changed

+463
-53
lines changed

config/config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ semantic_cache:
88
similarity_threshold: 0.8
99
max_entries: 1000 # Only applies to memory backend
1010
ttl_seconds: 3600
11+
eviction_policy: "fifo" # "fifo", "lru", "lfu", currently only supports memory backend
1112

1213
# For production environments, use Milvus for scalable caching:
1314
# backend_type: "milvus"

src/semantic-router/pkg/cache/cache_factory.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func NewCacheBackend(config CacheConfig) (CacheBackend, error) {
2727
SimilarityThreshold: config.SimilarityThreshold,
2828
MaxEntries: config.MaxEntries,
2929
TTLSeconds: config.TTLSeconds,
30+
EvictionPolicy: config.EvictionPolicy,
3031
}
3132
return NewInMemoryCache(options), nil
3233

src/semantic-router/pkg/cache/cache_interface.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ type CacheEntry struct {
1010
Model string
1111
Query string
1212
Embedding []float32
13-
Timestamp time.Time
13+
Timestamp time.Time // Creation time (when the entry was added or completed with a response)
14+
LastAccessAt time.Time // Last access time
15+
HitCount int64 // Access count
1416
}
1517

1618
// CacheBackend defines the interface for semantic cache implementations
@@ -58,6 +60,20 @@ const (
5860
MilvusCacheType CacheBackendType = "milvus"
5961
)
6062

63+
// EvictionPolicyType defines the available eviction policies
64+
type EvictionPolicyType string
65+
66+
const (
67+
// FIFOEvictionPolicyType specifies the FIFO eviction policy
68+
FIFOEvictionPolicyType EvictionPolicyType = "fifo"
69+
70+
// LRUEvictionPolicyType specifies the LRU eviction policy
71+
LRUEvictionPolicyType EvictionPolicyType = "lru"
72+
73+
// LFUEvictionPolicyType specifies the LFU eviction policy
74+
LFUEvictionPolicyType EvictionPolicyType = "lfu"
75+
)
76+
6177
// CacheConfig contains configuration settings shared across all cache backends
6278
type CacheConfig struct {
6379
// BackendType specifies which cache implementation to use
@@ -75,6 +91,9 @@ type CacheConfig struct {
7591
// TTLSeconds sets cache entry expiration time (0 disables expiration)
7692
TTLSeconds int `yaml:"ttl_seconds,omitempty"`
7793

94+
// EvictionPolicy defines the eviction policy for in-memory cache ("fifo", "lru", "lfu")
95+
EvictionPolicy EvictionPolicyType `yaml:"eviction_policy,omitempty"`
96+
7897
// BackendConfigPath points to backend-specific configuration files
7998
BackendConfigPath string `yaml:"backend_config_path,omitempty"`
8099
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package cache
2+
3+
type EvictionPolicy interface {
4+
SelectVictim(entries []CacheEntry) int
5+
}
6+
7+
type FIFOPolicy struct{}
8+
9+
func (p *FIFOPolicy) SelectVictim(entries []CacheEntry) int {
10+
if len(entries) == 0 {
11+
return -1
12+
}
13+
14+
oldestIdx := 0
15+
for i := 1; i < len(entries); i++ {
16+
if entries[i].Timestamp.Before(entries[oldestIdx].Timestamp) {
17+
oldestIdx = i
18+
}
19+
}
20+
return oldestIdx
21+
}
22+
23+
type LRUPolicy struct{}
24+
25+
func (p *LRUPolicy) SelectVictim(entries []CacheEntry) int {
26+
if len(entries) == 0 {
27+
return -1
28+
}
29+
30+
oldestIdx := 0
31+
for i := 1; i < len(entries); i++ {
32+
if entries[i].LastAccessAt.Before(entries[oldestIdx].LastAccessAt) {
33+
oldestIdx = i
34+
}
35+
}
36+
return oldestIdx
37+
}
38+
39+
type LFUPolicy struct{}
40+
41+
func (p *LFUPolicy) SelectVictim(entries []CacheEntry) int {
42+
if len(entries) == 0 {
43+
return -1
44+
}
45+
46+
victimIdx := 0
47+
for i := 1; i < len(entries); i++ {
48+
if entries[i].HitCount < entries[victimIdx].HitCount {
49+
victimIdx = i
50+
} else if entries[i].HitCount == entries[victimIdx].HitCount {
51+
// Use LRU as tiebreaker to avoid random selection
52+
if entries[i].LastAccessAt.Before(entries[victimIdx].LastAccessAt) {
53+
victimIdx = i
54+
}
55+
}
56+
}
57+
return victimIdx
58+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package cache
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestFIFOPolicy(t *testing.T) {
9+
policy := &FIFOPolicy{}
10+
11+
// Test empty entries
12+
if victim := policy.SelectVictim([]CacheEntry{}); victim != -1 {
13+
t.Errorf("Expected -1 for empty entries, got %d", victim)
14+
}
15+
16+
// Test with entries
17+
now := time.Now()
18+
entries := []CacheEntry{
19+
{Query: "query1", Timestamp: now.Add(-3 * time.Second)},
20+
{Query: "query2", Timestamp: now.Add(-1 * time.Second)},
21+
{Query: "query3", Timestamp: now.Add(-2 * time.Second)},
22+
}
23+
24+
victim := policy.SelectVictim(entries)
25+
if victim != 0 {
26+
t.Errorf("Expected victim index 0 (oldest), got %d", victim)
27+
}
28+
}
29+
30+
func TestLRUPolicy(t *testing.T) {
31+
policy := &LRUPolicy{}
32+
33+
// Test empty entries
34+
if victim := policy.SelectVictim([]CacheEntry{}); victim != -1 {
35+
t.Errorf("Expected -1 for empty entries, got %d", victim)
36+
}
37+
38+
// Test with entries
39+
now := time.Now()
40+
entries := []CacheEntry{
41+
{Query: "query1", LastAccessAt: now.Add(-3 * time.Second)},
42+
{Query: "query2", LastAccessAt: now.Add(-1 * time.Second)},
43+
{Query: "query3", LastAccessAt: now.Add(-2 * time.Second)},
44+
}
45+
46+
victim := policy.SelectVictim(entries)
47+
if victim != 0 {
48+
t.Errorf("Expected victim index 0 (least recently used), got %d", victim)
49+
}
50+
}
51+
52+
func TestLFUPolicy(t *testing.T) {
53+
policy := &LFUPolicy{}
54+
55+
// Test empty entries
56+
if victim := policy.SelectVictim([]CacheEntry{}); victim != -1 {
57+
t.Errorf("Expected -1 for empty entries, got %d", victim)
58+
}
59+
60+
// Test with entries
61+
now := time.Now()
62+
entries := []CacheEntry{
63+
{Query: "query1", HitCount: 5, LastAccessAt: now.Add(-2 * time.Second)},
64+
{Query: "query2", HitCount: 1, LastAccessAt: now.Add(-3 * time.Second)},
65+
{Query: "query3", HitCount: 3, LastAccessAt: now.Add(-1 * time.Second)},
66+
}
67+
68+
victim := policy.SelectVictim(entries)
69+
if victim != 1 {
70+
t.Errorf("Expected victim index 1 (least frequently used), got %d", victim)
71+
}
72+
}
73+
74+
func TestLFUPolicyTiebreaker(t *testing.T) {
75+
policy := &LFUPolicy{}
76+
77+
// Test tiebreaker: same frequency, choose least recently used
78+
now := time.Now()
79+
entries := []CacheEntry{
80+
{Query: "query1", HitCount: 2, LastAccessAt: now.Add(-1 * time.Second)},
81+
{Query: "query2", HitCount: 2, LastAccessAt: now.Add(-3 * time.Second)},
82+
{Query: "query3", HitCount: 5, LastAccessAt: now.Add(-2 * time.Second)},
83+
}
84+
85+
victim := policy.SelectVictim(entries)
86+
if victim != 1 {
87+
t.Errorf("Expected victim index 1 (LRU tiebreaker), got %d", victim)
88+
}
89+
}

0 commit comments

Comments
 (0)