//! 工具命名空间存储 — 大工具结果不进主消息队列 //! //! 当工具执行结果超过阈值时,存入 `NamespaceStore`,主队列只保留轻量引用路径。 //! 运行时为内存 HashMap(LRU 淘汰),持久化时由调用方展开引用写 DB。 //! //! 存储格式: "namespace://tool_name/args_hash" //! 例: namespace://read_file/a1b2c3d4 //! //! ## 淘汰治理(2026-10-06,设计文档 namespace淘汰治理-设计-2026-10-04.md,拍板 2/2) //! - **pin 机制**:关键产出(子代理报告等)标记 pinned,LRU 永不淘汰;pin 总字节超 //! 池容量 50% 时最旧 pin 降级可淘(拍板 C1,防 pin 滥用撑死池)。 //! - **EvictionLog**:淘汰留档(50 条上限),展开点 read_only()=None 时查 Log 给富提示 //! (原工具名+内容前 80 字+淘汰时刻),替代裸 EVICTED_PLACEHOLDER。 //! - **owner 软隔离**:条目带归属(调用方 conv_id),淘汰顺序=无主最旧→他主未 pin→ //! 当前主未 pin;软隔离只影响淘汰顺序,owner 传错退回现状行为,无正确性风险。 //! - **零 schema 变更**:纯内存态增强。 use std::collections::HashMap; use std::time::Instant; /// 引用路径前缀 pub const NAMESPACE_REF_PREFIX: &str = "namespace://"; /// namespace 条目被 LRU 淘汰(或跨会话残留引用)后,展开点 read_only()=None 时的提示文案。 /// /// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。 /// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。 /// 治理后:EvictionLog 命中时展开点改用富提示(见 `eviction_rich_notice`),未命中原样。 pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]"; /// namespace 字节阈值(> 2048 bytes 触发) pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048; /// namespace 行数阈值(> 50 行 触发) pub const NAMESPACE_LINE_THRESHOLD: usize = 50; /// 始终进 namespace 的工具名(其输出几乎总是大结果) pub const ALWAYS_LARGE_TOOLS: &[&str] = &["read_file", "list_directory", "grep", "diff_files"]; /// EvictionLog 容量上限(条)。单条 ~200B,50 条可忽略。 pub const EVICTION_LOG_CAP: usize = 50; /// pin 总字节占池容量比例上限。超过后最旧 pin 降级可淘(拍板 C1)。 pub const PIN_RATIO_CAP: f64 = 0.5; /// 判定工具结果是否应进入 namespace pub fn should_use_namespace(content: &str, tool_name: &str) -> bool { if ALWAYS_LARGE_TOOLS.contains(&tool_name) { return true; } content.len() > NAMESPACE_BYTE_THRESHOLD || content.lines().count() > NAMESPACE_LINE_THRESHOLD } /// 判定 tool_result 内容是否为 namespace 引用路径 pub fn is_namespace_ref(content: &str) -> bool { content.starts_with(NAMESPACE_REF_PREFIX) } /// 从引用路径中提取内部 key pub fn parse_namespace_key(full_path: &str) -> Option<&str> { full_path.strip_prefix(NAMESPACE_REF_PREFIX) // 939①: 先剥 ?len= 长度尾段(估算入账用),否则尾段并入 key 致全线查空 .and_then(|s| s.split("?len=").next()) .and_then(|s| s.split('/').nth(1)) } /// 从引用路径提取原文字节数(939①)。带 `?len=N` 尾段的引用返回 Some(N); /// 旧格式/放弃入库的无尾段引用返回 None(调用方按字面字符保守估算)。 pub fn parse_ref_content_len(full_path: &str) -> Option { full_path.rsplit("?len=") .next() .and_then(|s| s.parse::().ok()) .filter(|_| full_path.contains("?len=")) } /// 从引用路径提取工具名段(懒回填重建条目元数据用)。 pub fn parse_ref_tool_name(full_path: &str) -> Option<&str> { full_path.strip_prefix(NAMESPACE_REF_PREFIX)?.split('/').next() } /// namespace 条目 #[derive(Debug, Clone)] pub struct NamespaceEntry { pub key: String, pub tool_name: String, pub content: String, pub created_at: Instant, pub access_count: u64, /// pin 标记:LRU 永不淘汰(pin 总量超 50% 容量时最旧 pin 降级,拍板 C1) pub pinned: bool, /// 归属(conv_id / 子代理 id),软隔离淘汰顺序用;None=无主 pub owner: Option, /// 预生成的淘汰提示料(工具名+参数摘要,store 时固化),淘汰留档用 pub evict_hint: String, } /// 淘汰留档记录(EvictionLog 元素) #[derive(Debug, Clone)] pub struct EvictionRecord { pub key: String, pub tool_name: String, /// 内容前 80 字符(hint),供富提示识别原文 pub hint: String, pub evicted_at: Instant, pub owner: Option, } /// namespace 存储(内存 HashMap + LRU 淘汰 + pin/EvictionLog/owner 治理) #[derive(Debug, Clone)] pub struct NamespaceStore { entries: HashMap, max_bytes: usize, current_bytes: usize, /// 淘汰留档(VecDeque 语义,容量 EVICTION_LOG_CAP) eviction_log: std::collections::VecDeque, } impl NamespaceStore { /// 创建 namespace 存储 pub fn new(max_bytes: usize) -> Self { Self { entries: HashMap::new(), max_bytes, current_bytes: 0, eviction_log: std::collections::VecDeque::new(), } } /// 默认大小:10MB pub fn default() -> Self { Self::new(10 * 1024 * 1024) } /// 存入 namespace。返回引用路径字符串。(兼容入口,owner=None 无主) /// /// 超限行为分两档:常规超限 LRU 淘汰旧条目腾位(既有语义);单条本身超过 /// max_bytes 时淘汰清空也放不下,拒绝入库只返回引用路径——展开点对 /// read_only()=None 统一落 EVICTED_PLACEHOLDER,与 LRU 淘汰后的不可解析引用同语义。 pub fn store(&mut self, tool_name: &str, content: &str) -> String { self.store_with_owner(tool_name, content, None) } /// 存入(带归属)。owner 软隔离:淘汰优先淘无主/他主,当前主最后。 pub fn store_with_owner(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String { self.store_inner(tool_name, content, owner, false) } /// 存入并 pin(关键产出:子代理报告等)。pin 条目 LRU 永不淘汰, /// pin 总字节超池 50% 时最旧 pin 降级可淘(在 evict_if_needed 内兜底)。 pub fn store_pinned(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String { self.store_inner(tool_name, content, owner, true) } fn store_inner(&mut self, tool_name: &str, content: &str, owner: Option<&str>, pinned: bool) -> String { let key = simple_hash(content); // 939① 估算入账:ref 尾段携带原文字节数,TokenEstimator 解析即按真实体量入账 // (旧口径 ref 仅 ~30 字符被记 ~15 token,预算/裁剪/压缩三重门对其失效)。 // parse_namespace_key 取 nth(1) 不受尾段影响,展开/淘汰提示零改动。 // 放弃入库(超容量/淘汰后仍放不下)返回**不带长度**的 path:原文不在池,展开点 // 落占位提示,预算按轻量字符计——已淘汰内容不应继续虚占预算。 let path_with_len = format!("{}{}/{}?len={}", NAMESPACE_REF_PREFIX, tool_name, key, content.len()); let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key); // 已存在 → 刷新 access_count,不重复占用空间;若本次要求 pin,升级既有条目 if self.entries.contains_key(&key) { if let Some(entry) = self.entries.get_mut(&key) { entry.access_count += 1; if pinned && !entry.pinned { entry.pinned = true; } } return path_with_len; } let new_bytes = content.len(); // 单条超过总容量:硬入库会把 current_bytes 永久顶破上限(evict 清空后仍无条件 // insert 的旧路径),拒绝入库保住 current_bytes <= max_bytes 不变式 if new_bytes > self.max_bytes { return path; } // pin 比例上限(拍板 C1):pinned 入库前无条件校验,把 pin 总量压回 50% 内 // (最旧 pin 先降级),不只在池溢出时——防 pin 静默累积到失控 if pinned { let cap = (self.max_bytes as f64 * PIN_RATIO_CAP) as usize; while self.pinned_bytes() + new_bytes > cap { if !self.demote_oldest_pin() { break; } } } // 检查容量,超限淘汰 self.evict_if_needed(new_bytes); // 淘汰后仍放不下(全 pin 撑满):放弃入库(返回引用路径,展开点落占位), // 不降级 pin——pin 保护是强语义,普通 store 不得侵蚀 pin if self.current_bytes + new_bytes > self.max_bytes && !pinned { return path; } let entry = NamespaceEntry { key: key.clone(), tool_name: tool_name.to_string(), content: content.to_string(), created_at: Instant::now(), access_count: 1, pinned, owner: owner.map(str::to_string), evict_hint: content.chars().take(80).collect(), }; self.current_bytes += new_bytes; self.entries.insert(key, entry); path_with_len } /// pin/取消 pin(事后)。pin 总量超 50% 容量时本调用拒绝并返回 false(防失控,拍板 C1)。 pub fn pin(&mut self, full_path: &str) -> bool { let Some(key) = parse_namespace_key(full_path) else { return false }; if !self.entries.contains_key(key) { return false; } if self.pinned_bytes() + self.entries.get(key).map(|e| e.content.len()).unwrap_or(0) > (self.max_bytes as f64 * PIN_RATIO_CAP) as usize { return false; } if let Some(entry) = self.entries.get_mut(key) { entry.pinned = true; } true } pub fn unpin(&mut self, full_path: &str) { if let Some(key) = parse_namespace_key(full_path) { if let Some(entry) = self.entries.get_mut(key) { entry.pinned = false; } } } /// pin 条目总字节 pub fn pinned_bytes(&self) -> usize { self.entries.values().filter(|e| e.pinned).map(|e| e.content.len()).sum() } /// 降级最旧 pin。返回是否有降级发生(全 pin 已降无可降返回 false)。 fn demote_oldest_pin(&mut self) -> bool { let oldest_pin = self.entries.iter() .filter(|(_, e)| e.pinned) .min_by_key(|(_, e)| e.created_at) .map(|(k, _)| k.clone()); if let Some(key) = oldest_pin { if let Some(entry) = self.entries.get_mut(&key) { entry.pinned = false; return true; } } false } /// 读取完整内容 pub fn read(&mut self, full_path: &str) -> Option<&str> { let key = parse_namespace_key(full_path)?; let entry = self.entries.get_mut(key)?; entry.access_count += 1; Some(entry.content.as_str()) } /// 读取(只读,不改 access_count) pub fn read_only(&self, full_path: &str) -> Option<&str> { let key = parse_namespace_key(full_path)?; self.entries.get(key).map(|e| e.content.as_str()) } /// 展开失败的富提示:查 EvictionLog,命中返回「原为 X 前 80 字…于淘汰,可重新调用」, /// 未命中(跨会话残留/未知 key)返回 None,展开点落回 EVICTED_PLACEHOLDER。 pub fn eviction_rich_notice(&self, full_path: &str) -> Option { let key = parse_namespace_key(full_path)?; let rec = self.eviction_log.iter().find(|r| r.key == key)?; Some(format!( "[结果已淘汰] 原为 {}(\"{}\"…) 因内存容量淘汰。可重新调用 {} 取回原文。", rec.tool_name, rec.hint, rec.tool_name, )) } /// 跨重启懒回填:store 清空后旧引用首次展开未命中时,从会话消息原文找回并重建条目。 /// DB 落库约定存展开后原文,引用 key 即入库时的原文内容哈希(SipHash 确定性,跨进程 /// 一致),哈希精确匹配即可靠对应单条原文,无需按序重建;原文不在消息里的引用(真淘汰) /// 不进返回值,调用方维持淘汰占位语义。 /// 入库走 store 既有容量语义(超容量拒绝只影响后续轮次命中,本轮视图仍用返回的原文)。 /// 返回 引用路径 → 原文。 pub fn backfill_from_messages( &mut self, missed_refs: &[String], message_contents: &[String], ) -> HashMap { let mut recovered: HashMap = HashMap::new(); if missed_refs.is_empty() { return recovered; } let wanted: HashMap<&str, &String> = missed_refs.iter() .filter_map(|p| parse_namespace_key(p).map(|k| (k, p))) .collect(); for content in message_contents { let key = simple_hash(content); if let Some(path) = wanted.get(key.as_str()) { // 同一引用可能在多条历史消息出现,找回一次即可 if recovered.contains_key(path.as_str()) { continue; } if let Some(tool) = parse_ref_tool_name(path) { self.store(tool, content); } recovered.insert((*path).clone(), content.clone()); } } recovered } /// 检查 key 是否存在 pub fn contains(&self, full_path: &str) -> bool { parse_namespace_key(full_path) .and_then(|k| self.entries.get(k)) .is_some() } /// 条目数 pub fn len(&self) -> usize { self.entries.len() } pub fn is_empty(&self) -> bool { self.entries.is_empty() } /// LRU 淘汰:owner 软隔离三段顺序(设计 §3.4): /// ①无主且未 pin 的最旧 → ②他主(非最后入参 owner)未 pin 的最旧 → ③当前主未 pin 最旧。 /// pin 永不淘(除非 store_pinned 路径 demote 降级)。needed 所需空间,cur_owner=当前 /// 存入方归属(淘汰保护对象,None=无主视角)。 fn evict_if_needed_with_owner(&mut self, needed: usize, cur_owner: Option<&str>) { while self.current_bytes + needed > self.max_bytes && !self.entries.is_empty() { // 三段淘汰顺序:无主最旧 → 他主最旧 → 当前主最旧(各段均跳过 pinned) let oldest_key = self.entries.iter() .filter(|(_, e)| !e.pinned && e.owner.is_none()) .min_by_key(|(_, e)| e.created_at) .map(|(k, _)| k.clone()) .or_else(|| { self.entries.iter() .filter(|(_, e)| { !e.pinned && match (&e.owner, cur_owner) { (Some(o), Some(c)) => o != c, (Some(_), None) => true, // 有主 vs 无主调用:有主条目算"他主" _ => false, } }) .min_by_key(|(_, e)| e.created_at) .map(|(k, _)| k.clone()) }) .or_else(|| { self.entries.iter() .filter(|(_, e)| !e.pinned) .min_by_key(|(_, e)| e.created_at) .map(|(k, _)| k.clone()) }); match oldest_key { Some(key) => { if let Some(entry) = self.entries.remove(&key) { self.current_bytes = self.current_bytes.saturating_sub(entry.content.len()); // EvictionLog 留档(50 条上限,超出弹最旧) self.eviction_log.push_back(EvictionRecord { key: entry.key, tool_name: entry.tool_name, hint: entry.evict_hint, evicted_at: Instant::now(), owner: entry.owner, }); while self.eviction_log.len() > EVICTION_LOG_CAP { self.eviction_log.pop_front(); } } } None => break, // 全部 pinned(且非 pin 腾位路径):放不下就放不下 } } } /// 兼容入口(无主视角) fn evict_if_needed(&mut self, needed: usize) { self.evict_if_needed_with_owner(needed, None); } /// 清空 pub fn clear(&mut self) { self.entries.clear(); self.current_bytes = 0; self.eviction_log.clear(); } } /// 内容 → 引用 key 段(确定性,不含 `/` 等会破坏 `parse_namespace_key` 的字符)。 /// /// 历史 bug:旧实现用 `"{len}_{前16字符}"`,前 16 字符原样进 key;内容前缀含 `/`(目录列表、 /// 带路径的 grep 结果、文件头是路径)时,`namespace://tool/{key}` 被 `parse_namespace_key` /// 的 `split('/').nth(1)` 从首个 `/` 截断 → read/read_only 拿回 None → 原文彻底丢失。 /// 现改用 SipHash u64 → 16 位 hex:确定性 + 极低碰撞 + 不含特殊字符。 fn simple_hash(content: &str) -> String { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); content.hash(&mut hasher); format!("{:016x}", hasher.finish()) } #[cfg(test)] mod tests { use super::*; #[test] fn should_use_namespace_small_content() { assert!(!should_use_namespace("short", "custom_tool")); } #[test] fn should_use_namespace_large_bytes() { let large = "x".repeat(3000); assert!(should_use_namespace(&large, "custom_tool")); } #[test] fn should_use_namespace_always_large_tool() { assert!(should_use_namespace("short", "read_file")); assert!(should_use_namespace("short", "list_directory")); assert!(should_use_namespace("short", "diff_files")); } #[test] fn should_use_namespace_many_lines() { let many = (0..60).map(|i| format!("line {}", i)).collect::>().join("\n"); assert!(should_use_namespace(&many, "custom_tool")); } #[test] fn is_namespace_ref_detects_prefix() { assert!(is_namespace_ref("namespace://read_file/abc")); assert!(!is_namespace_ref("file content here")); } #[test] fn parse_namespace_key_extracts_key() { assert_eq!(parse_namespace_key("namespace://read_file/abc_123"), Some("abc_123")); assert_eq!(parse_namespace_key("namespace://grep/xyz"), Some("xyz")); assert_eq!(parse_namespace_key("no_prefix"), None); } #[test] fn store_and_read() { let mut ns = NamespaceStore::new(100_000); let path = ns.store("read_file", "hello world content"); assert!(is_namespace_ref(&path)); assert!(path.contains("read_file")); assert_eq!(ns.read(&path), Some("hello world content")); } #[test] fn store_duplicate_key_does_not_double_count() { let mut ns = NamespaceStore::new(100_000); let content = "test content for duplicate check"; let p1 = ns.store("tool_a", content); let p2 = ns.store("tool_b", content); assert_eq!(ns.len(), 1, "相同内容应去重"); // read(&mut self) 返回 Option<&str> 借用 ns,两次调用须各自转 owned 避免双重可变借用 let r1 = ns.read(&p1).map(str::to_owned); let r2 = ns.read(&p2).map(str::to_owned); assert_eq!(r1, r2); } #[test] fn store_content_with_slash_in_prefix_reads_back() { // 回归:旧 simple_hash 把内容前 16 字符原样进 key,前缀含 '/' 时(目录列表/带路径 grep // 结果/文件头是路径)namespace://tool/{key} 被 parse_namespace_key 的 split('/') 截断 // → read None,原文丢失。hex hash 修复后 store→read 正确取回。 let mut ns = NamespaceStore::new(100_000); let content = "foo/bar/baz/qux\nline2\nline3"; let path = ns.store("list_directory", content); assert!(is_namespace_ref(&path)); assert_eq!(ns.read(&path).map(str::to_owned), Some(content.to_string())); } #[test] fn eviction_oldest_removed_when_over_limit() { let mut ns = NamespaceStore::new(100); // 极小容量 let small = "a"; // 1 byte ns.store("t1", small); assert_eq!(ns.len(), 1); // 填充到超限 let big = "x".repeat(200); ns.store("t2", &big); // t1 可能被淘汰 if ns.current_bytes > 100 { assert!(ns.len() < 2, "超限时应已淘汰旧条目"); } } #[test] fn store_oversized_single_entry_rejected_cap_intact() { // 回归:单条超过 max_bytes 时旧路径淘汰清空后仍无条件入库,current_bytes // 永久突破上限。现拒绝入库,仅返回引用路径,展开点落 EVICTED_PLACEHOLDER。 let mut ns = NamespaceStore::new(100); let path = ns.store("t1", &"x".repeat(200)); assert_eq!(ns.len(), 0, "超容量单条应拒绝入库"); assert_eq!(ns.current_bytes, 0, "字节账目不得突破上限"); assert!(is_namespace_ref(&path), "返回值仍是引用路径"); assert_eq!(ns.read_only(&path), None, "不可解析 → 既有淘汰占位语义"); // 拒绝后不污染状态,常规条目仍可正常入 let p2 = ns.store("t2", "ok"); assert_eq!(ns.read(&p2).map(str::to_owned), Some("ok".to_string())); assert_eq!(ns.current_bytes, 2); } #[test] fn clear_resets_all() { let mut ns = NamespaceStore::new(100_000); ns.store("t1", "content"); assert!(!ns.is_empty()); ns.clear(); assert!(ns.is_empty()); assert_eq!(ns.current_bytes, 0); } // ── 淘汰治理新增 3 组(pin / EvictionLog / owner 隔离) ── #[test] fn pin_protects_from_eviction() { let mut ns = NamespaceStore::new(100); let pinned_path = ns.store_pinned("report", &"p".repeat(50), Some("conv-a")); assert!(ns.contains(&pinned_path)); // 塞入未 pin 大内容逼淘汰:pin 条目须存活 let _ = ns.store("t2", &"x".repeat(80)); assert!(ns.contains(&pinned_path), "pinned 条目不得被 LRU 淘汰"); assert_eq!(ns.read_only(&pinned_path).map(str::to_owned), Some("p".repeat(50))); } #[test] fn eviction_log_rich_notice() { let mut ns = NamespaceStore::new(50); let victim = ns.store("read_file", &"v".repeat(40)); let victim_path = victim.clone(); // 挤出 victim let _ = ns.store("t2", &"y".repeat(45)); assert_eq!(ns.read_only(&victim_path), None, "victim 应已被淘汰"); // 富提示:含工具名+内容前 80 字+重新调用指引 let notice = ns.eviction_rich_notice(&victim_path).expect("Log 应命中"); assert!(notice.contains("read_file"), "富提示应含工具名: {notice}"); assert!(notice.contains("vvvv"), "富提示应含内容 hint: {notice}"); // 未命中(未知 key)→ None,展开点落 EVICTED_PLACEHOLDER assert_eq!(ns.eviction_rich_notice("namespace://read_file/0000000000000000"), None); } #[test] fn owner_soft_isolation_evicts_other_owner_first() { let mut ns = NamespaceStore::new(100); // conv-a 存 30B,conv-b 存 30B(时间序:a 旧于 b) let _ = ns.store_with_owner("ta", &"a".repeat(30), Some("conv-a")); let path_b = ns.store_with_owner("tb", &"b".repeat(30), Some("conv-b")); // conv-b 再存 50B 逼淘汰:软隔离应先淘无主(无)→ 他主(conv-a)而非自己 let _ = ns.store_with_owner("tc", &"c".repeat(50), Some("conv-b")); assert!(!ns.contains("namespace://ta/x"), "ta 的 key 查询仅辅助"); assert!(ns.entries.values().all(|e| e.owner.as_deref() != Some("conv-a")), "他主(conv-a)条目应先被淘汰"); assert!(ns.contains(&path_b), "当前主(conv-b)条目应存活"); } #[test] fn pin_ratio_cap_demotes_oldest() { // 池 100B,50% 上限=50B:pin 60B 单条(store_pinned 自身允许,超限后 // 下一次 pin 腾位路径应降级它) let mut ns = NamespaceStore::new(100); let p_old = ns.store_pinned("old", &"o".repeat(60), None); // 再 pin 30B:总 pin 90B > 50B 上限,store_pinned 内 demote_oldest_pin 应降级 old let _ = ns.store_pinned("new", &"n".repeat(30), None); // 挤压逼淘汰:降级后的 old 可被淘,受保护的 new 存活 let _ = ns.store("t3", &"z".repeat(80)); assert!(ns.contains(&p_old) == false || ns.pinned_bytes() <= 50, "降级机制应使 pin 总量可被压缩"); } #[test] fn ref_carries_content_len_for_budget() { // 939①: 成功入库的 ref 带 ?len= 原文字节数,parse 往返一致; // parse_namespace_key 不受尾段影响(展开点零改动) let mut ns = NamespaceStore::new(100_000); let content = "x".repeat(5000); let path = ns.store("read_file", &content); assert!(path.contains(&format!("?len={}", content.len())), "ref 应带 len: {path}"); assert_eq!(parse_ref_content_len(&path), Some(content.len())); assert_eq!(parse_namespace_key(&path), parse_namespace_key(&format!("namespace://read_file/{}", crate::namespace_store::simple_hash(&content)))); // 旧格式/无尾段 → None(调用方保守按字面计) assert_eq!(parse_ref_content_len("namespace://read_file/abc"), None); } // ── 跨重启懒回填(跨会话召回设计 缺口3) ── #[test] fn backfill_recovers_original_by_content_hash() { // 引用路径取自热 store(模拟重启前生成),冷 store(重启后)从消息原文找回 let mut hot = NamespaceStore::new(1 << 20); let original = "目录列表内容\n".repeat(100); let path = hot.store("list_directory", &original); let mut cold = NamespaceStore::default(); let recovered = cold.backfill_from_messages( &[path.clone()], &[original.clone(), "无关消息".to_string()], ); assert_eq!(recovered.get(&path).map(String::as_str), Some(original.as_str())); assert_eq!(cold.read_only(&path).map(str::to_owned), Some(original), "回填后条目应可读"); } #[test] fn backfill_without_original_stays_empty() { let mut hot = NamespaceStore::new(1 << 20); let path = hot.store("read_file", &"真原文".repeat(500)); let mut cold = NamespaceStore::default(); let recovered = cold.backfill_from_messages( &[path.clone()], &["无关A".to_string(), "无关B".to_string()], ); assert!(recovered.is_empty(), "无对应原文不应误填"); assert_eq!(cold.read_only(&path), None); assert!(cold.is_empty()); } #[test] fn backfill_duplicate_refs_recover_once() { // 同一引用出现在多条历史消息:找回一次,store 只建一条 let mut hot = NamespaceStore::new(1 << 20); let original = "z".repeat(3000); let path = hot.store("grep", &original); let mut cold = NamespaceStore::default(); let recovered = cold.backfill_from_messages(&[path.clone(), path.clone()], &[original]); assert_eq!(recovered.len(), 1); assert_eq!(cold.len(), 1); } }