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

This commit is contained in:
Your Name 2026-02-23 19:42:40 +08:00 committed by Liang Zhang
parent b8f8e3f25f
commit 6f6de4598f
2 changed files with 207 additions and 45 deletions

View file

@ -11,6 +11,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
@ -19,10 +20,24 @@ import (
// MemoryStore manages persistent memory for the agent. // MemoryStore manages persistent memory for the agent.
// - Long-term memory: memory/MEMORY.md // - Long-term memory: memory/MEMORY.md
// - Daily notes: memory/YYYYMM/YYYYMMDD.md // - Daily notes: memory/YYYYMM/YYYYMMDD.md
// - In-memory cache to reduce file I/O
type MemoryStore struct { type MemoryStore struct {
workspace string workspace string
memoryDir string memoryDir string
memoryFile 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. // 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). // ReadLongTerm reads the long-term memory (MEMORY.md).
// Returns empty string if the file doesn't exist. // 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 { func (ms *MemoryStore) ReadLongTerm() string {
if data, err := os.ReadFile(ms.memoryFile); err == nil { ms.mu.RLock()
return string(data) 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 "" return ""
} }
// WriteLongTerm writes content to the long-term memory file (MEMORY.md). // WriteLongTerm writes content to the long-term memory file (MEMORY.md).
// Also updates the in-memory cache.
func (ms *MemoryStore) WriteLongTerm(content string) error { func (ms *MemoryStore) WriteLongTerm(content string) error {
// Use unified atomic write utility with explicit sync for flash storage reliability. // Use unified atomic write utility with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions. // 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. // ReadToday reads today's daily note.
// Returns empty string if the file doesn't exist. // 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 { func (ms *MemoryStore) ReadToday() string {
todayKey := ms.getCacheKey()
todayFile := ms.getTodayFile() 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 "" return ""
} }
// AppendToday appends content to today's daily note. // AppendToday appends content to today's daily note.
// If the file doesn't exist, it creates a new file with a date header. // 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 { func (ms *MemoryStore) AppendToday(content string) error {
todayFile := ms.getTodayFile() todayFile := ms.getTodayFile()
todayKey := ms.getCacheKey()
// Ensure month directory exists // Ensure month directory exists
monthDir := filepath.Dir(todayFile) monthDir := filepath.Dir(todayFile)
@ -86,10 +178,21 @@ func (ms *MemoryStore) AppendToday(content string) error {
return err return err
} }
// Get existing content from cache or file
var existingContent string var existingContent string
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 { if data, err := os.ReadFile(todayFile); err == nil {
existingContent = string(data) existingContent = string(data)
} }
}
var newContent string var newContent string
if existingContent == "" { if existingContent == "" {
@ -102,7 +205,19 @@ func (ms *MemoryStore) AppendToday(content string) error {
} }
// Use unified atomic write utility with explicit sync for flash storage reliability. // 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. // GetRecentDailyNotes returns daily notes from the last N days.

View file

@ -13,9 +13,11 @@ import (
type SearchCache struct { type SearchCache struct {
mu sync.RWMutex mu sync.RWMutex
entries map[string]*cacheEntry entries map[string]*cacheEntry
order []string // LRU order: oldest first.
maxEntries int maxEntries int
ttl time.Duration ttl time.Duration
// LRU linked list implementation
head *cacheEntry // Oldest entry
tail *cacheEntry // Newest entry
} }
type cacheEntry struct { type cacheEntry struct {
@ -23,6 +25,9 @@ type cacheEntry struct {
trigrams []uint32 trigrams []uint32
results []SearchResult results []SearchResult
createdAt time.Time createdAt time.Time
// LRU linked list pointers
prev *cacheEntry
next *cacheEntry
} }
// similarityThreshold is the minimum trigram Jaccard similarity for a cache hit. // 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{ return &SearchCache{
entries: make(map[string]*cacheEntry), entries: make(map[string]*cacheEntry),
order: make([]string, 0),
maxEntries: maxEntries, maxEntries: maxEntries,
ttl: ttl, ttl: ttl,
head: nil,
tail: nil,
} }
} }
@ -60,9 +66,12 @@ func (sc *SearchCache) Get(query string) ([]SearchResult, bool) {
// Exact match first. // Exact match first.
if entry, ok := sc.entries[normalized]; ok { if entry, ok := sc.entries[normalized]; ok {
if time.Since(entry.createdAt) < sc.ttl { if time.Since(entry.createdAt) < sc.ttl {
sc.moveToEndLocked(normalized) sc.moveToTailLocked(entry)
return copyResults(entry.results), true return copyResults(entry.results), true
} }
// Remove expired entry
sc.removeEntryLocked(entry)
delete(sc.entries, normalized)
} }
// Similarity match. // Similarity match.
@ -70,8 +79,13 @@ func (sc *SearchCache) Get(query string) ([]SearchResult, bool) {
var bestEntry *cacheEntry var bestEntry *cacheEntry
var bestSim float64 var bestSim float64
for _, entry := range sc.entries { for key, entry := range sc.entries {
if time.Since(entry.createdAt) >= sc.ttl { 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. continue // Skip expired.
} }
sim := jaccardSimilarity(queryTrigrams, entry.trigrams) sim := jaccardSimilarity(queryTrigrams, entry.trigrams)
@ -82,7 +96,7 @@ func (sc *SearchCache) Get(query string) ([]SearchResult, bool) {
} }
if bestSim >= similarityThreshold && bestEntry != nil { if bestSim >= similarityThreshold && bestEntry != nil {
sc.moveToEndLocked(bestEntry.query) sc.moveToTailLocked(bestEntry)
return copyResults(bestEntry.results), true return copyResults(bestEntry.results), true
} }
@ -103,33 +117,34 @@ func (sc *SearchCache) Put(query string, results []SearchResult) {
sc.evictExpiredLocked() sc.evictExpiredLocked()
// If already exists, update. // If already exists, update.
if _, ok := sc.entries[normalized]; ok { if entry, ok := sc.entries[normalized]; ok {
sc.entries[normalized] = &cacheEntry{ // Update entry
query: normalized, entry.trigrams = buildTrigrams(normalized)
trigrams: buildTrigrams(normalized), entry.results = copyResults(results)
results: copyResults(results), entry.createdAt = time.Now()
createdAt: time.Now(),
}
// Move to end of LRU order. // Move to end of LRU order.
sc.moveToEndLocked(normalized) sc.moveToTailLocked(entry)
return return
} }
// Evict LRU if at capacity. // Evict LRU if at capacity.
for len(sc.entries) >= sc.maxEntries && len(sc.order) > 0 { for len(sc.entries) >= sc.maxEntries && sc.head != nil {
oldest := sc.order[0] oldest := sc.head
sc.order = sc.order[1:] sc.removeEntryLocked(oldest)
delete(sc.entries, oldest) delete(sc.entries, oldest.query)
} }
// Insert new entry. // Insert new entry.
sc.entries[normalized] = &cacheEntry{ newEntry := &cacheEntry{
query: normalized, query: normalized,
trigrams: buildTrigrams(normalized), trigrams: buildTrigrams(normalized),
results: copyResults(results), results: copyResults(results),
createdAt: time.Now(), 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). // Len returns the number of entries (for testing).
@ -143,26 +158,58 @@ func (sc *SearchCache) Len() int {
func (sc *SearchCache) evictExpiredLocked() { func (sc *SearchCache) evictExpiredLocked() {
now := time.Now() now := time.Now()
newOrder := make([]string, 0, len(sc.order)) current := sc.head
for _, key := range sc.order { for current != nil {
entry, ok := sc.entries[key] next := current.next
if !ok || now.Sub(entry.createdAt) >= sc.ttl { if now.Sub(current.createdAt) >= sc.ttl {
delete(sc.entries, key) sc.removeEntryLocked(current)
continue delete(sc.entries, current.query)
} }
newOrder = append(newOrder, key) current = next
} }
sc.order = newOrder
} }
func (sc *SearchCache) moveToEndLocked(key string) { // addToTailLocked adds an entry to the tail of the LRU list
for i, k := range sc.order { func (sc *SearchCache) addToTailLocked(entry *cacheEntry) {
if k == key { if sc.tail == nil {
sc.order = append(sc.order[:i], sc.order[i+1:]...) // List is empty
break sc.head = entry
sc.tail = entry
} else {
// Add to tail
sc.tail.next = entry
entry.prev = sc.tail
sc.tail = entry
} }
}
// 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
} }
sc.order = append(sc.order, key)
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 { func normalizeQuery(q string) string {