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:
parent
b8f8e3f25f
commit
6f6de4598f
2 changed files with 207 additions and 45 deletions
|
|
@ -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
|
||||
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,10 +178,21 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Get existing content from cache or file
|
||||
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 {
|
||||
existingContent = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
var newContent string
|
||||
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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue