Code review fixes for cache eviction (bd-145)
Oracle-recommended improvements: **Thread Safety:** - Fix lastAccess race: updates now under Write lock - Make Stop() idempotent with sync.Once - Close stores synchronously (not in goroutine) **Performance:** - Replace O(n²) sort with sort.Slice (O(n log n)) - Enforce LRU immediately on insert (prevents FD spikes) **Correctness:** - Canonicalize cache key to repo root (not cwd) - Prevents duplicate connections for same repo - Multiple subdirs → single cache entry - Validate env vars: TTL <= 0 falls back to default **Tests (6 new edge cases):** - Canonical key behavior across subdirectories - Immediate LRU enforcement without periodic cleanup - Invalid TTL handling - Re-open after eviction - Stop idempotency - All tests pass with -race flag This addresses potential data races, resource spikes, and duplicate connections identified during code review.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -305,3 +306,220 @@ func TestStorageCacheEviction_CleanupOnStop(t *testing.T) {
|
||||
t.Errorf("expected cache to be cleared on stop, got %d entries", cacheSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageCacheEviction_CanonicalKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create main DB
|
||||
mainDB := filepath.Join(tmpDir, "main.db")
|
||||
mainStore, err := sqlite.New(mainDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mainStore.Close()
|
||||
|
||||
// Create server
|
||||
socketPath := filepath.Join(tmpDir, "test.sock")
|
||||
server := NewServer(socketPath, mainStore)
|
||||
defer server.Stop()
|
||||
|
||||
// Create test database
|
||||
dbPath := filepath.Join(tmpDir, "repo1", ".beads", "issues.db")
|
||||
os.MkdirAll(filepath.Dir(dbPath), 0755)
|
||||
store, err := sqlite.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Close()
|
||||
|
||||
// Access from different subdirectories of the same repo
|
||||
req1 := &Request{Cwd: filepath.Join(tmpDir, "repo1")}
|
||||
_, err = server.getStorageForRequest(req1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req2 := &Request{Cwd: filepath.Join(tmpDir, "repo1", "subdir1")}
|
||||
_, err = server.getStorageForRequest(req2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req3 := &Request{Cwd: filepath.Join(tmpDir, "repo1", "subdir1", "subdir2")}
|
||||
_, err = server.getStorageForRequest(req3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Should only have one cache entry (all pointing to same repo root)
|
||||
server.cacheMu.RLock()
|
||||
cacheSize := len(server.storageCache)
|
||||
server.cacheMu.RUnlock()
|
||||
if cacheSize != 1 {
|
||||
t.Errorf("expected 1 cached entry (canonical key), got %d", cacheSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageCacheEviction_ImmediateLRU(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create main DB
|
||||
mainDB := filepath.Join(tmpDir, "main.db")
|
||||
mainStore, err := sqlite.New(mainDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mainStore.Close()
|
||||
|
||||
// Create server with max cache size of 2
|
||||
socketPath := filepath.Join(tmpDir, "test.sock")
|
||||
server := NewServer(socketPath, mainStore)
|
||||
server.maxCacheSize = 2
|
||||
server.cacheTTL = 1 * time.Hour // Long TTL
|
||||
defer server.Stop()
|
||||
|
||||
// Create 3 test databases
|
||||
for i := 1; i <= 3; i++ {
|
||||
dbPath := filepath.Join(tmpDir, fmt.Sprintf("repo%d", i), ".beads", "issues.db")
|
||||
os.MkdirAll(filepath.Dir(dbPath), 0755)
|
||||
store, err := sqlite.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Close()
|
||||
}
|
||||
|
||||
// Access all 3 repos
|
||||
for i := 1; i <= 3; i++ {
|
||||
req := &Request{Cwd: filepath.Join(tmpDir, fmt.Sprintf("repo%d", i))}
|
||||
_, err = server.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond) // Ensure different timestamps
|
||||
}
|
||||
|
||||
// Cache should never exceed maxCacheSize (immediate LRU enforcement)
|
||||
server.cacheMu.RLock()
|
||||
cacheSize := len(server.storageCache)
|
||||
server.cacheMu.RUnlock()
|
||||
if cacheSize > server.maxCacheSize {
|
||||
t.Errorf("cache size %d exceeds max %d (immediate LRU not enforced)", cacheSize, server.maxCacheSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageCacheEviction_InvalidTTL(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create main DB
|
||||
mainDB := filepath.Join(tmpDir, "main.db")
|
||||
mainStore, err := sqlite.New(mainDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mainStore.Close()
|
||||
|
||||
// Set invalid TTL
|
||||
os.Setenv("BEADS_DAEMON_CACHE_TTL", "-5m")
|
||||
defer os.Unsetenv("BEADS_DAEMON_CACHE_TTL")
|
||||
|
||||
// Create server
|
||||
socketPath := filepath.Join(tmpDir, "test.sock")
|
||||
server := NewServer(socketPath, mainStore)
|
||||
defer server.Stop()
|
||||
|
||||
// Should fall back to default (30 minutes)
|
||||
expectedTTL := 30 * time.Minute
|
||||
if server.cacheTTL != expectedTTL {
|
||||
t.Errorf("expected TTL to fall back to %v for invalid value, got %v", expectedTTL, server.cacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageCacheEviction_ReopenAfterEviction(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create main DB
|
||||
mainDB := filepath.Join(tmpDir, "main.db")
|
||||
mainStore, err := sqlite.New(mainDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mainStore.Close()
|
||||
|
||||
// Create server with short TTL
|
||||
socketPath := filepath.Join(tmpDir, "test.sock")
|
||||
server := NewServer(socketPath, mainStore)
|
||||
server.cacheTTL = 50 * time.Millisecond
|
||||
defer server.Stop()
|
||||
|
||||
// Create test database
|
||||
dbPath := filepath.Join(tmpDir, "repo1", ".beads", "issues.db")
|
||||
os.MkdirAll(filepath.Dir(dbPath), 0755)
|
||||
store, err := sqlite.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Close()
|
||||
|
||||
// Access repo
|
||||
req := &Request{Cwd: filepath.Join(tmpDir, "repo1")}
|
||||
_, err = server.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Wait for TTL to expire
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Evict
|
||||
server.evictStaleStorage()
|
||||
|
||||
// Verify evicted
|
||||
server.cacheMu.RLock()
|
||||
cacheSize := len(server.storageCache)
|
||||
server.cacheMu.RUnlock()
|
||||
if cacheSize != 0 {
|
||||
t.Fatalf("expected cache to be empty after eviction, got %d", cacheSize)
|
||||
}
|
||||
|
||||
// Access again - should cleanly re-open
|
||||
_, err = server.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to re-open after eviction: %v", err)
|
||||
}
|
||||
|
||||
// Verify re-cached
|
||||
server.cacheMu.RLock()
|
||||
cacheSize = len(server.storageCache)
|
||||
server.cacheMu.RUnlock()
|
||||
if cacheSize != 1 {
|
||||
t.Errorf("expected 1 cached entry after re-open, got %d", cacheSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageCacheEviction_StopIdempotent(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create main DB
|
||||
mainDB := filepath.Join(tmpDir, "main.db")
|
||||
mainStore, err := sqlite.New(mainDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mainStore.Close()
|
||||
|
||||
// Create server
|
||||
socketPath := filepath.Join(tmpDir, "test.sock")
|
||||
server := NewServer(socketPath, mainStore)
|
||||
|
||||
// Stop multiple times - should not panic
|
||||
if err := server.Stop(); err != nil {
|
||||
t.Fatalf("first Stop failed: %v", err)
|
||||
}
|
||||
if err := server.Stop(); err != nil {
|
||||
t.Fatalf("second Stop failed: %v", err)
|
||||
}
|
||||
if err := server.Stop(); err != nil {
|
||||
t.Fatalf("third Stop failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user