// 持久化层:PersistenceBackend trait + FsBackend(native,双槽快照 + head 指针)
// + MemoryBackend(测试 / wasm-js 缺省)。
//
// 本文件是全库唯一允许出现 moonbitlang/x/fs 调用的地方。
// 后端差异(native→FsBackend,wasm/js→MemoryBackend)也只在此文件(#cfg)。
//
// 双槽快照:整份快照覆盖写入非当前槽,成功后再拨 head。
// /moomem/slot0.jsonl 快照槽 A
// /moomem/slot1.jsonl 快照槽 B
// /moomem/head 内容 "0" 或 "1",指向当前有效槽
//
// flush 流程:写非当前槽(完整覆盖写)→ 成功后写 head。
// 写槽中途崩溃 → head 仍指旧槽,数据无损;
// 写 head 中途崩溃 → head 损坏时回退"取能解析 header 且 gen 更大的槽"。
// ---------------------------------------------------------------------------
// 抽象
// ---------------------------------------------------------------------------
///|
/// 持久化后端抽象。load 返回 None 表示空库(首次打开)。
pub(open) trait PersistenceBackend {
fn load(self : Self) -> Result[String?, MoomemError]
fn save(self : Self, snapshot : String) -> Result[Unit, MoomemError]
}
// ---------------------------------------------------------------------------
// MemoryBackend:进程内后端(测试崩溃注入 / wasm-js 缺省)
// ---------------------------------------------------------------------------
///|
/// 进程内后端。保存完整快照字符串;支持模拟崩溃(半写 / 写失败)。
pub struct MemoryBackend {
mut snapshot : String?
/// 测试注入:写盘时只写入前 N 个字符(模拟进程被杀的半写状态);None = 完整写入
mut simulate_partial_write : Int32?
/// 测试注入:下一次 save 直接失败(模拟 IO 错误)
mut fail_next_save : Bool
/// 累计写入字节数(stats 的逻辑值来源)
mut bytes_written : Int64
}
///|
pub fn MemoryBackend::new() -> MemoryBackend {
MemoryBackend::{
snapshot: None,
simulate_partial_write: None,
fail_next_save: false,
bytes_written: 0L,
}
}
///|
pub impl PersistenceBackend for MemoryBackend with fn load(self) {
Ok(self.snapshot)
}
///|
pub impl PersistenceBackend for MemoryBackend with fn save(self, snapshot) {
if self.fail_next_save {
self.fail_next_save = false
return Err(MoomemError::IoFailure("MemoryBackend: simulated save failure"))
}
let stored = match self.simulate_partial_write {
Some(n) => {
let cut = if n < 0 {
0
} else if n < snapshot.length() {
n
} else {
snapshot.length()
}
snapshot[:cut].to_owned()
}
None => snapshot
}
self.snapshot = Some(stored)
self.bytes_written = self.bytes_written + stored.length().to_int64()
Ok(())
}
///|
pub extend MemoryBackend with PersistenceBackend::{save, load}
///|
/// 当前保存的快照(测试观察用)。
fn MemoryBackend::peek(self : MemoryBackend) -> String? {
self.snapshot
}
///|
/// 测试注入:模拟进程被杀的半写状态(save 只写入前 n 个字符);None = 完整写入。
fn MemoryBackend::set_simulate_partial_write(
self : MemoryBackend,
n : Int32?,
) -> Unit {
self.simulate_partial_write = n
}
///|
/// 测试注入:下一次 save 直接失败(模拟 IO 错误)。
fn MemoryBackend::set_fail_next_save(self : MemoryBackend, fail : Bool) -> Unit {
self.fail_next_save = fail
}
// ---------------------------------------------------------------------------
// FsBackend:native 磁盘后端(双槽快照 + head 指针)
// ---------------------------------------------------------------------------
///|
/// 磁盘后端。目录布局:
/// /moomem/slot{0,1}.jsonl + /moomem/head
pub struct FsBackend {
/// 宿主给定的记忆库根目录
base_dir : String
/// 当前有效槽(0 / 1)
mut slot : Int32
}
///|
pub fn FsBackend::new(path : String) -> FsBackend {
FsBackend::{ base_dir: path, slot: 0, }
}
///|
fn FsBackend::moomem_dir(self : FsBackend) -> String {
"\{self.base_dir}/moomem"
}
///|
fn FsBackend::slot_path(self : FsBackend, slot : Int32) -> String {
"\{self.moomem_dir()}/slot\{slot}.jsonl"
}
///|
fn FsBackend::head_path(self : FsBackend) -> String {
"\{self.moomem_dir()}/head"
}
///|
/// 读取指定槽文件并校验其 header 可解析(用于槽选择);失败返回 None。
fn FsBackend::read_slot_if_valid(
self : FsBackend,
slot : Int32,
) -> Result[String?, MoomemError] {
let path = self.slot_path(slot)
if !@fs.path_exists(path) {
return Ok(None)
}
try {
let content = @fs.read_file_to_string(path)
// 校验首行 header 可解析(尾部半行损坏交给上层 store 截断处理)
let first_line = match content.split("\n").next() {
Some(l) => l.to_owned()
None => ""
}
match parse_json_line(first_line) {
Ok(header_json) =>
match parse_snapshot_header(header_json) {
Ok(_) => Ok(Some(content))
Err(_) => Ok(None) // header 版本不符等:视为无效槽
}
Err(_) => Ok(None)
}
} catch {
e => Err(MoomemError::IoFailure(io_error_message("read '\{path}'", e)))
}
}
///|
/// 从快照文本解析 header 中的 gen(用于双槽歧义时取大者)。
fn snapshot_gen_of(content : String) -> Int32? {
let first_line = match content.split("\n").next() {
Some(l) => l.to_owned()
None => return None
}
match parse_json_line(first_line) {
Ok(header_json) =>
match parse_snapshot_header(header_json) {
Ok((_, gen, _)) => Some(gen)
Err(_) => None
}
Err(_) => None
}
}
///|
pub impl PersistenceBackend for FsBackend with fn load(self) {
try {
// 目录不存在则创建
mkdir_p(self.base_dir)
mkdir_p(self.moomem_dir())
let head_content : String? = if @fs.path_exists(self.head_path()) {
Some(@fs.read_file_to_string(self.head_path()))
} else {
None
}
let head_slot : Int32? = match head_content {
Some(c) =>
match c.trim() {
"0" => Some(0)
"1" => Some(1)
_ => None // head 损坏(写 head 中途崩溃)
}
None => None
}
match head_slot {
Some(primary) => {
// head 有效:优先读所指槽;损坏则回退另一槽
let other = 1 - primary
let primary_slot = self.read_slot_if_valid(primary)
let other_slot = self.read_slot_if_valid(other)
match (primary_slot, other_slot) {
(Ok(Some(content)), _) => {
self.slot = primary
Ok(Some(content))
}
(Ok(None), Ok(Some(content))) => {
self.slot = other
Ok(Some(content))
}
(Ok(None), Ok(None)) => {
let base = self.base_dir
let p0 = self.slot_path(primary)
let p1 = self.slot_path(other)
if @fs.path_exists(p0) || @fs.path_exists(p1) {
// 槽文件存在但 header 均无法解析 → 双槽均损坏
Err(
MoomemError::StoreCorrupted(
"both snapshot slots exist but are unreadable (base: '\{base}')",
),
)
} else {
Ok(None) // 空库
}
}
(Err(e), _) => Err(e)
(_, Err(e)) => Err(e)
}
}
None => {
// head 缺失或损坏:双槽歧义 → 取能完整解析 header 且 gen 更大者
let slot0 = self.read_slot_if_valid(0)
let slot1 = self.read_slot_if_valid(1)
match (slot0, slot1) {
(Ok(Some(a)), Ok(Some(b))) => {
let gen_a = snapshot_gen_of(a)
let gen_b = snapshot_gen_of(b)
let pick_b = match (gen_a, gen_b) {
(Some(ga), Some(gb)) => gb >= ga
(None, Some(_)) => true
(Some(_), None) => false
(None, None) => false
}
if pick_b {
self.slot = 1
Ok(Some(b))
} else {
self.slot = 0
Ok(Some(a))
}
}
(Ok(Some(a)), Ok(None)) => {
self.slot = 0
Ok(Some(a))
}
(Ok(None), Ok(Some(b))) => {
self.slot = 1
Ok(Some(b))
}
(Ok(None), Ok(None)) => {
let base = self.base_dir
let p0 = self.slot_path(0)
let p1 = self.slot_path(1)
if @fs.path_exists(p0) || @fs.path_exists(p1) {
Err(
MoomemError::StoreCorrupted(
"both snapshot slots exist but are unreadable (base: '\{base}')",
),
)
} else {
Ok(None) // 空库
}
}
(Err(e), _) => Err(e)
(_, Err(e)) => Err(e)
}
}
}
} catch {
e =>
Err(
MoomemError::IoFailure(
io_error_message("load (base '\{self.base_dir}')", e),
),
)
}
}
///|
pub impl PersistenceBackend for FsBackend with fn save(self, snapshot) {
try {
mkdir_p(self.base_dir)
mkdir_p(self.moomem_dir())
let target = 1 - self.slot
// 1. 完整写非当前槽
@fs.write_string_to_file(self.slot_path(target), snapshot)
// 2. 成功后写 head(内容仅 1 字节,实践上近原子)
@fs.write_string_to_file(self.head_path(), "\{target}")
self.slot = target
Ok(())
} catch {
e =>
Err(
MoomemError::IoFailure(
io_error_message("save (base '\{self.base_dir}')", e),
),
)
}
}
///|
pub extend FsBackend with PersistenceBackend::{save, load}
///|
/// 将 @fs 的 IOError 转为可读消息。
fn io_error_message(op : String, e : @fs.IOError) -> String {
let detail : String = match e {
@fs.IOError(msg) => msg
}
"\{op} failed: \{detail}"
}
///|
/// 逐级创建目录(x/fs 的 create_dir 在 native 不支持嵌套路径)。
/// 已存在的层级跳过;绝对路径以 "/" 起始。
fn mkdir_p(path : String) -> Unit raise @fs.IOError {
if @fs.path_exists(path) {
return
}
let segments : Array[String] = []
for seg in path.split("/") {
segments.push(seg.to_owned())
}
let mut acc = ""
for i = 0; i < segments.length(); i = i + 1 {
let seg = segments[i]
if acc.length() == 0 {
if seg.length() == 0 {
acc = "/" // 绝对路径起始
continue
}
acc = seg // 相对路径首段
} else {
if seg.length() == 0 || seg == "." {
continue
}
if acc == "/" {
acc = "/" + seg
} else {
acc = acc + "/" + seg
}
}
if seg.length() > 0 && seg != "." && !@fs.path_exists(acc) {
@fs.create_dir(acc)
}
}
}
// ---------------------------------------------------------------------------
// 缺省后端:native 用 FsBackend,wasm/js 用 MemoryBackend
// ---------------------------------------------------------------------------
///|
#cfg(target="native")
pub fn default_backend(
path : String,
) -> Result[&PersistenceBackend, MoomemError] {
let backend = FsBackend::new(path)
Ok(backend as &PersistenceBackend)
}
///|
#cfg(not(target="native"))
pub fn default_backend(
_path : String,
) -> Result[&PersistenceBackend, MoomemError] {
// wasm/js 无本地文件系统:缺省进程内后端;宿主可通过 config.persistence
// 注入自己的持久化适配(如浏览器 IndexedDB 胶水)。
let backend = MemoryBackend::new()
Ok(backend as &PersistenceBackend)
}
///|
test "MemoryBackend: simulated save failure surfaces IoFailure" {
let backend = MemoryBackend::new()
backend.set_fail_next_save(true)
let failed = match backend.save("whatever") {
Err(IoFailure(_)) => true
_ => false
}
inspect(failed, content="true")
inspect(backend.peek() == None, content="true")
}
///|
test "MemoryBackend: partial write stores only the prefix" {
let backend = MemoryBackend::new()
backend.set_simulate_partial_write(Some(4))
let saved = match backend.save("abcdefgh") {
Ok(_) => true
Err(_) => false
}
inspect(saved, content="true")
inspect(backend.peek() == Some("abcd"), content="true")
backend.set_simulate_partial_write(Some(-1))
let negative = match backend.save("abcdefgh") {
Ok(_) => true
Err(_) => false
}
inspect(negative, content="true")
inspect(backend.peek() == Some(""), content="true")
backend.set_simulate_partial_write(Some(100))
let oversized = match backend.save("abcdefgh") {
Ok(_) => true
Err(_) => false
}
inspect(oversized, content="true")
inspect(backend.peek() == Some("abcdefgh"), content="true")
}
///|
test "crash recovery: partial write (killed mid-flush) truncates tail" {
let e1 : MemoryEntry = MemoryEntry::{
id: "000000000001-00aa",
user_id: "user-42",
content: "我对花生过敏",
kind: Unstructured,
embedding: [],
keywords: tokenize("我对花生过敏"),
status: Active,
created_at: 1L,
superseded_by: None,
seq: 1,
metadata: Map([]),
}
let e2 : MemoryEntry = MemoryEntry::{
id: "000000000002-00bb",
user_id: "user-42",
content: "我住在深圳",
kind: Unstructured,
embedding: [],
keywords: tokenize("我住在深圳"),
status: Active,
created_at: 2L,
superseded_by: None,
seq: 2,
metadata: Map([]),
}
let full = build_snapshot_text(3, 2L, [e1, e2])
let cut = full.length() - 10
let partial = full[:cut].to_owned()
let result = match parse_snapshot_text(partial) {
Ok(r) => r
Err(e) => {
inspect(e.message(), content="SHOULD NOT REACH")
return
}
}
inspect(result.gen, content="3")
inspect(result.entries.length(), content="1")
inspect(result.entries[0].content, content="我对花生过敏")
inspect(result.truncated_recovered, content="1")
}
///|
test "simulated save failure surfaces from add, forget, import, and close" {
let backend = MemoryBackend::new()
let store = match
MemoryStore::open("memory://gap-io", config=Config::{
..Config::default(),
persistence: Some(backend),
}) {
Ok(store) => store
Err(_) => abort("open")
}
ignore(store.add("gap-io", "first fact"))
backend.set_fail_next_save(true)
let add_failed = match store.add("gap-io", "second fact") {
Err(IoFailure(_)) => true
_ => false
}
inspect(add_failed, content="true")
backend.set_fail_next_save(true)
let forget_failed = match store.forget("gap-io", All) {
Err(IoFailure(_)) => true
_ => false
}
inspect(forget_failed, content="true")
let line = "{\"id\":\"imp-1\",\"user_id\":\"gap-io\",\"content\":\"imported alpha\",\"kind\":\"fact\",\"embedding\":[],\"keywords\":[\"imported\",\"alpha\"],\"status\":\"active\",\"created_at\":9,\"seq\":9}"
backend.set_fail_next_save(true)
let import_failed = match store.import_jsonl(line + "\n") {
Err(IoFailure(_)) => true
_ => false
}
inspect(import_failed, content="true")
backend.set_fail_next_save(true)
let close_failed = match store.close() {
Err(IoFailure(_)) => true
_ => false
}
inspect(close_failed, content="true")
}