Skip to content

Commit 4f43a48

Browse files
committed
refactor(llm): move memo module under llm namespace and optimize thread safety
- Move memo module from root to llm/memo subdirectory - Update import paths in enhance.rs, strategy.rs, llm_pilot.rs, pipeline_retriever.rs, and toc_navigator.rs - Replace AsyncRwLock with atomic statistics for better performance - Remove unnecessary locking in cache operations - Consolidate stats methods into synchronous lock-free implementation - Add load_from method to AtomicStats for restoring persisted data - Update MemoStore constructor to remove capacity parameter - Remove redundant comments and streamline code structure BREAKING CHANGE: Memo module is now located under llm::memo namespace
1 parent 7bba183 commit 4f43a48

11 files changed

Lines changed: 60 additions & 129 deletions

File tree

rust/src/index/stages/enhance.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use crate::document::NodeId;
1313
use crate::error::Result;
1414
use crate::index::incremental;
1515
use crate::llm::LlmClient;
16-
use crate::memo::{MemoKey, MemoStore};
16+
use crate::llm::memo::{MemoKey, MemoStore};
1717
use crate::utils::fingerprint::Fingerprint;
1818

1919
use super::{IndexStage, StageResult};

rust/src/index/summary/strategy.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use async_trait::async_trait;
77

88
use crate::document::{DocumentTree, NodeId};
99
use crate::llm::{LlmClient, LlmResult};
10-
use crate::memo::{MemoKey, MemoStore, MemoValue};
10+
use crate::llm::memo::{MemoKey, MemoStore, MemoValue};
1111
use crate::utils::fingerprint::Fingerprint;
1212

1313
/// Configuration for summary strategies.

rust/src/lib.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ mod metrics;
5050

5151
mod index;
5252
mod llm;
53-
mod memo;
5453
mod retrieval;
5554
mod storage;
5655
mod utils;

rust/src/llm/memo/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) 2026 vectorless developers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! LLM Memoization system for caching expensive LLM calls.
5+
//!
6+
//! Provides a caching layer for LLM-generated content, avoiding
7+
//! redundant API calls via content-addressed LRU cache with TTL
8+
//! and optional disk persistence.
9+
10+
mod store;
11+
mod types;
12+
13+
pub use store::MemoStore;
14+
pub use types::{MemoKey, MemoValue, PilotDecisionValue};
Lines changed: 37 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ use chrono::Duration;
1515
use lru::LruCache;
1616
use parking_lot::RwLock;
1717
use serde::{Deserialize, Serialize};
18-
use tokio::sync::RwLock as AsyncRwLock;
1918
use tracing::{debug, info};
2019

2120
use super::types::{MemoEntry, MemoKey, MemoOpType, MemoStats, MemoValue};
@@ -41,8 +40,8 @@ struct MemoStoreData {
4140
stats: MemoStats,
4241
}
4342

44-
/// Atomic statistics for lock-free access.
45-
#[derive(Debug, Default)]
43+
/// Lock-free atomic statistics for concurrent access.
44+
#[derive(Debug)]
4645
struct AtomicStats {
4746
hits: AtomicU64,
4847
misses: AtomicU64,
@@ -77,6 +76,12 @@ impl AtomicStats {
7776
self.tokens_saved.load(Ordering::Relaxed),
7877
)
7978
}
79+
80+
fn load_from(&self, hits: u64, misses: u64, tokens_saved: u64) {
81+
self.hits.store(hits, Ordering::Relaxed);
82+
self.misses.store(misses, Ordering::Relaxed);
83+
self.tokens_saved.store(tokens_saved, Ordering::Relaxed);
84+
}
8085
}
8186

