use crate::fact::{Fact, FactId};
#[cfg(feature = "persistence")]
use crate::wal::{FactWalStore, WalWriter, DEFAULT_MAX_WAL_SIZE_BYTES};
use evorule_tcb::path::resolve_path_mut;
use evorule_tcb::JsonValue;
#[cfg(kani)]
use std::cell::RefCell;
use std::collections::BTreeMap;
#[cfg(feature = "persistence")]
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
#[cfg(not(kani))]
use std::sync::RwLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FactsLogError {
VersionOverflow,
HashError(String),
CompactedRead {
from_version: u64,
compacted_version: u64,
compacted_count: usize,
},
#[cfg(feature = "persistence")]
WalError(String),
}
impl core::fmt::Display for FactsLogError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
FactsLogError::VersionOverflow => write!(f, "facts log version overflow"),
FactsLogError::HashError(msg) => write!(f, "facts log hash error: {msg}"),
FactsLogError::CompactedRead {
from_version,
compacted_version,
compacted_count,
} => write!(
f,
"facts log read from version {from_version} precedes compaction point \
{compacted_version} ({compacted_count} facts dropped from memory projection; \
replay from WAL for the full prefix)"
),
#[cfg(feature = "persistence")]
FactsLogError::WalError(msg) => write!(f, "facts log WAL error: {msg}"),
}
}
}
impl std::error::Error for FactsLogError {}
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct CompactedSnapshot {
version: u64,
snapshot: JsonValue,
queue: Vec<JsonValue>,
last_hash: String,
compacted_count: usize,
}
struct FactsLogInner {
history: Vec<(u64, Fact)>,
current_snapshot: JsonValue,
current_queue: Vec<JsonValue>,
version: u64,
last_stable_version: u64,
last_hash: String,
#[cfg(feature = "persistence")]
wal: Option<Box<dyn FactWalStore>>,
#[cfg(feature = "persistence")]
fsync_on_flush: bool,
#[cfg(feature = "persistence")]
max_wal_size_bytes: u64,
version_index: BTreeMap<u64, usize>,
fact_id_index: BTreeMap<FactId, usize>,
path_index: BTreeMap<String, Vec<usize>>,
compacted_snapshot: Option<CompactedSnapshot>,
next_anchor_id: u64,
}
fn apply_fact_to_state(inner: &mut FactsLogInner, fact: &Fact) -> Result<(), FactsLogError> {
#[cfg(kani)]
{
match fact {
Fact::StateTransition { .. } | Fact::IoResponse { .. } | Fact::PayloadUpdate { .. } => {
inner.version = inner
.version
.checked_add(1)
.ok_or(FactsLogError::VersionOverflow)?;
}
Fact::Stable { .. } => {
inner.last_stable_version = inner.version;
}
Fact::Command { .. }
| Fact::IoRequest { .. }
| Fact::Error { .. }
| Fact::TransitionTrace { .. }
| Fact::Violation { .. } => {}
}
return Ok(());
}
#[cfg(not(kani))]
match fact {
Fact::StateTransition {
new_payload,
new_queue,
..
} => {
inner.current_snapshot = new_payload.clone();
inner.current_queue = new_queue.clone();
inner.version = inner
.version
.checked_add(1)
.ok_or(FactsLogError::VersionOverflow)?;
}
Fact::IoResponse { .. } => {
inner.version = inner
.version
.checked_add(1)
.ok_or(FactsLogError::VersionOverflow)?;
}
Fact::Stable { .. } => {
inner.last_stable_version = inner.version;
}
Fact::PayloadUpdate { path, value, .. } => {
if let Some(target) = resolve_path_mut(&mut inner.current_snapshot, path) {
*target = value.clone();
} else {
let parts: Vec<&str> = path.split('.').collect();
if !parts.is_empty() {
if parts.len() == 1 && !path.contains('[') {
if let JsonValue::Object(map) = &mut inner.current_snapshot {
map.insert(path.clone(), value.clone());
}
} else {
let mut current = &mut inner.current_snapshot;
for (i, &part) in parts.iter().enumerate() {
if i == parts.len() - 1 {
if let JsonValue::Object(map) = current {
map.insert(part.to_string(), value.clone());
}
} else if let JsonValue::Object(map) = current {
if !map.contains_key(part) {
map.insert(part.to_string(), JsonValue::empty_object());
}
if let Some(next) = map.get_mut(part) {
current = next;
} else {
break;
}
} else {
break;
}
}
}
}
}
inner.version = inner
.version
.checked_add(1)
.ok_or(FactsLogError::VersionOverflow)?;
}
Fact::Command { .. } | Fact::IoRequest { .. } | Fact::Error { .. } => {}
Fact::TransitionTrace { .. } => {
}
Fact::Violation { .. } => {
}
}
Ok(())
}
#[cfg(not(kani))]
struct FactsLogLock(RwLock<FactsLogInner>);
#[cfg(kani)]
struct FactsLogLock(RefCell<FactsLogInner>);
#[cfg(kani)]
#[allow(unsafe_code)]
unsafe impl Sync for FactsLogLock {}
#[cfg(not(kani))]
impl FactsLogLock {
fn new(inner: FactsLogInner) -> Self {
Self(RwLock::new(inner))
}
fn read(&self) -> std::sync::RwLockReadGuard<'_, FactsLogInner> {
self.0.read().unwrap_or_else(|e| e.into_inner())
}
fn write(&self) -> std::sync::RwLockWriteGuard<'_, FactsLogInner> {
self.0.write().unwrap_or_else(|e| e.into_inner())
}
}
#[cfg(kani)]
impl FactsLogLock {
fn new(inner: FactsLogInner) -> Self {
Self(RefCell::new(inner))
}
fn read(&self) -> std::cell::Ref<'_, FactsLogInner> {
self.0.borrow()
}
fn write(&self) -> std::cell::RefMut<'_, FactsLogInner> {
self.0.borrow_mut()
}
}
#[cfg(feature = "persistence")]
pub fn replay_wal_records(records: &[crate::wal::WalRecord]) -> Result<FactsLog, FactsLogError> {
let log = FactsLog::new();
{
let mut inner = log.inner.write();
let mut has_hash_records = false;
for record in records {
let version_before = record.version_before;
let fact = &record.fact;
if record.chain_hash.is_some() {
has_hash_records = true;
}
inner.history.push((version_before, fact.clone()));
let idx = inner.history.len() - 1;
inner.version_index.entry(version_before).or_insert(idx);
inner.fact_id_index.insert(fact.id(), idx);
if let Fact::PayloadUpdate { path, .. } = fact {
inner.path_index.entry(path.clone()).or_default().push(idx);
}
apply_fact_to_state(&mut inner, fact)?;
}
if has_hash_records {
if let Some(last_record) = records.last() {
if let Some(chain_hash) = &last_record.chain_hash {
inner.last_hash = chain_hash.clone();
} else {
let facts: Vec<Fact> = records.iter().map(|r| r.fact.clone()).collect();
inner.last_hash = crate::hash::compute_chain_hash(&facts)
.map_err(|e| FactsLogError::WalError(format!("hash recover error: {e}")))?;
}
}
} else {
let facts: Vec<Fact> = records.iter().map(|r| r.fact.clone()).collect();
inner.last_hash = crate::hash::compute_chain_hash(&facts)
.map_err(|e| FactsLogError::WalError(format!("hash recover error: {e}")))?;
}
}
Ok(log)
}
#[derive(Clone)]
pub struct FactsLog {
inner: Arc<FactsLogLock>,
#[cfg(feature = "persistence")]
wal_fail_count: Arc<AtomicU64>,
#[cfg(feature = "persistence")]
on_wal_failure_exhausted: WalFailureExhaustedCallback,
}
#[cfg(feature = "persistence")]
type WalFailureExhaustedCallback = Arc<std::sync::Mutex<Option<Box<dyn Fn(&str) + Send + Sync>>>>;
#[cfg(feature = "persistence")]
pub const WAL_FAIL_TERMINATE_THRESHOLD: u64 = 3;
#[cfg(feature = "persistence")]
fn wal_failure_guidance(error_msg: &str) -> String {
format!(
"审计日志(WAL)写入已连续 {} 次失败,本会话已被终止以保证可回放性不被静默破坏。\
错误详情: {error_msg}。\
该故障源于磁盘/权限等部署环境问题,请按以下顺序排查:
1. 检查磁盘空间是否已满(df -h / Windows 资源管理器查看 WAL 所在盘剩余空间),清理后重启服务即可自动恢复;
2. 检查进程对 WAL 目录是否有写权限(目录可能被迁移或 ACL 变更);
3. 若 WAL 位于网络盘/NAS,检查挂载是否掉线;
4. 排查后重启 evorule-server;recover 会从已有 WAL 完整重建状态,不丢数据。
注意:本会话中断前的事实已在最后成功写入的 WAL 记录内,重启后可通过 /replay 审计。",
WAL_FAIL_TERMINATE_THRESHOLD
)
}
impl std::fmt::Debug for FactsLog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let inner = self.inner.read();
let mut s = f.debug_struct("FactsLog");
s.field("version", &inner.version)
.field("history_len", &inner.history.len());
#[cfg(feature = "persistence")]
{
s.field("has_wal", &inner.wal.is_some());
}
s.finish()
}
}
impl FactsLog {
fn with_inner(inner: FactsLogInner) -> Self {
Self {
inner: Arc::new(FactsLogLock::new(inner)),
#[cfg(feature = "persistence")]
wal_fail_count: Arc::new(AtomicU64::new(0)),
#[cfg(feature = "persistence")]
on_wal_failure_exhausted: Arc::new(std::sync::Mutex::new(None)),
}
}
pub fn new() -> Self {
Self::with_inner(FactsLogInner {
history: Vec::new(),
current_snapshot: JsonValue::empty_object(),
current_queue: Vec::new(),
version: 0,
last_stable_version: 0,
last_hash: String::from("genesis"),
#[cfg(feature = "persistence")]
wal: None,
#[cfg(feature = "persistence")]
fsync_on_flush: false,
#[cfg(feature = "persistence")]
max_wal_size_bytes: DEFAULT_MAX_WAL_SIZE_BYTES,
version_index: BTreeMap::new(),
fact_id_index: BTreeMap::new(),
path_index: BTreeMap::new(),
compacted_snapshot: None,
next_anchor_id: 1_000_000_000,
})
}
pub fn with_initial_payload(payload: JsonValue) -> Self {
let log = Self::new();
{
let mut inner = log.inner.write();
inner.current_snapshot = payload;
}
log
}
#[cfg(feature = "persistence")]
pub fn set_on_wal_failure_exhausted<F>(&self, callback: F)
where
F: Fn(&str) + Send + Sync + 'static,
{
if let Ok(mut guard) = self.on_wal_failure_exhausted.lock() {
*guard = Some(Box::new(callback));
}
}
#[cfg(feature = "persistence")]
pub fn wal_fail_count(&self) -> u64 {
self.wal_fail_count.load(Ordering::SeqCst)
}
pub fn set_initial_state(&self, payload: JsonValue, version: u64) {
let mut inner = self.inner.write();
let digest = crate::hash::content_hash(&payload).unwrap_or_default();
let anchor_value = JsonValue::object_from_pairs(&[
("digest", JsonValue::string(digest)),
("version", JsonValue::integer(version as i64)),
]);
let anchor_id = FactId(inner.next_anchor_id);
inner.next_anchor_id = inner.next_anchor_id.saturating_add(1);
let fact = Fact::PayloadUpdate {
id: anchor_id,
path: "__fork__.initial_state".to_string(),
value: anchor_value,
};
drop(inner);
let _ = self.append(fact); let mut inner = self.inner.write();
inner.current_snapshot = payload;
inner.version = version;
inner.last_stable_version = version;
}
#[cfg(feature = "persistence")]
pub fn with_wal<P: AsRef<std::path::Path>>(path: P) -> Result<Self, FactsLogError> {
Self::with_wal_and_fsync(path, false)
}
#[cfg(feature = "persistence")]
pub fn with_wal_and_fsync<P: AsRef<std::path::Path>>(
path: P,
fsync: bool,
) -> Result<Self, FactsLogError> {
Self::with_wal_options(path, DEFAULT_MAX_WAL_SIZE_BYTES, fsync)
}
#[cfg(feature = "persistence")]
pub fn with_wal_options<P: AsRef<std::path::Path>>(
path: P,
max_wal_size_bytes: u64,
fsync: bool,
) -> Result<Self, FactsLogError> {
let wal = WalWriter::create_with_options(path, max_wal_size_bytes, fsync)
.map_err(|e| FactsLogError::WalError(e.to_string()))?;
Ok(Self::with_inner(FactsLogInner {
history: Vec::new(),
current_snapshot: JsonValue::empty_object(),
current_queue: Vec::new(),
version: 0,
last_stable_version: 0,
last_hash: String::from("genesis"),
wal: Some(Box::new(wal)),
fsync_on_flush: fsync,
max_wal_size_bytes,
version_index: BTreeMap::new(),
fact_id_index: BTreeMap::new(),
path_index: BTreeMap::new(),
compacted_snapshot: None,
next_anchor_id: 1_000_000_000,
}))
}
#[cfg(feature = "persistence")]
pub fn with_wal_store(store: Box<dyn FactWalStore>) -> Self {
Self::with_inner(FactsLogInner {
history: Vec::new(),
current_snapshot: JsonValue::empty_object(),
current_queue: Vec::new(),
version: 0,
last_stable_version: 0,
last_hash: String::from("genesis"),
wal: Some(store),
fsync_on_flush: false,
max_wal_size_bytes: DEFAULT_MAX_WAL_SIZE_BYTES,
version_index: BTreeMap::new(),
fact_id_index: BTreeMap::new(),
path_index: BTreeMap::new(),
compacted_snapshot: None,
next_anchor_id: 1_000_000_000,
})
}
#[cfg(feature = "persistence")]
pub fn recover<P: AsRef<std::path::Path>>(path: P) -> Result<Self, FactsLogError> {
Self::recover_with_fsync(path, false)
}
#[cfg(feature = "persistence")]
pub fn recover_with_fsync<P: AsRef<std::path::Path>>(
path: P,
fsync: bool,
) -> Result<Self, FactsLogError> {
Self::recover_with_options(path, DEFAULT_MAX_WAL_SIZE_BYTES, fsync)
}
#[cfg(feature = "persistence")]
pub fn recover_with_options<P: AsRef<std::path::Path>>(
path: P,
max_wal_size_bytes: u64,
fsync: bool,
) -> Result<Self, FactsLogError> {
use crate::wal::read_wal_with_hash;
let records =
read_wal_with_hash(&path).map_err(|e| FactsLogError::WalError(e.to_string()))?;
let log = replay_wal_records(&records)?;
{
let mut inner = log.inner.write();
let wal = WalWriter::append_with_options(path, max_wal_size_bytes, fsync)
.map_err(|e| FactsLogError::WalError(e.to_string()))?;
inner.wal = Some(Box::new(wal));
inner.fsync_on_flush = fsync;
inner.max_wal_size_bytes = max_wal_size_bytes;
}
Ok(log)
}
pub fn append(&self, fact: Fact) -> Result<u64, FactsLogError> {
let mut inner = self.inner.write();
let version_before = inner.version;
#[cfg(kani)]
{
apply_fact_to_state(&mut inner, &fact)?;
inner.history.push((version_before, fact));
return Ok(inner.version);
}
let content_hash = crate::hash::fact_hash(&fact)
.map_err(|e| FactsLogError::HashError(format!("hash error: {e}")))?;
let prev_hash = inner.last_hash.clone();
let chain_hash = crate::hash::chain_step(&prev_hash, &content_hash);
#[cfg(feature = "persistence")]
{
if let Some(wal) = inner.wal.as_mut() {
match wal.append_record_with_hash(
version_before,
&fact,
&content_hash,
&prev_hash,
&chain_hash,
) {
Ok(()) => {
self.wal_fail_count.store(0, Ordering::SeqCst);
}
Err(e) => {
let fail_count = self.wal_fail_count.fetch_add(1, Ordering::SeqCst) + 1;
if fail_count >= WAL_FAIL_TERMINATE_THRESHOLD {
let guidance = wal_failure_guidance(&e.to_string());
tracing::error!(
fail_count,
"WAL write failed {} times consecutively; escalating session termination",
WAL_FAIL_TERMINATE_THRESHOLD
);
drop(inner); if let Ok(guard) = self.on_wal_failure_exhausted.lock() {
if let Some(cb) = guard.as_ref() {
cb(&guidance);
}
}
return Err(FactsLogError::WalError(guidance));
}
return Err(FactsLogError::WalError(format!(
"WAL write failed (consecutive #{fail_count}/{}): {e}",
WAL_FAIL_TERMINATE_THRESHOLD
)));
}
}
}
}
inner.last_hash = chain_hash;
apply_fact_to_state(&mut inner, &fact)?;
let fact_id = fact.id();
let path_opt = match &fact {
Fact::PayloadUpdate { path, .. } => Some(path.clone()),
_ => None,
};
inner.history.push((version_before, fact));
let idx = inner.history.len() - 1;
inner.version_index.entry(version_before).or_insert(idx);
inner.fact_id_index.insert(fact_id, idx);
if let Some(path) = path_opt {
inner.path_index.entry(path).or_default().push(idx);
}
Ok(inner.version)
}
pub fn snapshot(&self) -> (JsonValue, Vec<JsonValue>, u64) {
let inner = self.inner.read();
(
inner.current_snapshot.clone(),
inner.current_queue.clone(),
inner.version,
)
}
pub fn read_from(&self, from_version: u64) -> Result<Vec<Fact>, FactsLogError> {
let inner = self.inner.read();
if let Some(ref compacted) = inner.compacted_snapshot {
if from_version < compacted.version {
return Err(FactsLogError::CompactedRead {
from_version,
compacted_version: compacted.version,
compacted_count: compacted.compacted_count,
});
}
}
let start = inner
.version_index
.range(from_version..)
.next()
.map(|(_, &idx)| idx)
.unwrap_or(inner.history.len());
Ok(inner
.history
.get(start..)
.unwrap_or(&[])
.iter()
.map(|(_, f)| f.clone())
.collect())
}
pub fn version(&self) -> u64 {
self.inner.read().version
}
pub fn last_stable_version(&self) -> u64 {
self.inner.read().last_stable_version
}
pub fn last_hash(&self) -> String {
self.inner.read().last_hash.clone()
}
pub fn history_len(&self) -> usize {
self.inner.read().history.len()
}
pub fn history(&self) -> Vec<Fact> {
let inner = self.inner.read();
inner.history.iter().map(|(_, f)| f.clone()).collect()
}
pub fn for_each_fact_from(&self, start: usize, mut f: impl FnMut(u64, &Fact)) -> usize {
let inner = self.inner.read();
let tail = match inner.history.get(start..) {
Some(t) => t,
None => return 0,
};
for (vb, fact) in tail.iter() {
f(*vb, fact);
}
tail.len()
}
pub fn history_with_versions(&self) -> Vec<(u64, Fact)> {
let inner = self.inner.read();
inner.history.iter().map(|(v, f)| (*v, f.clone())).collect()
}
pub fn history_last_with_versions(&self, n: usize) -> Vec<(u64, Fact)> {
let inner = self.inner.read();
let history = &inner.history;
let start = history.len().saturating_sub(n);
history
.get(start..)
.unwrap_or(&[])
.iter()
.map(|(v, f)| (*v, f.clone()))
.collect()
}
pub fn facts_by_path_prefix(&self, prefix: &str) -> Vec<(u64, Fact)> {
let inner = self.inner.read();
let mut result = Vec::new();
for (path, indices) in inner.path_index.range(prefix.to_string()..) {
if !path.starts_with(prefix) {
break; }
for &idx in indices {
if let Some((v, f)) = inner.history.get(idx) {
result.push((*v, f.clone()));
}
}
}
result
}
pub fn reset(&self) {
let mut inner = self.inner.write();
inner.history.clear();
inner.current_snapshot = JsonValue::empty_object();
inner.current_queue.clear();
inner.version = 0;
inner.last_stable_version = 0;
inner.last_hash = String::from("genesis");
inner.version_index.clear();
inner.fact_id_index.clear();
inner.path_index.clear();
inner.compacted_snapshot = None;
#[cfg(feature = "persistence")]
{
inner.wal = None;
}
}
pub fn compact(&self) -> f64 {
let mut inner = self.inner.write();
let split_point = inner
.history
.iter()
.position(|(v, _)| *v > inner.last_stable_version)
.unwrap_or(inner.history.len());
if split_point == 0 {
return 0.0; }
let compacted_count = split_point;
let total_before = inner.history.len();
inner.compacted_snapshot = Some(CompactedSnapshot {
version: inner.last_stable_version,
snapshot: inner.current_snapshot.clone(),
queue: inner.current_queue.clone(),
last_hash: inner.last_hash.clone(),
compacted_count,
});
inner.history.drain(0..split_point);
let history = std::mem::take(&mut inner.history);
inner.version_index.clear();
inner.fact_id_index.clear();
inner.path_index.clear();
for (new_idx, (version_before, fact)) in history.iter().enumerate() {
inner
.version_index
.entry(*version_before)
.or_insert(new_idx);
inner.fact_id_index.insert(fact.id(), new_idx);
if let Fact::PayloadUpdate { path, .. } = fact {
inner
.path_index
.entry(path.clone())
.or_default()
.push(new_idx);
}
}
inner.history = history;
compacted_count as f64 / total_before as f64
}
pub fn compacted_info(&self) -> Option<(u64, usize)> {
let inner = self.inner.read();
inner
.compacted_snapshot
.as_ref()
.map(|c| (c.version, c.compacted_count))
}
pub fn is_reusable(&self) -> bool {
Arc::strong_count(&self.inner) == 1
}
}
impl Default for FactsLog {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
#![allow(clippy::panic, clippy::expect_used, clippy::indexing_slicing)]
use super::*;
use crate::fact::{FactId, IoType};
#[cfg(feature = "persistence")]
#[test]
fn test_replay_wal_records_matches_append_path() {
use crate::wal::MemoryWalStore;
let store = MemoryWalStore::new();
let log = FactsLog::with_wal_store(Box::new(store.clone()));
let facts = vec![
Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
},
Fact::StateTransition {
id: FactId(2),
cause: FactId(1),
new_payload: JsonValue::object_from_pairs(&[("x", JsonValue::Integer(1))]),
new_queue: vec![],
},
Fact::PayloadUpdate {
id: FactId(3),
path: "y.z".into(),
value: JsonValue::string("v"),
},
Fact::Stable {
id: FactId(4),
version: 2,
},
];
for f in facts {
log.append(f).expect("append ok");
}
let (snap_a, queue_a, ver_a) = log.snapshot();
let hash_a = log.last_hash();
let records = store.records();
let replayed = replay_wal_records(&records).unwrap();
let (snap_b, queue_b, ver_b) = replayed.snapshot();
assert_eq!(ver_a, ver_b, "版本推进必须一致");
assert_eq!(snap_a, snap_b, "快照必须一致");
assert_eq!(queue_a, queue_b, "队列必须一致");
assert_eq!(hash_a, replayed.last_hash(), "链尾哈希必须一致");
assert_eq!(log.history_len(), replayed.history_len());
assert_eq!(replayed.last_stable_version(), 2);
}
#[cfg(feature = "persistence")]
#[test]
fn test_replay_wal_records_empty_and_legacy() {
use crate::wal::WalRecord;
let empty = replay_wal_records(&[]).unwrap();
assert_eq!(empty.last_hash(), "genesis");
assert_eq!(empty.version(), 0);
assert_eq!(empty.history_len(), 0);
let legacy_fact = Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
};
let legacy = vec![WalRecord {
version_before: 0,
fact: legacy_fact.clone(),
content_hash: None,
prev_hash: None,
chain_hash: None,
}];
let log = replay_wal_records(&legacy).unwrap();
assert_eq!(
log.last_hash(),
crate::hash::compute_chain_hash(&[legacy_fact]).unwrap(),
"旧格式(无哈希字段)必须全链重算"
);
assert_eq!(log.history_len(), 1);
}
#[test]
fn test_new_facts_log() {
let log = FactsLog::new();
let (payload, queue, version) = log.snapshot();
assert_eq!(version, 0);
assert_eq!(payload, JsonValue::empty_object());
assert!(queue.is_empty());
assert_eq!(log.history_len(), 0);
assert_eq!(log.last_stable_version(), 0);
}
#[test]
fn test_append_command_no_version_change() {
let log = FactsLog::new();
let v = log
.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
assert_eq!(v, 0); assert_eq!(log.history_len(), 1);
}
#[test]
fn test_append_state_transition_increments_version() {
let log = FactsLog::new();
let payload = JsonValue::object_from_pairs(&[("x", JsonValue::Integer(42))]);
let v = log
.append(Fact::StateTransition {
id: FactId(1),
cause: FactId(0),
new_payload: payload.clone(),
new_queue: vec![],
})
.unwrap();
assert_eq!(v, 1);
let (snap, queue, version) = log.snapshot();
assert_eq!(version, 1);
assert_eq!(snap, payload);
assert!(queue.is_empty());
}
#[test]
fn test_append_io_response_increments_version() {
let log = FactsLog::new();
let v = log
.append(Fact::IoResponse {
id: FactId(1),
request_id: FactId(2),
result: JsonValue::string("ok"),
error: None,
})
.unwrap();
assert_eq!(v, 1);
}
#[test]
fn test_append_stable_records_last_stable() {
let log = FactsLog::new();
log.append(Fact::StateTransition {
id: FactId(1),
cause: FactId(0),
new_payload: JsonValue::empty_object(),
new_queue: vec![],
})
.unwrap();
assert_eq!(log.version(), 1);
log.append(Fact::Stable {
id: FactId(2),
version: 0,
})
.unwrap();
assert_eq!(log.last_stable_version(), 1);
}
#[test]
fn test_read_from() {
let log = FactsLog::new();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
log.append(Fact::StateTransition {
id: FactId(2),
cause: FactId(1),
new_payload: JsonValue::empty_object(),
new_queue: vec![],
})
.unwrap();
log.append(Fact::IoRequest {
id: FactId(3),
cause: FactId(2),
io_type: IoType::call_external(),
params: JsonValue::empty_object(),
})
.unwrap();
log.append(Fact::IoResponse {
id: FactId(4),
request_id: FactId(3),
result: JsonValue::string("resp"),
error: None,
})
.unwrap();
let all = log.read_from(0).unwrap();
assert_eq!(all.len(), 4);
let from_v1 = log.read_from(1).unwrap();
assert_eq!(from_v1.len(), 2);
assert_eq!(from_v1[0].id(), FactId(3));
assert_eq!(from_v1[1].id(), FactId(4));
let from_v2 = log.read_from(2).unwrap();
assert!(from_v2.is_empty());
}
#[test]
fn test_clone_shares_state() {
let log = FactsLog::new();
let log2 = log.clone();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
assert_eq!(log2.history_len(), 1);
}
#[test]
fn test_with_initial_payload() {
let payload = JsonValue::object_from_pairs(&[("init", JsonValue::Integer(42))]);
let log = FactsLog::with_initial_payload(payload.clone());
let (snap, queue, version) = log.snapshot();
assert_eq!(version, 0); assert_eq!(snap, payload);
assert!(queue.is_empty());
assert_eq!(log.history_len(), 0); }
#[test]
fn test_snapshot_with_non_empty_queue() {
let log = FactsLog::new();
let payload = JsonValue::object_from_pairs(&[("x", JsonValue::Integer(1))]);
let queue = vec![JsonValue::string("instr1"), JsonValue::string("instr2")];
log.append(Fact::StateTransition {
id: FactId(1),
cause: FactId(0),
new_payload: payload.clone(),
new_queue: queue.clone(),
})
.unwrap();
let (snap, q, version) = log.snapshot();
assert_eq!(version, 1);
assert_eq!(snap, payload);
assert_eq!(q, queue);
}
#[test]
fn test_payload_update_increments_version() {
let log = FactsLog::new();
let v0 = log.version();
let v = log
.append(Fact::PayloadUpdate {
id: FactId(1),
path: "x".to_string(),
value: JsonValue::Integer(42),
})
.unwrap();
assert_eq!(v, v0 + 1); assert_eq!(log.version(), 1);
assert_eq!(log.history_len(), 1);
}
#[test]
fn test_io_request_does_not_change_version() {
let log = FactsLog::new();
let v = log
.append(Fact::IoRequest {
id: FactId(1),
cause: FactId(0),
io_type: IoType::call_external(),
params: JsonValue::empty_object(),
})
.unwrap();
assert_eq!(v, 0); assert_eq!(log.version(), 0);
}
#[test]
fn test_error_does_not_change_version() {
let log = FactsLog::new();
let v = log
.append(Fact::Error {
id: FactId(1),
message: "test error".to_string(),
})
.unwrap();
assert_eq!(v, 0); assert_eq!(log.version(), 0);
}
#[test]
fn test_version_sequence() {
let log = FactsLog::new();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
assert_eq!(log.version(), 0);
log.append(Fact::StateTransition {
id: FactId(2),
cause: FactId(1),
new_payload: JsonValue::empty_object(),
new_queue: vec![],
})
.unwrap();
assert_eq!(log.version(), 1);
log.append(Fact::IoRequest {
id: FactId(3),
cause: FactId(2),
io_type: IoType::call_external(),
params: JsonValue::empty_object(),
})
.unwrap();
assert_eq!(log.version(), 1);
log.append(Fact::IoResponse {
id: FactId(4),
request_id: FactId(3),
result: JsonValue::string("resp"),
error: None,
})
.unwrap();
assert_eq!(log.version(), 2);
log.append(Fact::Stable {
id: FactId(5),
version: 0,
})
.unwrap();
assert_eq!(log.version(), 2);
assert_eq!(log.last_stable_version(), 2);
}
#[test]
fn test_read_from_with_state_transition() {
let log = FactsLog::new();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
log.append(Fact::StateTransition {
id: FactId(2),
cause: FactId(1),
new_payload: JsonValue::empty_object(),
new_queue: vec![],
})
.unwrap();
log.append(Fact::StateTransition {
id: FactId(3),
cause: FactId(2),
new_payload: JsonValue::empty_object(),
new_queue: vec![],
})
.unwrap();
assert_eq!(log.read_from(0).unwrap().len(), 3);
let from_v1 = log.read_from(1).unwrap();
assert_eq!(from_v1.len(), 1);
assert_eq!(from_v1[0].id(), FactId(3));
assert!(log.read_from(2).unwrap().is_empty());
}
#[test]
fn test_history_preserves_order() {
let log = FactsLog::new();
let ids = [FactId(1), FactId(2), FactId(3), FactId(4)];
for &id in &ids {
log.append(Fact::Command {
id,
instruction: JsonValue::empty_object(),
})
.unwrap();
}
let history = log.history();
assert_eq!(history.len(), 4);
for (i, fact) in history.iter().enumerate() {
assert_eq!(fact.id(), ids[i]);
}
}
#[test]
fn test_facts_by_path_prefix_empty_history() {
let log = FactsLog::new();
let result = log.facts_by_path_prefix("any_prefix");
assert!(result.is_empty());
}
#[test]
fn test_facts_by_path_prefix_no_matches() {
let log = FactsLog::new();
log.append(Fact::PayloadUpdate {
id: FactId(1),
path: "agent_other.shared.note".to_string(),
value: JsonValue::string("hello"),
})
.unwrap();
let result = log.facts_by_path_prefix("agent_researcher");
assert!(result.is_empty());
}
#[test]
fn test_facts_by_path_prefix_single_match() {
let log = FactsLog::new();
log.append(Fact::PayloadUpdate {
id: FactId(1),
path: "agent_researcher.shared.note".to_string(),
value: JsonValue::string("hello"),
})
.unwrap();
let result = log.facts_by_path_prefix("agent_researcher.shared");
assert_eq!(result.len(), 1);
assert_eq!(result[0].1.id(), FactId(1));
}
#[test]
fn test_facts_by_path_prefix_multiple_matches() {
let log = FactsLog::new();
log.append(Fact::PayloadUpdate {
id: FactId(1),
path: "agent_researcher.shared.note1".to_string(),
value: JsonValue::string("v1"),
})
.unwrap();
log.append(Fact::PayloadUpdate {
id: FactId(2),
path: "agent_researcher.shared.note2".to_string(),
value: JsonValue::string("v2"),
})
.unwrap();
log.append(Fact::PayloadUpdate {
id: FactId(3),
path: "agent_other.shared.note3".to_string(),
value: JsonValue::string("v3"),
})
.unwrap();
let result = log.facts_by_path_prefix("agent_researcher.shared");
assert_eq!(result.len(), 2);
assert_eq!(result[0].1.id(), FactId(1));
assert_eq!(result[1].1.id(), FactId(2));
}
#[test]
fn test_facts_by_path_prefix_prefix_boundary() {
let log = FactsLog::new();
log.append(Fact::PayloadUpdate {
id: FactId(1),
path: "agent_researcher_shared.note".to_string(),
value: JsonValue::string("v1"),
})
.unwrap();
log.append(Fact::PayloadUpdate {
id: FactId(2),
path: "agent_researcher.shared.note".to_string(),
value: JsonValue::string("v2"),
})
.unwrap();
let result = log.facts_by_path_prefix("agent_researcher.");
assert_eq!(result.len(), 1);
assert_eq!(result[0].1.id(), FactId(2));
}
#[test]
fn test_facts_by_path_prefix_only_payload_update() {
let log = FactsLog::new();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
log.append(Fact::PayloadUpdate {
id: FactId(2),
path: "agent_researcher.shared.note".to_string(),
value: JsonValue::string("v1"),
})
.unwrap();
log.append(Fact::StateTransition {
id: FactId(3),
cause: FactId(1),
new_payload: JsonValue::empty_object(),
new_queue: vec![],
})
.unwrap();
let result = log.facts_by_path_prefix("agent_researcher");
assert_eq!(result.len(), 1);
assert_eq!(result[0].1.id(), FactId(2));
}
#[cfg_attr(not(feature = "persistence"), allow(dead_code))]
fn temp_wal_path(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"evorule_factslog_test_{name}_{}.jsonl",
std::process::id()
));
let _ = std::fs::remove_file(&p);
p
}
#[cfg(feature = "persistence")]
#[test]
fn test_facts_log_error_wal_error_display() {
let e = FactsLogError::WalError("disk full".into());
assert!(format!("{e}").contains("disk full"));
}
#[cfg(feature = "persistence")]
#[test]
fn test_with_wal_creates_empty_log() {
let path = temp_wal_path("with_wal_empty");
let log = FactsLog::with_wal(&path).unwrap();
assert_eq!(log.version(), 0);
assert_eq!(log.history_len(), 0);
assert!(std::fs::metadata(&path).is_ok());
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "persistence")]
#[test]
fn test_wal_persists_facts_across_drop() {
let path = temp_wal_path("persist_drop");
let log = FactsLog::with_wal(&path).unwrap();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::object_from_pairs(&[("type", JsonValue::string("increment"))]),
})
.unwrap();
log.append(Fact::StateTransition {
id: FactId(2),
cause: FactId(1),
new_payload: JsonValue::object_from_pairs(&[("x", JsonValue::Integer(42))]),
new_queue: vec![],
})
.unwrap();
log.append(Fact::Stable {
id: FactId(3),
version: 1,
})
.unwrap();
let (snap_before, _, ver_before) = log.snapshot();
let hist_before = log.history();
assert_eq!(ver_before, 1);
assert_eq!(hist_before.len(), 3);
drop(log);
let recovered = FactsLog::recover(&path).unwrap();
let (snap_after, _, ver_after) = recovered.snapshot();
let hist_after = recovered.history();
assert_eq!(ver_after, ver_before, "version should match after recovery");
assert_eq!(
snap_after, snap_before,
"snapshot should match after recovery"
);
assert_eq!(
hist_after.len(),
hist_before.len(),
"history length should match"
);
for (i, (a, b)) in hist_before.iter().zip(hist_after.iter()).enumerate() {
assert_eq!(a, b, "fact {i} should match after recovery");
}
assert_eq!(recovered.last_stable_version(), 1);
let _ = std::fs::remove_file(&path);
}
#[test]
#[cfg(feature = "persistence")]
fn test_recovered_log_can_continue_appending() {
let path = temp_wal_path("continue_append");
let log = FactsLog::with_wal(&path).unwrap();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
log.append(Fact::Stable {
id: FactId(2),
version: 0,
})
.unwrap();
drop(log);
let recovered = FactsLog::recover(&path).unwrap();
assert_eq!(recovered.history_len(), 2);
recovered
.append(Fact::Error {
id: FactId(3),
message: "post-recovery".into(),
})
.unwrap();
assert_eq!(recovered.history_len(), 3);
drop(recovered);
let recovered2 = FactsLog::recover(&path).unwrap();
assert_eq!(recovered2.history_len(), 3);
let history = recovered2.history();
assert_eq!(history[2].id(), FactId(3));
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "persistence")]
#[test]
fn test_recover_nonexistent_wal_returns_error() {
let path = temp_wal_path("nonexistent");
let result = FactsLog::recover(&path);
match result {
Err(FactsLogError::WalError(msg)) => {
assert!(!msg.is_empty());
}
Err(other) => panic!("expected WalError, got other error: {other:?}"),
Ok(_) => panic!("expected WalError, got Ok"),
}
}
#[cfg(feature = "persistence")]
#[test]
fn test_wal_disabled_when_using_new() {
let log = FactsLog::new();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
assert_eq!(log.history_len(), 1);
}
#[cfg(feature = "persistence")]
#[test]
fn test_wal_consecutive_failure_escalates_with_guidance() {
use std::sync::atomic::AtomicUsize;
use std::sync::Arc as StdArc;
let base = std::env::temp_dir().join(format!("evorule_w1_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let wal_path = base.join("wal.jsonl");
let log = FactsLog::with_wal_options(&wal_path, 1, false).unwrap();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
for seq in 1..=(WAL_FAIL_TERMINATE_THRESHOLD + 3) {
std::fs::create_dir(base.join(format!("wal.{seq}.jsonl"))).unwrap();
}
let escalated = StdArc::new(AtomicUsize::new(0));
let escalated_cb = escalated.clone();
log.set_on_wal_failure_exhausted(move |guidance| {
escalated_cb.fetch_add(1, Ordering::SeqCst);
assert!(
guidance.contains("磁盘") && guidance.contains("权限"),
"guidance must contain self-service recovery hints"
);
});
for i in 1..=WAL_FAIL_TERMINATE_THRESHOLD {
let result = log.append(Fact::Command {
id: FactId(i + 10),
instruction: JsonValue::empty_object(),
});
assert!(
result.is_err(),
"attempt {i} should fail on dir-as-rotate-target"
);
let err_msg = format!("{}", result.unwrap_err());
if i < WAL_FAIL_TERMINATE_THRESHOLD {
assert!(
err_msg.contains("#"),
"pre-threshold error carries consecutive count, got: {err_msg}"
);
} else {
assert!(
err_msg.contains("自助诊断") || err_msg.contains("排查"),
"escalated error should carry user guidance, got: {err_msg}"
);
}
}
assert_eq!(
escalated.load(Ordering::SeqCst),
1,
"callback should fire exactly once at threshold"
);
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(feature = "persistence")]
#[test]
fn test_wal_recovery_preserves_all_seven_fact_variants() {
let path = temp_wal_path("all_variants");
let log = FactsLog::with_wal(&path).unwrap();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::object_from_pairs(&[("a", JsonValue::Integer(1))]),
})
.unwrap();
log.append(Fact::PayloadUpdate {
id: FactId(2),
path: "x.y".into(),
value: JsonValue::String("v".into()),
})
.unwrap();
log.append(Fact::StateTransition {
id: FactId(3),
cause: FactId(1),
new_payload: JsonValue::object_from_pairs(&[("x", JsonValue::Integer(5))]),
new_queue: vec![JsonValue::String("q1".into())],
})
.unwrap();
log.append(Fact::IoRequest {
id: FactId(4),
cause: FactId(3),
io_type: IoType::call_external(),
params: JsonValue::object_from_pairs(&[("prompt", JsonValue::String("hi".into()))]),
})
.unwrap();
log.append(Fact::IoResponse {
id: FactId(5),
request_id: FactId(4),
result: JsonValue::String("resp".into()),
error: None,
})
.unwrap();
log.append(Fact::Stable {
id: FactId(6),
version: 2,
})
.unwrap();
log.append(Fact::Error {
id: FactId(7),
message: "all variants tested".into(),
})
.unwrap();
let original_history = log.history();
let (original_snap, original_queue, original_ver) = log.snapshot();
let original_last_stable = log.last_stable_version();
drop(log);
let recovered = FactsLog::recover(&path).unwrap();
let recovered_history = recovered.history();
let (recovered_snap, recovered_queue, recovered_ver) = recovered.snapshot();
let recovered_last_stable = recovered.last_stable_version();
assert_eq!(recovered_history.len(), original_history.len());
for (i, (a, b)) in original_history
.iter()
.zip(recovered_history.iter())
.enumerate()
{
assert_eq!(a, b, "fact {i} mismatch after recovery");
}
assert_eq!(recovered_snap, original_snap);
assert_eq!(recovered_queue, original_queue);
assert_eq!(recovered_ver, original_ver);
assert_eq!(recovered_last_stable, original_last_stable);
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "persistence")]
#[test]
fn test_with_wal_and_fsync_creates_empty_log() {
let path = temp_wal_path("with_wal_fsync_empty");
let log = FactsLog::with_wal_and_fsync(&path, true).unwrap();
assert_eq!(log.version(), 0);
assert_eq!(log.history_len(), 0);
assert!(std::fs::metadata(&path).is_ok());
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "persistence")]
#[test]
fn test_wal_fsync_persists_facts_across_drop() {
let path = temp_wal_path("fsync_persist_drop");
let log = FactsLog::with_wal_and_fsync(&path, true).unwrap();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::object_from_pairs(&[("type", JsonValue::string("increment"))]),
})
.unwrap();
log.append(Fact::StateTransition {
id: FactId(2),
cause: FactId(1),
new_payload: JsonValue::object_from_pairs(&[("x", JsonValue::Integer(42))]),
new_queue: vec![],
})
.unwrap();
let (snap_before, _, ver_before) = log.snapshot();
let hist_before = log.history();
assert_eq!(ver_before, 1);
assert_eq!(hist_before.len(), 2);
drop(log);
let recovered = FactsLog::recover_with_fsync(&path, true).unwrap();
let (snap_after, _, ver_after) = recovered.snapshot();
let hist_after = recovered.history();
assert_eq!(ver_after, ver_before);
assert_eq!(snap_after, snap_before);
assert_eq!(hist_after.len(), hist_before.len());
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "persistence")]
#[test]
fn test_recover_with_fsync_can_continue_appending() {
let path = temp_wal_path("fsync_continue_append");
let log = FactsLog::with_wal_and_fsync(&path, true).unwrap();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
drop(log);
let recovered = FactsLog::recover_with_fsync(&path, true).unwrap();
assert_eq!(recovered.history_len(), 1);
recovered
.append(Fact::Error {
id: FactId(2),
message: "post-recovery with fsync".into(),
})
.unwrap();
assert_eq!(recovered.history_len(), 2);
drop(recovered);
let recovered2 = FactsLog::recover(&path).unwrap();
assert_eq!(recovered2.history_len(), 2);
let history = recovered2.history();
assert_eq!(history[1].id(), FactId(2));
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "persistence")]
#[test]
fn test_fsync_false_is_default() {
let path = temp_wal_path("fsync_default");
let log = FactsLog::with_wal(&path).unwrap();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
drop(log);
let recovered = FactsLog::recover(&path).unwrap();
assert_eq!(recovered.history_len(), 1);
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "persistence")]
#[test]
fn test_wal_rotation_creates_multiple_files() {
let path = temp_wal_path("rotation_create");
let log = FactsLog::with_wal_options(&path, 100, false).unwrap();
for i in 0..10 {
log.append(Fact::Command {
id: FactId(i as u64 + 1),
instruction: JsonValue::object_from_pairs(&[(
"data",
JsonValue::string("x".repeat(50)),
)]),
})
.unwrap();
}
assert_eq!(log.history_len(), 10);
drop(log);
let file_stem = path.file_stem().unwrap().to_string_lossy().to_string();
let files = std::fs::read_dir(path.parent().unwrap()).unwrap();
let wal_files: Vec<_> = files
.filter_map(|e| {
let e = e.unwrap();
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with(&file_stem) {
Some(name)
} else {
None
}
})
.collect();
assert!(
wal_files.len() > 1,
"Expected multiple WAL files, got {:?}",
wal_files
);
for f in &wal_files {
let fp = path.parent().unwrap().join(f);
let _ = std::fs::remove_file(&fp);
}
}
#[cfg(feature = "persistence")]
#[test]
fn test_wal_rotation_recover_reads_all_files() {
let path = temp_wal_path("rotation_recover");
let log = FactsLog::with_wal_options(&path, 100, false).unwrap();
for i in 0..20 {
log.append(Fact::Command {
id: FactId(i as u64 + 1),
instruction: JsonValue::object_from_pairs(&[(
"data",
JsonValue::string("x".repeat(30)),
)]),
})
.unwrap();
}
let history_before = log.history();
assert_eq!(history_before.len(), 20);
drop(log);
let recovered = FactsLog::recover(&path).unwrap();
let history_after = recovered.history();
assert_eq!(history_after.len(), 20);
for i in 0..20 {
assert_eq!(history_after[i].id(), history_before[i].id());
}
let file_stem = path.file_stem().unwrap().to_string_lossy().to_string();
let files = std::fs::read_dir(path.parent().unwrap()).unwrap();
for e in files {
let e = e.unwrap();
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with(&file_stem) {
let _ = std::fs::remove_file(e.path());
}
}
}
#[cfg(feature = "persistence")]
#[test]
fn test_wal_rotation_default_size() {
let path = temp_wal_path("rotation_default");
let log = FactsLog::with_wal(&path).unwrap();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
assert_eq!(log.history_len(), 1);
drop(log);
let recovered = FactsLog::recover(&path).unwrap();
assert_eq!(recovered.history_len(), 1);
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "persistence")]
#[test]
fn test_wal_rotation_zero_disables_rotation() {
let path = temp_wal_path("rotation_zero");
let log = FactsLog::with_wal_options(&path, 0, false).unwrap();
for i in 0..100 {
log.append(Fact::Command {
id: FactId(i as u64 + 1),
instruction: JsonValue::object_from_pairs(&[(
"data",
JsonValue::string("x".repeat(100)),
)]),
})
.unwrap();
}
assert_eq!(log.history_len(), 100);
drop(log);
let file_stem = path.file_stem().unwrap().to_string_lossy().to_string();
let files = std::fs::read_dir(path.parent().unwrap()).unwrap();
let wal_files: Vec<_> = files
.filter_map(|e| {
let e = e.unwrap();
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with(&file_stem) {
Some(name)
} else {
None
}
})
.collect();
assert_eq!(
wal_files.len(),
1,
"Expected single WAL file when rotation disabled, got {:?}",
wal_files
);
let recovered = FactsLog::recover(&path).unwrap();
assert_eq!(recovered.history_len(), 100);
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "persistence")]
#[test]
fn test_wal_rotation_after_recovery() {
let path = temp_wal_path("rotation_after_recovery");
let log = FactsLog::with_wal_options(&path, 100, false).unwrap();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::string("initial"),
})
.unwrap();
drop(log);
let recovered = FactsLog::recover_with_options(&path, 100, false).unwrap();
assert_eq!(recovered.history_len(), 1);
for i in 0..20 {
recovered
.append(Fact::Command {
id: FactId(i as u64 + 2),
instruction: JsonValue::object_from_pairs(&[(
"data",
JsonValue::string("x".repeat(50)),
)]),
})
.unwrap();
}
assert_eq!(recovered.history_len(), 21);
drop(recovered);
let recovered2 = FactsLog::recover(&path).unwrap();
assert_eq!(recovered2.history_len(), 21);
let file_stem = path.file_stem().unwrap().to_string_lossy().to_string();
let files = std::fs::read_dir(path.parent().unwrap()).unwrap();
for e in files {
let e = e.unwrap();
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with(&file_stem) {
let _ = std::fs::remove_file(e.path());
}
}
}
#[cfg(feature = "persistence")]
#[test]
fn test_memory_wal_store_roundtrip_preserves_hash_fields() {
use crate::wal::{FactWalStore, MemoryWalStore};
let mut store = MemoryWalStore::new();
assert!(store.is_empty());
store
.append_record_with_hash(
0,
&Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
},
"c1",
"genesis",
"h1",
)
.unwrap();
store
.append_record_with_hash(
1,
&Fact::Error {
id: FactId(2),
message: "e".into(),
},
"c2",
"h1",
"h2",
)
.unwrap();
assert_eq!(store.len(), 2);
let records = store.into_records();
assert_eq!(records.len(), 2);
assert_eq!(records[0].version_before, 0);
assert_eq!(records[0].content_hash.as_deref(), Some("c1"));
assert_eq!(records[0].prev_hash.as_deref(), Some("genesis"));
assert_eq!(records[0].chain_hash.as_deref(), Some("h1"));
assert_eq!(records[1].version_before, 1);
assert_eq!(records[1].chain_hash.as_deref(), Some("h2"));
}
#[cfg(feature = "persistence")]
#[test]
fn test_with_wal_store_memory_backend_same_hash_chain_as_pure_memory() {
use crate::wal::MemoryWalStore;
let store = MemoryWalStore::new();
let probe = store.clone();
let log = FactsLog::with_wal_store(Box::new(store));
let log_pure = FactsLog::new();
let mut expected_vb: Vec<u64> = Vec::new();
let mut cur_version: u64 = 0;
for i in 0..5u64 {
let fact = if i % 2 == 0 {
Fact::PayloadUpdate {
id: FactId(i + 1),
path: format!("k{i}"),
value: JsonValue::Integer(i as i64),
}
} else {
Fact::Command {
id: FactId(i + 1),
instruction: JsonValue::empty_object(),
}
};
expected_vb.push(cur_version);
if matches!(fact, Fact::PayloadUpdate { .. }) {
cur_version += 1;
}
log.append(fact.clone()).unwrap();
log_pure.append(fact).unwrap();
}
assert_eq!(log.version(), log_pure.version());
assert_eq!(log.history_len(), log_pure.history_len());
assert_eq!(log.last_hash(), log_pure.last_hash());
assert_eq!(probe.len(), 5);
let records = probe.records();
let hist = log.history();
assert_eq!(records.len(), hist.len());
for (r, vb) in records.iter().zip(expected_vb.iter()) {
assert_eq!(r.version_before, *vb);
assert!(r.has_hash());
}
}
#[cfg(feature = "persistence")]
#[test]
fn test_file_backend_via_trait_unchanged_roundtrip() {
let path = temp_wal_path("reg026_file_trait");
let log = FactsLog::with_wal(&path).unwrap();
log.append(Fact::PayloadUpdate {
id: FactId(1),
path: "a".into(),
value: JsonValue::Integer(7),
})
.unwrap();
let expected_last = log.last_hash();
drop(log);
let recovered = FactsLog::recover(&path).unwrap();
assert_eq!(recovered.history_len(), 1);
assert_eq!(recovered.last_hash(), expected_last);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_a3_version_index_accelerates_read_from() {
let log = FactsLog::new();
for i in 0..50u64 {
log.append(Fact::Command {
id: FactId(i * 2 + 1),
instruction: JsonValue::empty_object(),
})
.unwrap();
log.append(Fact::StateTransition {
id: FactId(i * 2 + 2),
cause: FactId(i * 2 + 1),
new_payload: JsonValue::object_from_pairs(&[(
"count",
JsonValue::Integer(i as i64),
)]),
new_queue: vec![],
})
.unwrap();
}
assert_eq!(log.history_len(), 100);
assert_eq!(log.version(), 50);
let facts = log.read_from(25).unwrap();
assert_eq!(facts.len(), 50);
let all = log.read_from(0).unwrap();
assert_eq!(all.len(), 100);
}
#[test]
fn test_a3_path_index_accelerates_facts_by_path_prefix() {
let log = FactsLog::new();
for i in 0..20u64 {
log.append(Fact::PayloadUpdate {
id: FactId(i + 1),
path: format!("agent.shared.notes_{i}"),
value: JsonValue::Integer(i as i64),
})
.unwrap();
}
for i in 0..10u64 {
log.append(Fact::PayloadUpdate {
id: FactId(i + 21),
path: format!("agent.memory.fact_{i}"),
value: JsonValue::Integer(i as i64),
})
.unwrap();
}
assert_eq!(log.facts_by_path_prefix("agent.shared").len(), 20);
assert_eq!(log.facts_by_path_prefix("agent.memory").len(), 10);
assert_eq!(log.facts_by_path_prefix("agent").len(), 30);
assert_eq!(log.facts_by_path_prefix("nonexistent").len(), 0);
}
#[test]
fn test_a3_compact_reduces_history_size() {
let log = FactsLog::new();
for i in 0..1666u64 {
log.append(Fact::Command {
id: FactId(i * 3 + 1),
instruction: JsonValue::empty_object(),
})
.unwrap();
log.append(Fact::StateTransition {
id: FactId(i * 3 + 2),
cause: FactId(i * 3 + 1),
new_payload: JsonValue::object_from_pairs(&[(
"count",
JsonValue::Integer(i as i64),
)]),
new_queue: vec![],
})
.unwrap();
log.append(Fact::Stable {
id: FactId(i * 3 + 3),
version: 0,
})
.unwrap();
}
log.append(Fact::StateTransition {
id: FactId(4999),
cause: FactId(4998),
new_payload: JsonValue::object_from_pairs(&[("final", JsonValue::bool(true))]),
new_queue: vec![],
})
.unwrap(); log.append(Fact::Command {
id: FactId(5000),
instruction: JsonValue::empty_object(),
})
.unwrap();
assert_eq!(log.history_len(), 5000);
let (snapshot_before, queue_before, version_before) = log.snapshot();
let last_hash_before = log.last_hash();
let ratio = log.compact();
assert!(ratio >= 0.4, "压缩率 {ratio:.4} 应 >= 0.4 (40%)");
assert_eq!(log.history_len(), 1);
let (snapshot_after, queue_after, version_after) = log.snapshot();
assert_eq!(snapshot_after, snapshot_before);
assert_eq!(queue_after, queue_before);
assert_eq!(version_after, version_before);
assert_eq!(log.last_hash(), last_hash_before);
let (compacted_version, compacted_count) = log.compacted_info().expect("应有压缩快照");
assert_eq!(compacted_count, 4999);
assert_eq!(compacted_version, 1666);
}
#[test]
fn test_a3_compact_read_from_before_compaction_point_returns_explicit_error() {
let log = FactsLog::new();
for i in 0..10u64 {
log.append(Fact::Command {
id: FactId(i * 3 + 1),
instruction: JsonValue::empty_object(),
})
.unwrap();
log.append(Fact::StateTransition {
id: FactId(i * 3 + 2),
cause: FactId(i * 3 + 1),
new_payload: JsonValue::object_from_pairs(&[("v", JsonValue::Integer(i as i64))]),
new_queue: vec![],
})
.unwrap();
log.append(Fact::Stable {
id: FactId(i * 3 + 3),
version: 0,
})
.unwrap();
}
log.append(Fact::StateTransition {
id: FactId(31),
cause: FactId(30),
new_payload: JsonValue::object_from_pairs(&[("v", JsonValue::Integer(10))]),
new_queue: vec![],
})
.unwrap(); log.append(Fact::Command {
id: FactId(32),
instruction: JsonValue::empty_object(),
})
.unwrap();
assert!(!log.read_from(5).unwrap().is_empty());
log.compact();
match log.read_from(5) {
Err(FactsLogError::CompactedRead {
from_version,
compacted_version,
compacted_count,
}) => {
assert_eq!(from_version, 5);
assert_eq!(compacted_version, 10, "压缩点版本应为 last_stable_version");
assert!(compacted_count > 0, "应报告被压实丢弃的事实条数");
}
other => panic!("压缩点前读取应返回 CompactedRead,实际得到 {other:?}"),
}
assert_eq!(
log.read_from(11).unwrap().len(),
1,
"压缩后 read_from(11) 应返回 1 条"
);
let fresh = FactsLog::new();
assert!(
fresh.read_from(0).unwrap().is_empty(),
"真空历史应返回空 Vec,而非 CompactedRead —— 二者不得再混淆"
);
}
}