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"
"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.

View file

@ -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 {