From 6f6de4598feac7d54217d9e2d2222de39f674414 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 23 Feb 2026 19:42:40 +0800 Subject: [PATCH] Optimize memory and cache performance\n\n1. SearchCache: Convert from slice-based LRU to doubly-linked list for O(1) operations\n2. MemoryStore: Add concurrency safety with sync.RWMutex\n3. MemoryStore: Add cache invalidation based on file modification time\n4. Add safety comment for map deletion during iteration --- pkg/agent/memory.go | 137 ++++++++++++++++++++++++++++++++++--- pkg/skills/search_cache.go | 115 ++++++++++++++++++++++--------- 2 files changed, 207 insertions(+), 45 deletions(-) diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 01e682f3b..1affc12c8 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/sipeed/picoclaw/pkg/fileutil" @@ -19,10 +20,24 @@ import ( // MemoryStore manages persistent memory for the agent. // - Long-term memory: memory/MEMORY.md // - Daily notes: memory/YYYYMM/YYYYMMDD.md +// - In-memory cache to reduce file I/O type MemoryStore struct { - workspace string - memoryDir string - memoryFile string + workspace string + memoryDir string + memoryFile string + longTermCache string + todayCache string + todayCacheKey string + // Concurrency safety + mu sync.RWMutex + // File modification times for cache invalidation + longTermMtime time.Time + todayMtime time.Time +} + +// getCacheKey returns a cache key based on the current date. +func (ms *MemoryStore) getCacheKey() string { + return time.Now().Format("20060102") // YYYYMMDD } // NewMemoryStore creates a new MemoryStore with the given workspace path. @@ -51,34 +66,111 @@ func (ms *MemoryStore) getTodayFile() string { // ReadLongTerm reads the long-term memory (MEMORY.md). // Returns empty string if the file doesn't exist. +// Uses in-memory cache to reduce file I/O, but checks file mtime for cache invalidation. func (ms *MemoryStore) ReadLongTerm() string { - if data, err := os.ReadFile(ms.memoryFile); err == nil { - return string(data) + ms.mu.RLock() + cache := ms.longTermCache + mtime := ms.longTermMtime + ms.mu.RUnlock() + + // Check file modification time to invalidate cache if file was edited externally + if cache != "" { + if info, err := os.Stat(ms.memoryFile); err == nil { + if info.ModTime().After(mtime) { + // File was modified externally, invalidate cache + cache = "" + } + } } + + if cache != "" { + return cache + } + + // Read from file and update cache + if data, err := os.ReadFile(ms.memoryFile); err == nil { + content := string(data) + ms.mu.Lock() + ms.longTermCache = content + if info, err := os.Stat(ms.memoryFile); err == nil { + ms.longTermMtime = info.ModTime() + } + ms.mu.Unlock() + return content + } + return "" } // WriteLongTerm writes content to the long-term memory file (MEMORY.md). +// Also updates the in-memory cache. func (ms *MemoryStore) WriteLongTerm(content string) error { // Use unified atomic write utility with explicit sync for flash storage reliability. // Using 0o600 (owner read/write only) for secure default permissions. - return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600) + if err := fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600); err != nil { + return err + } + + // Update cache on successful write + ms.mu.Lock() + ms.longTermCache = content + if info, err := os.Stat(ms.memoryFile); err == nil { + ms.longTermMtime = info.ModTime() + } + ms.mu.Unlock() + return nil } // ReadToday reads today's daily note. // Returns empty string if the file doesn't exist. +// Uses in-memory cache to reduce file I/O, but checks file mtime for cache invalidation. func (ms *MemoryStore) ReadToday() string { + todayKey := ms.getCacheKey() todayFile := ms.getTodayFile() - if data, err := os.ReadFile(todayFile); err == nil { - return string(data) + + ms.mu.RLock() + cacheKey := ms.todayCacheKey + cache := ms.todayCache + mtime := ms.todayMtime + ms.mu.RUnlock() + + // Check if cache is valid for today and not expired + if cacheKey == todayKey && cache != "" { + // Check file modification time to invalidate cache if file was edited externally + if info, err := os.Stat(todayFile); err == nil { + if info.ModTime().After(mtime) { + // File was modified externally, invalidate cache + cache = "" + } + } } + + if cache != "" { + return cache + } + + // Read from file and update cache + if data, err := os.ReadFile(todayFile); err == nil { + content := string(data) + ms.mu.Lock() + ms.todayCache = content + ms.todayCacheKey = todayKey + if info, err := os.Stat(todayFile); err == nil { + ms.todayMtime = info.ModTime() + } + ms.mu.Unlock() + return content + } + return "" } // AppendToday appends content to today's daily note. // If the file doesn't exist, it creates a new file with a date header. +// Also updates the in-memory cache. func (ms *MemoryStore) AppendToday(content string) error { todayFile := ms.getTodayFile() + todayKey := ms.getCacheKey() // Ensure month directory exists monthDir := filepath.Dir(todayFile) @@ -86,9 +178,20 @@ func (ms *MemoryStore) AppendToday(content string) error { return err } + // Get existing content from cache or file var existingContent string - if data, err := os.ReadFile(todayFile); err == nil { - existingContent = string(data) + ms.mu.RLock() + cacheKey := ms.todayCacheKey + if cacheKey == todayKey { + existingContent = ms.todayCache + } + ms.mu.RUnlock() + + // Fallback to file if cache is not valid + if existingContent == "" { + if data, err := os.ReadFile(todayFile); err == nil { + existingContent = string(data) + } } var newContent string @@ -102,7 +205,19 @@ func (ms *MemoryStore) AppendToday(content string) error { } // Use unified atomic write utility with explicit sync for flash storage reliability. - return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600) + if err := fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600); err != nil { + return err + } + + // Update cache on successful write + ms.mu.Lock() + ms.todayCache = newContent + ms.todayCacheKey = todayKey + if info, err := os.Stat(todayFile); err == nil { + ms.todayMtime = info.ModTime() + } + ms.mu.Unlock() + return nil } // GetRecentDailyNotes returns daily notes from the last N days. diff --git a/pkg/skills/search_cache.go b/pkg/skills/search_cache.go index 1686e3f98..6d6d0131c 100644 --- a/pkg/skills/search_cache.go +++ b/pkg/skills/search_cache.go @@ -13,9 +13,11 @@ import ( type SearchCache struct { mu sync.RWMutex entries map[string]*cacheEntry - order []string // LRU order: oldest first. maxEntries int ttl time.Duration + // LRU linked list implementation + head *cacheEntry // Oldest entry + tail *cacheEntry // Newest entry } type cacheEntry struct { @@ -23,6 +25,9 @@ type cacheEntry struct { trigrams []uint32 results []SearchResult createdAt time.Time + // LRU linked list pointers + prev *cacheEntry + next *cacheEntry } // similarityThreshold is the minimum trigram Jaccard similarity for a cache hit. @@ -40,9 +45,10 @@ func NewSearchCache(maxEntries int, ttl time.Duration) *SearchCache { } return &SearchCache{ entries: make(map[string]*cacheEntry), - order: make([]string, 0), maxEntries: maxEntries, ttl: ttl, + head: nil, + tail: nil, } } @@ -60,9 +66,12 @@ func (sc *SearchCache) Get(query string) ([]SearchResult, bool) { // Exact match first. if entry, ok := sc.entries[normalized]; ok { if time.Since(entry.createdAt) < sc.ttl { - sc.moveToEndLocked(normalized) + sc.moveToTailLocked(entry) return copyResults(entry.results), true } + // Remove expired entry + sc.removeEntryLocked(entry) + delete(sc.entries, normalized) } // Similarity match. @@ -70,8 +79,13 @@ func (sc *SearchCache) Get(query string) ([]SearchResult, bool) { var bestEntry *cacheEntry var bestSim float64 - for _, entry := range sc.entries { + for key, entry := range sc.entries { if time.Since(entry.createdAt) >= sc.ttl { + // Remove expired entry + // Note: In Go, deleting from a map during range iteration is safe + // The iteration continues over the remaining elements + sc.removeEntryLocked(entry) + delete(sc.entries, key) continue // Skip expired. } sim := jaccardSimilarity(queryTrigrams, entry.trigrams) @@ -82,7 +96,7 @@ func (sc *SearchCache) Get(query string) ([]SearchResult, bool) { } if bestSim >= similarityThreshold && bestEntry != nil { - sc.moveToEndLocked(bestEntry.query) + sc.moveToTailLocked(bestEntry) return copyResults(bestEntry.results), true } @@ -103,33 +117,34 @@ func (sc *SearchCache) Put(query string, results []SearchResult) { sc.evictExpiredLocked() // If already exists, update. - if _, ok := sc.entries[normalized]; ok { - sc.entries[normalized] = &cacheEntry{ - query: normalized, - trigrams: buildTrigrams(normalized), - results: copyResults(results), - createdAt: time.Now(), - } + if entry, ok := sc.entries[normalized]; ok { + // Update entry + entry.trigrams = buildTrigrams(normalized) + entry.results = copyResults(results) + entry.createdAt = time.Now() // Move to end of LRU order. - sc.moveToEndLocked(normalized) + sc.moveToTailLocked(entry) return } // Evict LRU if at capacity. - for len(sc.entries) >= sc.maxEntries && len(sc.order) > 0 { - oldest := sc.order[0] - sc.order = sc.order[1:] - delete(sc.entries, oldest) + for len(sc.entries) >= sc.maxEntries && sc.head != nil { + oldest := sc.head + sc.removeEntryLocked(oldest) + delete(sc.entries, oldest.query) } // Insert new entry. - sc.entries[normalized] = &cacheEntry{ + newEntry := &cacheEntry{ query: normalized, trigrams: buildTrigrams(normalized), results: copyResults(results), createdAt: time.Now(), + prev: nil, + next: nil, } - sc.order = append(sc.order, normalized) + sc.entries[normalized] = newEntry + sc.addToTailLocked(newEntry) } // Len returns the number of entries (for testing). @@ -143,26 +158,58 @@ func (sc *SearchCache) Len() int { func (sc *SearchCache) evictExpiredLocked() { now := time.Now() - newOrder := make([]string, 0, len(sc.order)) - for _, key := range sc.order { - entry, ok := sc.entries[key] - if !ok || now.Sub(entry.createdAt) >= sc.ttl { - delete(sc.entries, key) - continue + current := sc.head + for current != nil { + next := current.next + if now.Sub(current.createdAt) >= sc.ttl { + sc.removeEntryLocked(current) + delete(sc.entries, current.query) } - newOrder = append(newOrder, key) + current = next } - sc.order = newOrder } -func (sc *SearchCache) moveToEndLocked(key string) { - for i, k := range sc.order { - if k == key { - sc.order = append(sc.order[:i], sc.order[i+1:]...) - break - } +// addToTailLocked adds an entry to the tail of the LRU list +func (sc *SearchCache) addToTailLocked(entry *cacheEntry) { + if sc.tail == nil { + // List is empty + sc.head = entry + sc.tail = entry + } else { + // Add to tail + sc.tail.next = entry + entry.prev = sc.tail + sc.tail = entry } - sc.order = append(sc.order, key) +} + +// removeEntryLocked removes an entry from the LRU list +func (sc *SearchCache) removeEntryLocked(entry *cacheEntry) { + if entry.prev != nil { + entry.prev.next = entry.next + } else { + // Entry is head + sc.head = entry.next + } + + if entry.next != nil { + entry.next.prev = entry.prev + } else { + // Entry is tail + sc.tail = entry.prev + } + + // Clear pointers + entry.prev = nil + entry.next = nil +} + +// moveToTailLocked moves an entry to the tail of the LRU list +func (sc *SearchCache) moveToTailLocked(entry *cacheEntry) { + // Remove from current position + sc.removeEntryLocked(entry) + // Add to tail + sc.addToTailLocked(entry) } func normalizeQuery(q string) string {