use crate::fact::{Fact, FactId};
#[cfg(feature = "persistence")]
use crate::wal::{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;
use std::sync::Arc;
#[cfg(not(kani))]
use std::sync::RwLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FactsLogError {
VersionOverflow,
HashError(String),
#[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}"),
#[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<WalWriter>,
#[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>,
}
#[cfg(not(kani))]
struct FactsLogLock(RwLock<FactsLogInner>);
#[cfg(kani)]
struct FactsLogLock(RefCell<FactsLogInner>);
#[cfg(kani)]
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()
}
}
#[derive(Clone)]
pub struct FactsLog {
inner: Arc<FactsLogLock>,
}
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 {
pub fn new() -> Self {
Self {
inner: Arc::new(FactsLogLock::new(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,
})),
}
}
pub fn with_initial_payload(payload: JsonValue) -> Self {
let log = Self::new();
{
let mut inner = log.inner.write();
inner.current_snapshot = payload;
}
log
}
pub fn set_initial_state(&self, payload: JsonValue, version: u64) {
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 {
inner: Arc::new(FactsLogLock::new(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(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,
})),
})
}
#[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 = Self::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);
}
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 { .. } => {}
}
}
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}")))?;
}
let wal = WalWriter::append_with_options(path, max_wal_size_bytes, fsync)
.map_err(|e| FactsLogError::WalError(e.to_string()))?;
inner.wal = Some(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)]
{
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 { .. } => {}
}
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() {
wal.append_record_with_hash(
version_before,
&fact,
&content_hash,
&prev_hash,
&chain_hash,
)
.map_err(|e| FactsLogError::WalError(e.to_string()))?;
}
}
inner.last_hash = chain_hash;
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 { .. } => {
}
}
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) -> Vec<Fact> {
let inner = self.inner.read();
if let Some(ref compacted) = inner.compacted_snapshot {
if from_version < compacted.version {
return Vec::new();
}
}
let start = inner
.version_index
.range(from_version..)
.next()
.map(|(_, &idx)| idx)
.unwrap_or(inner.history.len());
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 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};
#[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),
final_snapshot: JsonValue::empty_object(),
})
.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);
assert_eq!(all.len(), 4);
let from_v1 = log.read_from(1);
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);
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),
final_snapshot: JsonValue::empty_object(),
})
.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).len(), 3);
let from_v1 = log.read_from(1);
assert_eq!(from_v1.len(), 1);
assert_eq!(from_v1[0].id(), FactId(3));
assert!(log.read_from(2).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),
final_snapshot: JsonValue::object_from_pairs(&[("x", JsonValue::Integer(42))]),
})
.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),
final_snapshot: JsonValue::empty_object(),
})
.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_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),
final_snapshot: JsonValue::object_from_pairs(&[("x", JsonValue::Integer(5))]),
})
.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());
}
}
}
#[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);
assert_eq!(facts.len(), 50);
let all = log.read_from(0);
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),
final_snapshot: JsonValue::empty_object(),
})
.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_empty() {
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),
final_snapshot: JsonValue::empty_object(),
})
.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).is_empty());
log.compact();
assert!(
log.read_from(5).is_empty(),
"压缩后 read_from(5) 应返回空 Vec"
);
assert_eq!(
log.read_from(11).len(),
1,
"压缩后 read_from(11) 应返回 1 条"
);
}
}