8287
/// LLM Memoization store.
@@ -90,7 +95,7 @@ impl AtomicStats {
9095
/// # Example
9196
///
9297
/// ```rust,ignore
93-
/// let store = MemoStore::new(1000);
98+
/// let store = MemoStore::new();
9499
///
95100
/// let summary = store.get_or_compute(
96101
/// MemoKey::summary(&content_fp),
@@ -103,8 +108,8 @@ pub struct MemoStore {
103108
/// LRU cache for entries.
104109
cache: Arc<RwLock<LruCache<String, MemoEntry>>>,
105110

106-
/// Statistics (async for safe updates).
107-
stats: Arc<AsyncRwLock<MemoStats>>,
111+
/// Lock-free statistics.
112+
stats: Arc<AtomicStats>,
108113

109114
/// TTL for entries.
110115
ttl: Duration,
@@ -152,7 +157,7 @@ impl MemoStore {
152157
std::num::NonZeroUsize::new(capacity)
153158
.unwrap_or(std::num::NonZeroUsize::new(1000).unwrap()),
154159
))),
155-
stats: Arc::new(AsyncRwLock::new(MemoStats::default())),
160+
stats: Arc::new(AtomicStats::new()),
156161
ttl: DEFAULT_TTL,
157162
model_id: None,
158163
version: 1,
@@ -183,13 +188,10 @@ impl MemoStore {
183188
let mut cache = self.cache.write();
184189

185190
if let Some(entry) = cache.get_mut(&full_key) {
186-
// Check TTL
187191
if entry.is_expired(self.ttl) {
188192
cache.pop(&full_key);
189193
return None;
190194
}
191-
192-
// Record hit
193195
entry.record_hit();
194196
debug!("Memo cache hit for {:?}", key.op_type);
195197
return Some(entry.value.clone());
@@ -226,29 +228,21 @@ impl MemoStore {
226228
{
227229
// Check cache first (synchronous)
228230
if let Some(value) = self.get(&key) {
229-
// Update stats
230-
let mut stats = self.stats.write().await;
231-
stats.hits += 1;
231+
self.stats.record_hit();
232232
return Ok(value);
233233
}
234234

235235
// Record miss
236-
{
237-
let mut stats = self.stats.write().await;
238-
stats.misses += 1;
239-
}
236+
self.stats.record_miss();
240237

241238
// Compute
242239
let (value, tokens) = compute().await?;
243240

244241
// Cache result
245242
self.put_with_tokens(key.clone(), value.clone(), tokens);
246243

247-
// Update stats
248-
{
249-
let mut stats = self.stats.write().await;
250-
stats.tokens_saved += tokens;
251-
}
244+
// Update tokens saved
245+
self.stats.add_tokens_saved(tokens);
252246

253247
Ok(value)
254248
}
@@ -285,50 +279,25 @@ impl MemoStore {
285279
self.len() == 0
286280
}
287281

288-
/// Get cache statistics.
289-
pub async fn stats(&self) -> MemoStats {
290-
let stats = self.stats.read().await;
291-
let mut result = stats.clone();
292-
result.entries = self.len();
293-
result
294-
}
295-
296-
/// Get cache statistics synchronously.
297-
///
298-
/// This acquires a read lock on the stats, which is generally fast.
299-
/// Use this when you need stats without async context.
300-
pub fn stats_snapshot(&self) -> MemoStats {
301-
// Use try_read to avoid blocking; fall back to defaults if locked
302-
match self.stats.try_read() {
303-
Ok(stats) => {
304-
let mut result = stats.clone();
305-
result.entries = self.len();
306-
result
307-
}
308-
Err(_) => MemoStats {
309-
entries: self.len(),
310-
..Default::default()
311-
},
282+
/// Get cache statistics (synchronous, lock-free).
283+
pub fn stats(&self) -> MemoStats {
284+
let (hits, misses, tokens_saved) = self.stats.snapshot();
285+
MemoStats {
286+
entries: self.len(),
287+
hits,
288+
misses,
289+
tokens_saved,
290+
cost_saved: 0.0,
312291
}
313292
}
314293

315294
/// Invalidate all entries of a specific operation type.
316295
///
317-
/// This is useful for batch invalidation when the algorithm for
318-
/// a specific operation type changes.
319-
///
320-
/// # Example
321-
///
322-
/// ```rust,ignore
323-
/// // Invalidate all pilot decision caches
324-
/// let removed = store.invalidate_by_op_type(MemoOpType::PilotDecision);
325-
/// println!("Removed {} cached pilot decisions", removed);
326-
/// ```
296+
/// Useful when the algorithm for a specific operation changes.
327297
pub fn invalidate_by_op_type(&self, op_type: MemoOpType) -> usize {
328298
let mut cache = self.cache.write();
329299
let before = cache.len();
330300

331-
// Collect keys to remove based on entry value type
332301
let keys_to_remove: Vec<String> = cache
333302
.iter()
334303
.filter_map(|(key, entry)| {
@@ -343,7 +312,6 @@ impl MemoStore {
343312
})
344313
.collect();
345314

346-
// Remove entries
347315
for key in keys_to_remove {
348316
cache.pop(&key);
349317
}
@@ -357,21 +325,11 @@ impl MemoStore {
357325

358326
/// Invalidate all entries matching a model ID prefix.
359327
///
360-
/// This is useful when switching models or when a model's behavior changes.
361-
///
362-
/// # Example
363-
///
364-
/// ```rust,ignore
365-
/// // Invalidate all GPT-4 caches
366-
/// let removed = store.invalidate_by_model_prefix("gpt-4");
367-
/// ```
328+
/// Useful when switching models or when a model's behavior changes.
368329
pub fn invalidate_by_model_prefix(&self, prefix: &str) -> usize {
369330
let mut cache = self.cache.write();
370331
let before = cache.len();
371332

372-
// Since the key is a fingerprint, we need to check model_id from entries
373-
// For now, we'll clear all entries if prefix matches our model_id
374-
// A better approach would be to store model_id in entry metadata
375333
let should_clear = self
376334
.model_id
377335
.as_ref()
@@ -396,14 +354,12 @@ impl MemoStore {
396354
let mut cache = self.cache.write();
397355
let before = cache.len();
398356

399-
// Collect expired keys
400357
let expired: Vec<String> = cache
401358
.iter()
402359
.filter(|(_, entry)| entry.is_expired(self.ttl))
403360
.map(|(k, _)| k.clone())
404361
.collect();
405362

406-
// Remove expired entries
407363
for key in expired {
408364
cache.pop(&key);
409365
}
@@ -417,16 +373,19 @@ impl MemoStore {
417373

418374
/// Save the cache to disk.
419375
pub async fn save(&self, path: &Path) -> Result<()> {
376+
// Prune expired entries before persisting
377+
self.prune_expired();
378+
420379
let cache = self.cache.read();
421-
let stats = self.stats.read().await;
380+
let stats = self.stats();
422381

423382
let entries: HashMap<String, MemoEntry> =
424383
cache.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
425384

426385
let data = MemoStoreData {
427386
version: 1,
428387
entries,
429-
stats: stats.clone(),
388+
stats,
430389
};
431390

432391
let parent = path
@@ -459,20 +418,15 @@ impl MemoStore {
459418
.map_err(|e| crate::Error::Parse(format!("Failed to deserialize memo store: {}", e)))?;
460419

461420
let mut cache = self.cache.write();
462-
let mut stats = self.stats.write().await;
463421

464422
for (key, entry) in data.entries {
465-
// Skip expired entries
466423
if !entry.is_expired(self.ttl) {
467424
cache.put(key, entry);
468425
}
469426
}
470427

471-
stats.entries = cache.len();
472-
stats.hits = data.stats.hits;
473-
stats.misses = data.stats.misses;
474-
stats.tokens_saved = data.stats.tokens_saved;
475-
stats.cost_saved = data.stats.cost_saved;
428+
// Restore stats
429+
self.stats.load_from(data.stats.hits, data.stats.misses, data.stats.tokens_saved);
476430

477431
info!(
478432
"Loaded memo store with {} entries from {:?}",
@@ -484,7 +438,6 @@ impl MemoStore {
484438

485439
/// Make a full cache key from a MemoKey.
486440
fn make_key(&self, key: &MemoKey) -> String {
487-
// Include model_id and version in the key
488441
let mut key_with_context = key.clone();
489442
if key_with_context.model_id.is_none() {
490443
key_with_context.model_id = self.model_id.clone();
@@ -616,7 +569,6 @@ mod tests {
616569
store.put(key, MemoValue::Summary(format!("Summary {}", i)));
617570
}
618571

619-
// Only 3 entries should remain
620572
assert_eq!(store.len(), 3);
621573
}
622574

@@ -656,7 +608,7 @@ mod tests {
656608
.unwrap();
657609

658610
assert_eq!(result2.as_summary(), Some("Computed"));
659-
assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1); // Still 1
611+
assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
660612
}
661613

662614
#[tokio::test]
@@ -699,15 +651,15 @@ mod tests {
699651
.await
700652
.unwrap();
701653

702-
// Hit via get_or_compute (this updates global stats)
654+
// Hit
703655
store
704656
.get_or_compute(key.clone(), || async {
705657
Ok((MemoValue::Summary("Should not be called".to_string()), 0))
706658
})
707659
.await
708660
.unwrap();
709661

710-
let stats = store.stats().await;
662+
let stats = store.stats();
711663
assert_eq!(stats.misses, 1);
712664
assert_eq!(stats.hits, 1);
713665
assert_eq!(stats.tokens_saved, 100);

rust/src/llm/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pub(crate) mod config;
3333
mod error;
3434
mod executor;
3535
mod fallback;
36+
pub(crate) mod memo;
3637
mod pool;
3738
pub(crate) mod throttle;
3839

rust/src/memo/mod.rs

Lines changed: 0 additions & 35 deletions
This file was deleted.

0 commit comments

Comments
 (0)