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"
|
"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.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue