pub mod crdt;
use chrono::{DateTime, Duration, Utc};
use parking_lot::{Mutex, MutexGuard};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::ffi::OsString;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RestoreDurability {
Durable,
DurabilityUnknown { error: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestoreFailurePoint {
BeforePublication,
ParentDirectorySync,
}
#[derive(Debug, Clone, Default)]
pub struct RestoreFailureInjector {
failures: Arc<std::sync::Mutex<Vec<RestoreFailurePoint>>>,
}
impl RestoreFailureInjector {
pub fn fail_next(&self, point: RestoreFailurePoint) {
self.failures
.lock()
.expect("restore failure injector lock poisoned")
.push(point);
}
fn check(&self, point: RestoreFailurePoint) -> std::io::Result<()> {
let mut failures = self
.failures
.lock()
.map_err(|_| std::io::Error::other("restore failure injector lock poisoned"))?;
if failures.first() == Some(&point) {
failures.remove(0);
let message = match point {
RestoreFailurePoint::BeforePublication => {
"injected restore failure before publication"
}
RestoreFailurePoint::ParentDirectorySync => {
"injected restore parent directory sync failure"
}
};
return Err(std::io::Error::other(message));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StateTransition {
pub key: String,
pub old_value: Option<Value>,
pub new_value: Option<Value>,
pub action_id: String,
pub timestamp: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u64>,
}
enum TailRepair {
None,
Terminate,
TruncateTo(u64),
}
#[derive(Debug, Deserialize)]
struct BatchTransitionRecord {
batch: Vec<StateTransition>,
}
#[derive(Serialize)]
struct BatchTransitionRecordRef<'a> {
batch: &'a [StateTransition],
}
pub struct StateStore {
proposal_execution: tokio::sync::Mutex<()>,
state: Mutex<HashMap<String, Value>>,
transitions: Mutex<Vec<StateTransition>>,
versions: Mutex<HashMap<String, u64>>,
journal: Mutex<Option<Journal>>,
restore_failures: Option<RestoreFailureInjector>,
#[cfg(test)]
mutation_before_state_lock: Option<Arc<MutationRaceBarrier>>,
#[cfg(test)]
batch_mid_apply: Option<Arc<MutationRaceBarrier>>,
}
#[cfg(test)]
struct MutationRaceBarrier {
reached: std::sync::Barrier,
release: std::sync::Barrier,
}
#[cfg(test)]
impl MutationRaceBarrier {
fn new() -> Self {
Self {
reached: std::sync::Barrier::new(2),
release: std::sync::Barrier::new(2),
}
}
fn pause_mutation(&self) {
self.reached.wait();
self.release.wait();
}
}
struct Journal {
path: PathBuf,
writer: Option<BufWriter<File>>,
pending_parent_sync: Option<String>,
}
impl Journal {
fn reopen_writer(&mut self) -> std::io::Result<()> {
let file = OpenOptions::new().append(true).open(&self.path)?;
self.writer = Some(BufWriter::new(file));
Ok(())
}
fn writer_mut(&mut self) -> std::io::Result<&mut BufWriter<File>> {
if self.writer.is_none() {
self.reopen_writer()?;
}
self.writer
.as_mut()
.ok_or_else(|| std::io::Error::other("state journal writer is unavailable"))
}
}
const REPLACEMENT_TEMP_ATTEMPTS: usize = 32;
static NEXT_REPLACEMENT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
struct TempFileCleanup {
path: PathBuf,
armed: bool,
}
impl TempFileCleanup {
fn new(path: PathBuf) -> Self {
Self { path, armed: true }
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for TempFileCleanup {
fn drop(&mut self) {
if self.armed {
let _ = std::fs::remove_file(&self.path);
}
}
}
fn create_replacement_temp(destination: &Path) -> std::io::Result<(PathBuf, File)> {
let parent = destination.parent().unwrap_or_else(|| Path::new("."));
let file_name = destination.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"state journal path has no file name",
)
})?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
for _ in 0..REPLACEMENT_TEMP_ATTEMPTS {
let id = NEXT_REPLACEMENT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let mut temp_name = OsString::from(".");
temp_name.push(file_name);
temp_name.push(format!(
".restore.{}.{}.{}.tmp",
std::process::id(),
timestamp,
id
));
let temp_path = parent.join(temp_name);
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&temp_path) {
Ok(file) => return Ok((temp_path, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"could not allocate a unique state journal replacement after {REPLACEMENT_TEMP_ATTEMPTS} attempts"
),
))
}
#[cfg(unix)]
fn replace_file_atomically(temp: &Path, destination: &Path) -> std::io::Result<()> {
std::fs::rename(temp, destination)
}
#[cfg(target_os = "windows")]
fn replace_file_atomically(temp: &Path, destination: &Path) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::{
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
};
let temp: Vec<u16> = temp.as_os_str().encode_wide().chain(Some(0)).collect();
let destination: Vec<u16> = destination
.as_os_str()
.encode_wide()
.chain(Some(0))
.collect();
unsafe {
MoveFileExW(
PCWSTR(temp.as_ptr()),
PCWSTR(destination.as_ptr()),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
}
.map_err(|error| std::io::Error::other(error.to_string()))
}
#[cfg(not(any(unix, target_os = "windows")))]
fn replace_file_atomically(temp: &Path, destination: &Path) -> std::io::Result<()> {
std::fs::rename(temp, destination)
}
#[cfg(unix)]
fn sync_parent_directory(path: &Path) -> std::io::Result<()> {
File::open(path.parent().unwrap_or_else(|| Path::new(".")))?.sync_all()
}
#[cfg(target_os = "windows")]
fn sync_parent_directory(_path: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(not(any(unix, target_os = "windows")))]
fn sync_parent_directory(_path: &Path) -> std::io::Result<()> {
Ok(())
}
impl StateStore {
pub fn new() -> Self {
Self {
proposal_execution: tokio::sync::Mutex::new(()),
state: Mutex::new(HashMap::new()),
transitions: Mutex::new(Vec::new()),
versions: Mutex::new(HashMap::new()),
journal: Mutex::new(None),
restore_failures: None,
#[cfg(test)]
mutation_before_state_lock: None,
#[cfg(test)]
batch_mid_apply: None,
}
}
pub async fn lock_proposal_execution(&self) -> tokio::sync::MutexGuard<'_, ()> {
self.proposal_execution.lock().await
}
pub fn durable(path: impl Into<PathBuf>) -> std::io::Result<Self> {
Self::durable_with_restore_failure_injector(path, None)
}
#[doc(hidden)]
pub fn durable_with_restore_failure_injector(
path: impl Into<PathBuf>,
restore_failures: impl Into<Option<RestoreFailureInjector>>,
) -> std::io::Result<Self> {
let path = path.into();
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let mut store = Self::new();
store.restore_failures = restore_failures.into();
match store.replay_journal(&path)? {
TailRepair::None => {}
TailRepair::Terminate => {
let mut file = OpenOptions::new().append(true).open(&path)?;
file.write_all(b"\n")?;
file.sync_all()?;
}
TailRepair::TruncateTo(offset) => {
let file = OpenOptions::new().write(true).open(&path)?;
file.set_len(offset)?;
file.sync_all()?;
}
}
let file = OpenOptions::new().create(true).append(true).open(&path)?;
*store.journal.lock() = Some(Journal {
path,
writer: Some(BufWriter::new(file)),
pending_parent_sync: None,
});
Ok(store)
}
fn replay_journal(&self, path: &Path) -> std::io::Result<TailRepair> {
if !path.exists() {
return Ok(TailRepair::None);
}
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let now = Utc::now();
let mut state = self.state.lock();
let mut transitions = self.transitions.lock();
let mut versions = self.versions.lock();
let mut buf = Vec::new();
let mut offset: u64 = 0;
let mut repair = TailRepair::None;
loop {
buf.clear();
let read = match reader.read_until(b'\n', &mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
let line_start = offset;
offset += read as u64;
let terminated = buf.ends_with(b"\n");
if buf.iter().all(|byte| byte.is_ascii_whitespace()) {
if !terminated {
repair = TailRepair::TruncateTo(line_start);
break;
}
continue;
}
if let Ok(t) = serde_json::from_slice::<StateTransition>(&buf) {
replay_transition(&mut state, &mut transitions, &mut versions, now, t);
if !terminated {
repair = TailRepair::Terminate;
break;
}
continue;
}
if let Ok(record) = serde_json::from_slice::<BatchTransitionRecord>(&buf) {
for t in record.batch {
replay_transition(&mut state, &mut transitions, &mut versions, now, t);
}
if !terminated {
repair = TailRepair::Terminate;
break;
}
continue;
}
if !terminated {
repair = TailRepair::TruncateTo(line_start);
break;
}
tracing::warn!(
journal = %path.display(),
"skipping malformed StateStore journal line"
);
}
Ok(repair)
}
fn append_journal(&self, transition: &StateTransition) {
let Ok(json) = serde_json::to_string(transition) else {
return;
};
self.append_journal_line(json);
}
fn append_journal_batch(&self, batch: &[StateTransition]) {
match batch {
[] => {}
[single] => self.append_journal(single),
_ => {
let Ok(json) = serde_json::to_string(&BatchTransitionRecordRef { batch }) else {
return;
};
self.append_journal_line(json);
}
}
}
fn append_journal_line(&self, json: String) {
let mut journal = self.journal.lock();
let Some(journal) = journal.as_mut() else {
return;
};
let path = journal.path.clone();
let writer = match journal.writer_mut() {
Ok(writer) => writer,
Err(e) => {
tracing::warn!(
journal = %path.display(),
error = %e,
"StateStore journal writer reopen failed"
);
return;
}
};
if let Err(e) = writeln!(writer, "{json}") {
tracing::warn!(
journal = %path.display(),
error = %e,
"StateStore journal append failed"
);
return;
}
let _ = writer.flush();
}
fn replace_journal_with_snapshot(
&self,
state: &HashMap<String, Value>,
transitions: &[StateTransition],
versions: &HashMap<String, u64>,
) -> std::io::Result<RestoreDurability> {
let restore_failures = self.restore_failures.as_ref();
self.replace_journal_with_snapshot_and_sync(state, transitions, versions, |path| {
if let Some(failures) = restore_failures {
failures.check(RestoreFailurePoint::ParentDirectorySync)?;
}
sync_parent_directory(path)
})
}
fn replace_journal_with_snapshot_and_sync<F>(
&self,
state: &HashMap<String, Value>,
transitions: &[StateTransition],
versions: &HashMap<String, u64>,
sync_parent: F,
) -> std::io::Result<RestoreDurability>
where
F: FnOnce(&Path) -> std::io::Result<()>,
{
let mut journal = self.journal.lock();
let Some(journal) = journal.as_mut() else {
return Ok(RestoreDurability::Durable);
};
journal.writer_mut()?.flush()?;
let journal_permissions = std::fs::metadata(&journal.path)?.permissions();
let (tmp_path, tmp_file) = create_replacement_temp(&journal.path)?;
let mut cleanup = TempFileCleanup::new(tmp_path.clone());
let mut replacement = BufWriter::new(tmp_file);
let latest_by_key: HashMap<&str, &StateTransition> = transitions
.iter()
.map(|transition| (transition.key.as_str(), transition))
.collect();
let mut keys: Vec<&String> = state.keys().collect();
keys.sort();
for key in keys {
let previous = latest_by_key.get(key.as_str()).copied();
let transition = StateTransition {
key: key.clone(),
old_value: None,
new_value: state.get(key).cloned(),
action_id: previous
.map(|transition| transition.action_id.clone())
.unwrap_or_else(|| "restore".to_string()),
timestamp: previous
.map(|transition| transition.timestamp)
.unwrap_or_else(Utc::now),
ttl_secs: previous.and_then(|transition| transition.ttl_secs),
version: versions.get(key).copied(),
};
let line = serde_json::to_string(&transition)?;
writeln!(replacement, "{line}")?;
}
replacement.flush()?;
replacement.get_ref().set_permissions(journal_permissions)?;
replacement.get_ref().sync_all()?;
if let Some(failures) = &self.restore_failures {
failures.check(RestoreFailurePoint::BeforePublication)?;
}
#[cfg(target_os = "windows")]
{
drop(journal.writer.take());
drop(replacement);
if let Err(publication_error) = replace_file_atomically(&tmp_path, &journal.path) {
return match journal.reopen_writer() {
Ok(()) => Err(publication_error),
Err(reopen_error) => Err(std::io::Error::other(format!(
"state journal replacement failed ({publication_error}); reopening the original journal also failed ({reopen_error})"
))),
};
}
cleanup.disarm();
if let Err(error) = journal.reopen_writer() {
let error = format!(
"replacement journal was published but its append writer could not be reopened: {error}"
);
journal.pending_parent_sync = Some(error.clone());
return Ok(RestoreDurability::DurabilityUnknown { error });
}
}
#[cfg(not(target_os = "windows"))]
{
replace_file_atomically(&tmp_path, &journal.path)?;
cleanup.disarm();
journal.writer = Some(replacement);
}
match sync_parent(&journal.path) {
Ok(()) => {
journal.pending_parent_sync = None;
Ok(RestoreDurability::Durable)
}
Err(error) => {
let error = error.to_string();
journal.pending_parent_sync = Some(error.clone());
Ok(RestoreDurability::DurabilityUnknown { error })
}
}
}
fn reconcile_pending_parent_sync(&self) -> std::io::Result<()> {
let mut journal = self.journal.lock();
let Some(journal) = journal.as_mut() else {
return Ok(());
};
if journal.pending_parent_sync.is_none() {
return Ok(());
}
let writer = journal.writer_mut()?;
writer.flush()?;
writer.get_ref().sync_all()?;
if let Some(failures) = &self.restore_failures {
failures.check(RestoreFailurePoint::ParentDirectorySync)?;
}
sync_parent_directory(&journal.path)?;
journal.pending_parent_sync = None;
Ok(())
}
#[cfg(test)]
fn pause_before_mutation_state_lock(&self) {
if let Some(barrier) = &self.mutation_before_state_lock {
barrier.pause_mutation();
}
}
fn lock_reconciled_state_for_mutation(
&self,
) -> std::io::Result<MutexGuard<'_, HashMap<String, Value>>> {
#[cfg(test)]
self.pause_before_mutation_state_lock();
let state = self.state.lock();
self.reconcile_pending_parent_sync()?;
Ok(state)
}
fn require_reconciled_state_for_mutation(&self) -> MutexGuard<'_, HashMap<String, Value>> {
self.lock_reconciled_state_for_mutation()
.unwrap_or_else(|error| {
panic!(
"StateStore journal is durability-unknown; refusing mutation until parent sync succeeds: {error}"
)
})
}
pub fn sync(&self) -> std::io::Result<()> {
let mut journal = self.journal.lock();
let Some(journal) = journal.as_mut() else {
return Ok(());
};
let writer = journal.writer_mut()?;
writer.flush()?;
writer.get_ref().sync_all()?;
if journal.pending_parent_sync.is_some() {
sync_parent_directory(&journal.path)?;
journal.pending_parent_sync = None;
}
Ok(())
}
pub fn reap_expired(&self, now: DateTime<Utc>) -> std::io::Result<Vec<String>> {
self.reap_expired_where(now, |_| true)
}
pub fn reap_expired_scoped(
&self,
now: DateTime<Utc>,
tenant: Option<&str>,
) -> std::io::Result<Vec<String>> {
self.reap_expired_where(now, |k| key_in_tenant_namespace(k, tenant))
}
fn reap_expired_where(
&self,
now: DateTime<Utc>,
keep: impl Fn(&str) -> bool,
) -> std::io::Result<Vec<String>> {
let mut state = self.lock_reconciled_state_for_mutation()?;
let mut transitions = self.transitions.lock();
let mut latest_by_key: HashMap<&str, &StateTransition> = HashMap::new();
for t in transitions.iter() {
latest_by_key.insert(t.key.as_str(), t);
}
let expired: Vec<String> = latest_by_key
.values()
.filter_map(|t| {
if !keep(&t.key) {
return None;
}
let ttl = t.ttl_secs?;
t.new_value.as_ref()?;
let age = now.signed_duration_since(t.timestamp);
(age > Duration::seconds(ttl as i64)).then(|| t.key.clone())
})
.collect();
let mut reaped = Vec::new();
for key in expired {
if state.remove(&key).is_some() {
let version = self.bump_version(&key);
reaped.push(key.clone());
transitions.push(StateTransition {
key,
old_value: None,
new_value: None,
action_id: "reap".to_string(),
timestamp: now,
ttl_secs: None,
version: Some(version),
});
}
}
drop(state);
drop(transitions);
if !reaped.is_empty() {
self.compact_journal()?;
}
Ok(reaped)
}
pub(crate) fn compact_journal(&self) -> std::io::Result<()> {
let state = self.state.lock();
let transitions = self.transitions.lock();
let versions = self.versions.lock();
match self.replace_journal_with_snapshot(&state, &transitions, &versions)? {
RestoreDurability::Durable => Ok(()),
RestoreDurability::DurabilityUnknown { error } => Err(std::io::Error::other(format!(
"state journal compaction is durability-unknown: {error}"
))),
}
}
pub fn get(&self, key: &str) -> Option<Value> {
self.state.lock().get(key).cloned()
}
pub fn get_or(&self, key: &str, default: Value) -> Value {
self.state.lock().get(key).cloned().unwrap_or(default)
}
pub fn exists(&self, key: &str) -> bool {
self.state.lock().contains_key(key)
}
pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
self.set_inner(key, value, action_id, None)
}
pub fn set_with_ttl(
&self,
key: &str,
value: Value,
action_id: &str,
ttl_secs: u64,
) -> StateTransition {
self.set_inner(key, value, action_id, Some(ttl_secs))
}
fn set_inner(
&self,
key: &str,
value: Value,
action_id: &str,
ttl_secs: Option<u64>,
) -> StateTransition {
let mut state = self.require_reconciled_state_for_mutation();
let old = state.get(key).cloned();
state.insert(key.to_string(), value.clone());
let version = self.bump_version(key);
let t = StateTransition {
key: key.to_string(),
old_value: old,
new_value: Some(value),
action_id: action_id.to_string(),
timestamp: Utc::now(),
ttl_secs,
version: Some(version),
};
self.transitions.lock().push(t.clone());
self.append_journal(&t);
t
}
pub fn set_batch(
&self,
entries: Vec<(String, Value)>,
action_id: &str,
) -> Vec<StateTransition> {
if entries.is_empty() {
return Vec::new();
}
let state = self.require_reconciled_state_for_mutation();
self.set_batch_locked(state, entries, action_id)
}
fn set_batch_locked(
&self,
mut state: MutexGuard<'_, HashMap<String, Value>>,
entries: Vec<(String, Value)>,
action_id: &str,
) -> Vec<StateTransition> {
let timestamp = Utc::now();
let mut batch = Vec::with_capacity(entries.len());
for (key, value) in entries {
let old = state.get(&key).cloned();
state.insert(key.clone(), value.clone());
let version = self.bump_version(&key);
batch.push(StateTransition {
key,
old_value: old,
new_value: Some(value),
action_id: action_id.to_string(),
timestamp,
ttl_secs: None,
version: Some(version),
});
#[cfg(test)]
if batch.len() == 1 {
if let Some(barrier) = &self.batch_mid_apply {
barrier.pause_mutation();
}
}
}
self.transitions.lock().extend(batch.iter().cloned());
self.append_journal_batch(&batch);
batch
}
fn bump_version(&self, key: &str) -> u64 {
let mut versions = self.versions.lock();
let v = versions.entry(key.to_string()).or_insert(0);
*v += 1;
*v
}
pub fn version(&self, key: &str) -> Option<u64> {
self.versions.lock().get(key).copied()
}
pub fn versions(&self) -> HashMap<String, u64> {
self.versions.lock().clone()
}
pub fn versioned_snapshot(&self) -> (HashMap<String, Value>, HashMap<String, u64>) {
let state = self.state.lock();
let versions = self.versions.lock();
(state.clone(), versions.clone())
}
pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
let mut state = self.require_reconciled_state_for_mutation();
let old = state.remove(key)?;
let version = self.bump_version(key);
let t = StateTransition {
key: key.to_string(),
old_value: Some(old),
new_value: None,
action_id: action_id.to_string(),
timestamp: Utc::now(),
ttl_secs: None,
version: Some(version),
};
self.transitions.lock().push(t.clone());
self.append_journal(&t);
Some(t)
}
pub fn snapshot(&self) -> HashMap<String, Value> {
self.state.lock().clone()
}
pub fn restore(
&self,
snapshot: HashMap<String, Value>,
transition_count: usize,
) -> std::io::Result<RestoreDurability> {
let restore_failures = self.restore_failures.as_ref();
self.restore_with_parent_sync(snapshot, transition_count, |path| {
if let Some(failures) = restore_failures {
failures.check(RestoreFailurePoint::ParentDirectorySync)?;
}
sync_parent_directory(path)
})
}
fn restore_with_parent_sync<F>(
&self,
snapshot: HashMap<String, Value>,
transition_count: usize,
sync_parent: F,
) -> std::io::Result<RestoreDurability>
where
F: FnOnce(&Path) -> std::io::Result<()>,
{
let mut state = self.state.lock();
let mut transitions = self.transitions.lock();
let versions = self.versions.lock();
let mut restored_transitions = transitions.clone();
restored_transitions.truncate(transition_count);
let durability = self.replace_journal_with_snapshot_and_sync(
&snapshot,
&restored_transitions,
&versions,
sync_parent,
)?;
*state = snapshot;
*transitions = restored_transitions;
Ok(durability)
}
pub fn snapshot_scoped(&self, tenant: Option<&str>) -> HashMap<String, Value> {
let state = self.state.lock();
state
.iter()
.filter(|(k, _)| key_in_tenant_namespace(k, tenant))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
pub fn restore_scoped(
&self,
tenant: Option<&str>,
snapshot: HashMap<String, Value>,
transition_count: usize,
) -> std::io::Result<RestoreDurability> {
let mut state = self.state.lock();
let mut transitions = self.transitions.lock();
let versions = self.versions.lock();
let mut restored_state = state.clone();
restored_state.retain(|k, _| !key_in_tenant_namespace(k, tenant));
restored_state.extend(snapshot);
let mut restored_transitions = transitions.clone();
if transition_count < restored_transitions.len() {
let tail: Vec<StateTransition> = restored_transitions
.drain(transition_count..)
.filter(|transition| !key_in_tenant_namespace(&transition.key, tenant))
.collect();
restored_transitions.extend(tail);
}
let durability =
self.replace_journal_with_snapshot(&restored_state, &restored_transitions, &versions)?;
*state = restored_state;
*transitions = restored_transitions;
Ok(durability)
}
pub fn transition_count(&self) -> usize {
self.transitions.lock().len()
}
pub fn transitions(&self) -> Vec<StateTransition> {
self.transitions.lock().clone()
}
pub fn transitions_since(&self, index: usize) -> Vec<StateTransition> {
let transitions = self.transitions.lock();
let start = index.min(transitions.len());
transitions[start..].to_vec()
}
pub fn keys(&self) -> Vec<String> {
self.state.lock().keys().cloned().collect()
}
pub fn replace_all(&self, snapshot: HashMap<String, Value>) {
let mut state = self.require_reconciled_state_for_mutation();
*state = snapshot;
self.transitions.lock().clear();
}
pub fn scoped<'a>(&'a self, tenant: Option<&'a str>) -> ScopedStateView<'a> {
ScopedStateView {
store: self,
tenant,
}
}
}
fn replay_transition(
state: &mut HashMap<String, Value>,
transitions: &mut Vec<StateTransition>,
versions: &mut HashMap<String, u64>,
now: DateTime<Utc>,
t: StateTransition,
) {
if let (Some(ttl), Some(value)) = (t.ttl_secs, &t.new_value) {
if now.signed_duration_since(t.timestamp) > Duration::seconds(ttl as i64) {
state.remove(&t.key);
} else {
state.insert(t.key.clone(), value.clone());
}
} else if let Some(value) = &t.new_value {
state.insert(t.key.clone(), value.clone());
} else {
state.remove(&t.key);
}
let entry = versions.entry(t.key.clone()).or_insert(0);
let restored = t.version.unwrap_or(*entry + 1);
*entry = (*entry).max(restored);
transitions.push(t);
}
fn key_in_tenant_namespace(key: &str, tenant: Option<&str>) -> bool {
match tenant {
Some(t) if !t.is_empty() => key.starts_with(&format!("tenant:{t}:")),
_ => !key.starts_with("tenant:"),
}
}
pub struct ScopedStateView<'a> {
store: &'a StateStore,
tenant: Option<&'a str>,
}
impl<'a> ScopedStateView<'a> {
fn full_key(&self, key: &str) -> String {
match self.tenant {
Some(t) if !t.is_empty() => format!("tenant:{t}:{key}"),
_ => key.to_string(),
}
}
fn strip_prefix<'k>(&self, full: &'k str) -> Option<&'k str> {
match self.tenant {
Some(t) if !t.is_empty() => {
let prefix = format!("tenant:{t}:");
full.strip_prefix(&prefix)
}
_ => Some(full),
}
}
pub fn get(&self, key: &str) -> Option<Value> {
self.store.get(&self.full_key(key))
}
pub fn get_or(&self, key: &str, default: Value) -> Value {
self.store.get_or(&self.full_key(key), default)
}
pub fn snapshot(&self) -> HashMap<String, Value> {
self.store.snapshot_scoped(self.tenant)
}
pub fn snapshot_stripped(&self) -> HashMap<String, Value> {
self.store
.snapshot_scoped(self.tenant)
.into_iter()
.filter_map(|(key, value)| {
self.strip_prefix(&key)
.map(|stripped| (stripped.to_string(), value))
})
.collect()
}
pub fn restore(
&self,
snapshot: HashMap<String, Value>,
transition_count: usize,
) -> std::io::Result<RestoreDurability> {
self.store
.restore_scoped(self.tenant, snapshot, transition_count)
}
pub fn exists(&self, key: &str) -> bool {
self.store.exists(&self.full_key(key))
}
pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
self.store.set(&self.full_key(key), value, action_id)
}
pub fn set_with_ttl(
&self,
key: &str,
value: Value,
action_id: &str,
ttl_secs: u64,
) -> StateTransition {
self.store
.set_with_ttl(&self.full_key(key), value, action_id, ttl_secs)
}
pub fn set_batch(
&self,
entries: Vec<(String, Value)>,
action_id: &str,
) -> Vec<StateTransition> {
let prefixed = entries
.into_iter()
.map(|(key, value)| (self.full_key(&key), value))
.collect();
self.store.set_batch(prefixed, action_id)
}
pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
self.store.delete(&self.full_key(key), action_id)
}
pub fn keys(&self) -> Vec<String> {
self.store
.keys()
.into_iter()
.filter_map(|k| {
if self.tenant.map(|t| !t.is_empty()).unwrap_or(false) {
self.strip_prefix(&k).map(str::to_string)
} else if k.starts_with("tenant:") {
None
} else {
Some(k)
}
})
.collect()
}
}
impl Default for StateStore {
fn default() -> Self {
Self::new()
}
}
impl car_ir::precondition::StateView for StateStore {
fn get_value(&self, key: &str) -> Option<Value> {
self.get(key)
}
fn key_exists(&self, key: &str) -> bool {
self.exists(key)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn set_and_get() {
let store = StateStore::new();
store.set("x", Value::from(42), "test");
assert_eq!(store.get("x"), Some(Value::from(42)));
}
#[test]
fn exists() {
let store = StateStore::new();
assert!(!store.exists("x"));
store.set("x", Value::from(1), "test");
assert!(store.exists("x"));
}
#[test]
fn delete() {
let store = StateStore::new();
store.set("x", Value::from(1), "test");
let t = store.delete("x", "test");
assert!(t.is_some());
assert!(!store.exists("x"));
}
#[test]
fn delete_nonexistent() {
let store = StateStore::new();
assert!(store.delete("x", "test").is_none());
}
#[test]
fn snapshot_and_restore() {
let store = StateStore::new();
store.set("x", Value::from(1), "a");
let snap = store.snapshot();
let tc = store.transition_count();
store.set("y", Value::from(2), "b");
assert!(store.exists("y"));
store.restore(snap, tc).unwrap();
assert!(store.exists("x"));
assert!(!store.exists("y"));
assert_eq!(store.transition_count(), 1);
}
#[test]
fn transitions_logged() {
let store = StateStore::new();
store.set("a", Value::from(1), "act1");
store.set("b", Value::from(2), "act2");
let transitions = store.transitions();
assert_eq!(transitions.len(), 2);
assert_eq!(transitions[0].key, "a");
assert_eq!(transitions[1].key, "b");
}
#[test]
fn transitions_since() {
let store = StateStore::new();
store.set("a", Value::from(1), "act1");
let idx = store.transition_count();
store.set("b", Value::from(2), "act2");
let since = store.transitions_since(idx);
assert_eq!(since.len(), 1);
assert_eq!(since[0].key, "b");
}
#[test]
fn transition_records_old_value() {
let store = StateStore::new();
store.set("x", Value::from(1), "first");
store.set("x", Value::from(2), "second");
let transitions = store.transitions();
assert_eq!(transitions[1].old_value, Some(Value::from(1)));
assert_eq!(transitions[1].new_value, Some(Value::from(2)));
}
#[test]
fn keys() {
let store = StateStore::new();
store.set("a", Value::from(1), "t");
store.set("b", Value::from(2), "t");
let mut keys = store.keys();
keys.sort();
assert_eq!(keys, vec!["a", "b"]);
}
#[test]
fn transitions_since_after_restore_does_not_panic() {
let store = StateStore::new();
store.set("a", serde_json::json!(1), "test");
store.set("b", serde_json::json!(2), "test");
let count_before = store.transition_count();
store.restore(HashMap::new(), 0).unwrap();
let result = store.transitions_since(count_before);
assert!(result.is_empty());
}
#[test]
fn transitions_since_normal_usage() {
let store = StateStore::new();
store.set("a", serde_json::json!(1), "test");
let mark = store.transition_count();
store.set("b", serde_json::json!(2), "test");
let since = store.transitions_since(mark);
assert_eq!(since.len(), 1);
assert_eq!(since[0].key, "b");
}
#[test]
fn replace_all_swaps_state_without_transitions() {
let store = StateStore::new();
store.set("old_key", serde_json::json!("old"), "setup");
let mut new_state = HashMap::new();
new_state.insert("new_key".to_string(), serde_json::json!("new"));
store.replace_all(new_state);
assert_eq!(store.get("new_key"), Some(serde_json::json!("new")));
assert_eq!(store.get("old_key"), None);
assert_eq!(store.transition_count(), 0);
}
#[test]
fn durable_store_survives_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
{
let store = StateStore::durable(&path).unwrap();
store.set("agent", serde_json::json!("planner"), "boot");
store.set("turns", serde_json::json!(42), "tick");
store.sync().unwrap();
}
let store = StateStore::durable(&path).unwrap();
assert_eq!(store.get("agent"), Some(serde_json::json!("planner")));
assert_eq!(store.get("turns"), Some(serde_json::json!(42)));
}
#[test]
fn durable_store_replays_deletes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
{
let store = StateStore::durable(&path).unwrap();
store.set("transient", serde_json::json!("x"), "boot");
store.delete("transient", "rm");
store.sync().unwrap();
}
let store = StateStore::durable(&path).unwrap();
assert!(!store.exists("transient"));
}
#[test]
fn durable_restore_survives_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
{
let store = StateStore::durable(&path).unwrap();
store.set("existing", serde_json::json!("old"), "setup");
let snapshot = store.snapshot();
let transition_count = store.transition_count();
store.set("existing", serde_json::json!("new"), "candidate");
store.set("created", serde_json::json!(true), "candidate");
store.restore(snapshot, transition_count).unwrap();
store.sync().unwrap();
assert_eq!(store.get("existing"), Some(serde_json::json!("old")));
assert!(!store.exists("created"));
}
let reopened = StateStore::durable(&path).unwrap();
assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
assert!(
!reopened.exists("created"),
"a full rollback must not resurrect candidate state after reopen"
);
}
#[test]
fn abandoned_replacement_temps_do_not_block_restore_compaction_or_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
{
let store = StateStore::durable(&path).unwrap();
store.set("existing", serde_json::json!("old"), "setup");
store.sync().unwrap();
}
let abandoned_transition = StateTransition {
key: "intruder".to_string(),
old_value: None,
new_value: Some(serde_json::json!("must-not-replay")),
action_id: "abandoned-temp".to_string(),
timestamp: Utc::now(),
ttl_secs: None,
version: Some(99),
};
let abandoned_contents = format!(
"{}\n",
serde_json::to_string(&abandoned_transition).unwrap()
);
let abandoned_temps = [
path.with_extension("jsonl.restore.tmp"),
dir.path().join(".state.jsonl.restore.123.456.0.tmp"),
dir.path().join(".state.jsonl.restore.789.012.1.tmp"),
];
for temp in &abandoned_temps {
std::fs::write(temp, &abandoned_contents).unwrap();
}
let store = StateStore::durable(&path).unwrap();
assert!(!store.exists("intruder"));
let snapshot = store.snapshot();
let transition_count = store.transition_count();
store.set("existing", serde_json::json!("new"), "candidate");
store.set("candidate_only", serde_json::json!(true), "candidate");
assert_eq!(
store.restore(snapshot, transition_count).unwrap(),
RestoreDurability::Durable
);
store.set_with_ttl("expired", serde_json::json!(true), "ttl", 0);
store.sync().unwrap();
assert_eq!(
store
.reap_expired(Utc::now() + Duration::seconds(1))
.unwrap(),
vec!["expired".to_string()]
);
store.sync().unwrap();
drop(store);
let reopened = StateStore::durable(&path).unwrap();
assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
assert!(!reopened.exists("candidate_only"));
assert!(!reopened.exists("expired"));
assert!(!reopened.exists("intruder"));
for temp in &abandoned_temps {
assert!(temp.exists(), "abandoned sibling temp should be ignored");
}
}
#[test]
fn durable_restore_parent_sync_failure_keeps_live_and_reopened_state_coherent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
let store = StateStore::durable(&path).unwrap();
store.set("existing", serde_json::json!("old"), "setup");
let snapshot = store.snapshot();
let transition_count = store.transition_count();
store.set("existing", serde_json::json!("new"), "candidate");
#[cfg(unix)]
let original_mode = {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(&path).unwrap().permissions().mode()
};
let durability = store
.restore_with_parent_sync(snapshot, transition_count, |_| {
Err(std::io::Error::other(
"injected parent directory sync failure",
))
})
.unwrap();
assert!(matches!(
durability,
RestoreDurability::DurabilityUnknown { ref error }
if error.contains("parent directory sync failure")
));
assert_eq!(
store.get("existing"),
Some(serde_json::json!("old")),
"once rename publishes the rollback journal, memory must adopt the same state"
);
assert!(
!path.with_extension("jsonl.restore.tmp").exists(),
"replacement temp must be cleaned after atomic publication"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode(),
original_mode,
"replacement must preserve journal permissions"
);
}
store.set("after_unknown", serde_json::json!("safe"), "later");
store.sync().unwrap();
drop(store);
let reopened = StateStore::durable(&path).unwrap();
assert_eq!(
reopened.get("existing"),
Some(serde_json::json!("old")),
"the visible journal and live rollback state must agree"
);
assert_eq!(
reopened.get("after_unknown"),
Some(serde_json::json!("safe")),
"a later write must first reconcile the pending parent sync"
);
}
#[test]
fn durability_unknown_store_refuses_write_until_parent_sync_reconciles() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
let failures = RestoreFailureInjector::default();
let store =
StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
.unwrap();
store.set("existing", serde_json::json!("old"), "setup");
let snapshot = store.snapshot();
let transition_count = store.transition_count();
store.set("existing", serde_json::json!("new"), "candidate");
failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
assert!(matches!(
store.restore(snapshot, transition_count).unwrap(),
RestoreDurability::DurabilityUnknown { .. }
));
let refused = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
store.set("must_not_write", serde_json::json!(true), "later");
}));
assert!(refused.is_err());
assert!(!store.exists("must_not_write"));
drop(store);
let reopened = StateStore::durable(&path).unwrap();
assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
assert!(!reopened.exists("must_not_write"));
}
#[test]
fn mutation_waiting_for_state_reconciles_restore_durability_unknown() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
let failures = RestoreFailureInjector::default();
let mut store =
StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
.unwrap();
store.set("existing", serde_json::json!("old"), "setup");
let snapshot = store.snapshot();
let transition_count = store.transition_count();
store.set("existing", serde_json::json!("candidate"), "candidate");
let barrier = Arc::new(MutationRaceBarrier::new());
store.mutation_before_state_lock = Some(barrier.clone());
let store = Arc::new(store);
failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
let mutation_store = store.clone();
let mutation = std::thread::spawn(move || {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mutation_store.set("must_not_write", serde_json::json!(true), "racing");
}))
});
barrier.reached.wait();
assert!(matches!(
store.restore(snapshot, transition_count).unwrap(),
RestoreDurability::DurabilityUnknown { .. }
));
barrier.release.wait();
assert!(
mutation.join().unwrap().is_err(),
"a mutation admitted before restore must reconcile the journal after acquiring state"
);
assert!(!store.exists("must_not_write"));
drop(store);
let reopened = StateStore::durable(&path).unwrap();
assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
assert!(!reopened.exists("must_not_write"));
}
#[test]
fn durable_scoped_restore_survives_reopen_without_clobbering_other_tenants() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
{
let store = StateStore::durable(&path).unwrap();
store
.scoped(Some("acme"))
.set("existing", serde_json::json!("old"), "setup");
store
.scoped(Some("globex"))
.set("survivor", serde_json::json!("before"), "setup");
let snapshot = store.snapshot_scoped(Some("acme"));
let transition_count = store.transition_count();
store
.scoped(Some("acme"))
.set("existing", serde_json::json!("new"), "candidate");
store
.scoped(Some("acme"))
.set("created", serde_json::json!(true), "candidate");
store.scoped(Some("globex")).set(
"survivor",
serde_json::json!("after"),
"other-proposal",
);
store
.restore_scoped(Some("acme"), snapshot, transition_count)
.unwrap();
store.sync().unwrap();
assert_eq!(
store.scoped(Some("acme")).get("existing"),
Some(serde_json::json!("old"))
);
assert!(!store.scoped(Some("acme")).exists("created"));
assert_eq!(
store.scoped(Some("globex")).get("survivor"),
Some(serde_json::json!("after"))
);
}
let reopened = StateStore::durable(&path).unwrap();
assert_eq!(
reopened.scoped(Some("acme")).get("existing"),
Some(serde_json::json!("old"))
);
assert!(!reopened.scoped(Some("acme")).exists("created"));
assert_eq!(
reopened.scoped(Some("globex")).get("survivor"),
Some(serde_json::json!("after")),
"scoped rollback must preserve unrelated tenant state after reopen"
);
}
#[test]
fn ttl_reap_drops_expired_and_keeps_fresh() {
let store = StateStore::new();
store.set_with_ttl("short", serde_json::json!(1), "set", 0);
store.set_with_ttl("long", serde_json::json!(2), "set", 3600);
store.set("forever", serde_json::json!(3), "set");
let reaped = store
.reap_expired(Utc::now() + Duration::seconds(10))
.unwrap();
assert_eq!(reaped, vec!["short".to_string()]);
assert!(!store.exists("short"));
assert_eq!(store.get("long"), Some(serde_json::json!(2)));
assert_eq!(store.get("forever"), Some(serde_json::json!(3)));
}
#[test]
fn scoped_reap_isolates_tenants() {
let store = StateStore::new();
store
.scoped(Some("a"))
.set_with_ttl("k", serde_json::json!(1), "set", 0);
store
.scoped(Some("b"))
.set_with_ttl("k", serde_json::json!(2), "set", 0);
store.set_with_ttl("global", serde_json::json!(3), "set", 0);
let future = Utc::now() + Duration::seconds(10);
let reaped = store.reap_expired_scoped(future, Some("a")).unwrap();
assert_eq!(reaped, vec!["tenant:a:k".to_string()]);
assert!(!store.scoped(Some("a")).exists("k"));
assert!(store.scoped(Some("b")).exists("k"));
assert!(store.exists("global"));
let reaped = store.reap_expired_scoped(future, None).unwrap();
assert_eq!(reaped, vec!["global".to_string()]);
assert!(store.scoped(Some("b")).exists("k"));
}
#[test]
fn durable_ttl_compacts_journal() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
{
let store = StateStore::durable(&path).unwrap();
for i in 0..50 {
store.set_with_ttl(&format!("k{i}"), serde_json::json!(i), "set", 0);
}
store.set("survivor", serde_json::json!("kept"), "set");
store.sync().unwrap();
let pre = std::fs::metadata(&path).unwrap().len();
let reaped = store
.reap_expired(Utc::now() + Duration::seconds(1))
.unwrap();
assert_eq!(reaped.len(), 50);
store.sync().unwrap();
let post = std::fs::metadata(&path).unwrap().len();
assert!(
post < pre,
"post={post} pre={pre} — compaction did not shrink"
);
}
let store = StateStore::durable(&path).unwrap();
assert!(!store.exists("k0"));
assert!(!store.exists("k49"));
assert_eq!(store.get("survivor"), Some(serde_json::json!("kept")));
assert_eq!(store.version("survivor"), Some(1));
}
#[test]
fn version_is_monotonic_and_survives_compaction() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v.jsonl");
{
let store = StateStore::durable(&path).unwrap();
for i in 0..3 {
store.set("cfg", serde_json::json!(i), "set");
}
assert_eq!(store.version("cfg"), Some(3));
store.set_with_ttl("tmp", serde_json::json!(1), "set", 0);
store.sync().unwrap();
store
.reap_expired(Utc::now() + Duration::seconds(1))
.unwrap();
store.sync().unwrap();
}
let store = StateStore::durable(&path).unwrap();
assert_eq!(store.version("cfg"), Some(3));
}
#[test]
fn reap_bumps_version() {
let store = StateStore::new();
store.set("k", serde_json::json!("v"), "set");
assert_eq!(store.version("k"), Some(1));
store.set_with_ttl("k", serde_json::json!("v2"), "set", 0);
assert_eq!(store.version("k"), Some(2));
store
.reap_expired(Utc::now() + Duration::seconds(1))
.unwrap();
assert_eq!(store.version("k"), Some(3));
}
#[test]
fn ttl_then_rewrite_without_ttl_does_not_reap() {
let store = StateStore::new();
store.set_with_ttl("k", serde_json::json!("a"), "first", 0);
store.set("k", serde_json::json!("b"), "second"); let reaped = store
.reap_expired(Utc::now() + Duration::seconds(10))
.unwrap();
assert!(reaped.is_empty());
assert_eq!(store.get("k"), Some(serde_json::json!("b")));
}
#[test]
fn invalid_utf8_journal_line_is_skipped_never_applied_mangled() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
let mut journal = Vec::new();
journal.extend_from_slice(
b"{\"key\":\"good\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
);
journal.extend_from_slice(
b"{\"key\":\"corrupt\",\"old_value\":null,\"new_value\":\"va\xFFlue\",\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
);
journal.extend_from_slice(
b"{\"batch\":[{\"key\":\"corrupt_batch\",\"old_value\":null,\"new_value\":\"a\xFFb\",\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}]}\n",
);
std::fs::write(&path, &journal).unwrap();
let store = StateStore::durable(&path).unwrap();
assert_eq!(store.get("good"), Some(json!(1)));
assert!(
!store.exists("corrupt"),
"an invalid-UTF-8 record must be skipped, not applied with U+FFFD"
);
assert!(!store.exists("corrupt_batch"));
}
#[test]
fn malformed_journal_line_is_skipped_not_fatal() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
{
std::fs::write(
&path,
"{\"key\":\"a\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n\
not-json\n\
{\"key\":\"b\",\"old_value\":null,\"new_value\":2,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
)
.unwrap();
}
let store = StateStore::durable(&path).unwrap();
assert_eq!(store.get("a"), Some(serde_json::json!(1)));
assert_eq!(store.get("b"), Some(serde_json::json!(2)));
}
#[test]
fn scoped_view_writes_isolate_between_tenants() {
let store = StateStore::new();
store.scoped(Some("acme")).set("config", json!("A"), "act");
store
.scoped(Some("globex"))
.set("config", json!("G"), "act");
assert_eq!(store.scoped(Some("acme")).get("config"), Some(json!("A")));
assert_eq!(store.scoped(Some("globex")).get("config"), Some(json!("G")));
}
#[test]
fn scoped_view_isolates_existence_check() {
let store = StateStore::new();
store.scoped(Some("acme")).set("k", json!(1), "act");
assert!(store.scoped(Some("acme")).exists("k"));
assert!(!store.scoped(Some("globex")).exists("k"));
}
#[test]
fn scoped_view_keys_filters_to_tenant() {
let store = StateStore::new();
store.scoped(Some("acme")).set("a", json!(1), "act");
store.scoped(Some("acme")).set("b", json!(2), "act");
store.scoped(Some("globex")).set("g", json!(9), "act");
store.set("unscoped", json!(0), "act");
let mut acme_keys = store.scoped(Some("acme")).keys();
acme_keys.sort();
assert_eq!(acme_keys, vec!["a", "b"]);
let globex_keys = store.scoped(Some("globex")).keys();
assert_eq!(globex_keys, vec!["g"]);
}
#[test]
fn unscoped_view_skips_tenant_prefixed_keys() {
let store = StateStore::new();
store.set("legacy", json!("ok"), "act");
store.scoped(Some("acme")).set("hidden", json!(42), "act");
let unscoped = store.scoped(None).keys();
assert_eq!(unscoped, vec!["legacy"]);
assert!(store.scoped(None).get("hidden").is_none());
}
#[test]
fn scoped_restore_does_not_clobber_other_tenants() {
let store = StateStore::new();
store.scoped(Some("acme")).set("k", json!("acme-v1"), "a");
store
.scoped(Some("globex"))
.set("k", json!("globex-v1"), "a");
store.set("global", json!("g-v1"), "a");
let acme_snap = store.scoped(Some("acme")).snapshot();
store.scoped(Some("acme")).set("k", json!("acme-v2"), "a");
store
.scoped(Some("globex"))
.set("k", json!("globex-v2"), "a");
store.set("global", json!("g-v2"), "a");
store.scoped(Some("acme")).restore(acme_snap, 0).unwrap();
assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme-v1")));
assert_eq!(
store.scoped(Some("globex")).get("k"),
Some(json!("globex-v2"))
);
assert_eq!(store.get("global"), Some(json!("g-v2")));
}
#[test]
fn snapshot_scoped_captures_only_its_namespace() {
let store = StateStore::new();
store.set("global", json!(1), "a");
store.scoped(Some("acme")).set("x", json!(2), "a");
store.scoped(Some("globex")).set("y", json!(3), "a");
let acme = store.snapshot_scoped(Some("acme"));
assert_eq!(acme.len(), 1);
assert!(acme.contains_key("tenant:acme:x"));
let global = store.snapshot_scoped(None);
assert_eq!(global.len(), 1);
assert!(global.contains_key("global"));
}
#[test]
fn unscoped_restore_leaves_tenant_keys_intact() {
let store = StateStore::new();
store.set("g", json!("v1"), "a");
store.scoped(Some("acme")).set("k", json!("acme"), "a");
let snap = store.snapshot_scoped(None);
store.set("g", json!("v2"), "a");
store.restore_scoped(None, snap, 0).unwrap();
assert_eq!(store.get("g"), Some(json!("v1")));
assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme")));
}
#[test]
fn scoped_restore_preserves_other_tenants_transitions() {
let store = StateStore::new();
store.scoped(Some("acme")).set("k", json!("a1"), "act");
let snap = store.snapshot_scoped(Some("acme"));
let count = store.transition_count();
store.scoped(Some("acme")).set("k", json!("a2"), "act");
store.scoped(Some("globex")).set("g", json!("gv"), "act");
store.restore_scoped(Some("acme"), snap, count).unwrap();
let tail = store.transitions_since(count);
assert_eq!(
tail.len(),
1,
"exactly globex's transition survives: {tail:?}"
);
assert_eq!(tail[0].key, "tenant:globex:g");
assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("a1")));
assert_eq!(store.scoped(Some("globex")).get("g"), Some(json!("gv")));
}
#[test]
fn scoped_view_delete_doesnt_touch_other_tenants() {
let store = StateStore::new();
store.scoped(Some("acme")).set("shared", json!(1), "act");
store.scoped(Some("globex")).set("shared", json!(2), "act");
store.scoped(Some("acme")).delete("shared", "act");
assert!(!store.scoped(Some("acme")).exists("shared"));
assert!(store.scoped(Some("globex")).exists("shared"));
}
#[test]
fn empty_tenant_string_treated_as_unscoped() {
let store = StateStore::new();
store.scoped(Some("")).set("k", json!(1), "act");
assert_eq!(store.get("k"), Some(json!(1)));
assert_eq!(store.scoped(None).get("k"), Some(json!(1)));
}
#[test]
fn set_batch_applies_all_entries_with_versions_and_transitions() {
let store = StateStore::new();
store.set("a", json!("old"), "setup");
let batch = store.set_batch(
vec![("a".to_string(), json!("new")), ("b".to_string(), json!(2))],
"callback-action",
);
assert_eq!(store.get("a"), Some(json!("new")));
assert_eq!(store.get("b"), Some(json!(2)));
assert_eq!(store.version("a"), Some(2));
assert_eq!(store.version("b"), Some(1));
assert_eq!(batch.len(), 2);
assert_eq!(batch[0].old_value, Some(json!("old")));
assert_eq!(batch[1].old_value, None);
assert!(batch.iter().all(|t| t.action_id == "callback-action"));
assert_eq!(store.transition_count(), 3);
}
#[test]
fn empty_batch_is_a_noop() {
let store = StateStore::new();
assert!(store.set_batch(Vec::new(), "noop").is_empty());
assert_eq!(store.transition_count(), 0);
}
#[test]
fn durable_two_and_three_key_batches_survive_reopen_with_versions() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
{
let store = StateStore::durable(&path).unwrap();
store.set_batch(
vec![("x".to_string(), json!(1)), ("y".to_string(), json!(2))],
"two-key",
);
store.set_batch(
vec![
("x".to_string(), json!(10)),
("y".to_string(), json!(20)),
("z".to_string(), json!(30)),
],
"three-key",
);
store.sync().unwrap();
}
let reopened = StateStore::durable(&path).unwrap();
assert_eq!(reopened.get("x"), Some(json!(10)));
assert_eq!(reopened.get("y"), Some(json!(20)));
assert_eq!(reopened.get("z"), Some(json!(30)));
assert_eq!(reopened.version("x"), Some(2));
assert_eq!(reopened.version("y"), Some(2));
assert_eq!(reopened.version("z"), Some(1));
}
#[test]
fn multi_key_batch_is_one_journal_line_and_single_key_keeps_legacy_shape() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
let store = StateStore::durable(&path).unwrap();
store.set_batch(
vec![
("k1".to_string(), json!(1)),
("k2".to_string(), json!(2)),
("k3".to_string(), json!(3)),
],
"multi",
);
store.set_batch(vec![("solo".to_string(), json!(4))], "single");
store.sync().unwrap();
let journal = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = journal.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(
lines.len(),
2,
"a 3-key batch must be ONE line and a 1-key batch one line: {journal}"
);
let multi: Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(
multi["batch"].as_array().map(Vec::len),
Some(3),
"the multi-key line is a batch record"
);
let single: StateTransition = serde_json::from_str(lines[1]).unwrap();
assert_eq!(single.key, "solo");
}
fn torn_batch_replays_complete_old_state(n_keys: usize) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
let torn_keys: Vec<String> = (1..=n_keys).map(|i| format!("t{i}")).collect();
{
let store = StateStore::durable(&path).unwrap();
store.set("base", json!("kept"), "setup");
store.set_batch(
vec![("p".to_string(), json!(1)), ("q".to_string(), json!(2))],
"complete-batch",
);
store.set_batch(
torn_keys
.iter()
.map(|key| (key.clone(), json!(format!("{key}-value"))))
.collect(),
"torn-batch",
);
store.sync().unwrap();
}
let full = std::fs::read_to_string(&path).unwrap();
let last_line_start = full.trim_end().rfind('\n').unwrap() + 1;
let torn_cut = last_line_start + (full.trim_end().len() - last_line_start) / 2;
std::fs::write(&path, &full.as_bytes()[..torn_cut]).unwrap();
let store = StateStore::durable(&path).unwrap();
assert_eq!(store.get("base"), Some(json!("kept")));
assert_eq!(store.get("p"), Some(json!(1)));
assert_eq!(store.get("q"), Some(json!(2)));
for torn_key in &torn_keys {
assert!(
!store.exists(torn_key),
"an interrupted {n_keys}-key batch append must replay as \
complete-old — no key of the torn batch may surface ({torn_key} did)"
);
}
}
#[test]
fn torn_two_key_batch_journal_line_replays_complete_old_state_never_a_prefix() {
torn_batch_replays_complete_old_state(2);
}
#[test]
fn torn_three_key_batch_journal_line_replays_complete_old_state_never_a_prefix() {
torn_batch_replays_complete_old_state(3);
}
#[test]
fn valid_unterminated_tail_is_terminated_so_the_next_append_survives_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
{
let store = StateStore::durable(&path).unwrap();
store.set("base", json!("kept"), "setup");
store.set_batch(
vec![("b1".to_string(), json!(1)), ("b2".to_string(), json!(2))],
"tail-batch",
);
store.sync().unwrap();
}
let full = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, full.trim_end().as_bytes()).unwrap();
{
let store = StateStore::durable(&path).unwrap();
assert_eq!(store.get("b1"), Some(json!(1)), "the complete tail replays");
store.set("after", json!("survives"), "later");
store.sync().unwrap();
}
let reopened = StateStore::durable(&path).unwrap();
assert_eq!(reopened.get("base"), Some(json!("kept")));
assert_eq!(reopened.get("b1"), Some(json!(1)));
assert_eq!(reopened.get("b2"), Some(json!(2)));
assert_eq!(reopened.get("after"), Some(json!("survives")));
}
#[test]
fn torn_unterminated_tail_is_truncated_so_the_next_append_is_not_merged() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
{
let store = StateStore::durable(&path).unwrap();
store.set("base", json!("kept"), "setup");
store.set_batch(
vec![
("t1".to_string(), json!(1)),
("t2".to_string(), json!(2)),
("t3".to_string(), json!(3)),
],
"torn-batch",
);
store.sync().unwrap();
}
let full = std::fs::read_to_string(&path).unwrap();
let last_line_start = full.trim_end().rfind('\n').unwrap() + 1;
let torn_cut = last_line_start + (full.trim_end().len() - last_line_start) / 2;
std::fs::write(&path, &full.as_bytes()[..torn_cut]).unwrap();
{
let store = StateStore::durable(&path).unwrap();
assert!(
!store.exists("t1"),
"the torn batch replays as complete-old"
);
store.set("after", json!("survives"), "later");
store.sync().unwrap();
}
let reopened = StateStore::durable(&path).unwrap();
assert_eq!(reopened.get("base"), Some(json!("kept")));
assert_eq!(
reopened.get("after"),
Some(json!("survives")),
"the record appended after a torn tail must not merge into it and be lost"
);
for torn_key in ["t1", "t2", "t3"] {
assert!(!reopened.exists(torn_key));
}
}
fn reader_cannot_enter_mid_batch(n_keys: usize) {
let keys: Vec<String> = (1..=n_keys).map(|i| format!("k{i}")).collect();
let mut store = StateStore::new();
for key in &keys {
store.set(key, json!("old"), "setup");
}
let barrier = Arc::new(MutationRaceBarrier::new());
store.batch_mid_apply = Some(barrier.clone());
let store = Arc::new(store);
let batch_store = store.clone();
let batch_keys = keys.clone();
let batch = std::thread::spawn(move || {
batch_store.set_batch(
batch_keys
.into_iter()
.map(|key| (key, json!("new")))
.collect(),
"batch",
);
});
barrier.reached.wait();
assert!(
store.state.try_lock().is_none(),
"the state lock must be held for the whole batch — a reader \
admitted here would observe a partial {n_keys}-key set"
);
let reader_store = store.clone();
let reader_keys = keys.clone();
let reader = std::thread::spawn(move || {
reader_keys
.iter()
.rev()
.map(|key| reader_store.get(key))
.collect::<Vec<_>>()
});
barrier.release.wait();
batch.join().unwrap();
let observed = reader.join().unwrap();
assert!(
observed.iter().all(|value| value == &Some(json!("new"))),
"a reader admitted during a batch must see the complete new \
state, got {observed:?}"
);
}
#[test]
fn reader_cannot_enter_mid_two_key_batch_and_observes_complete_state() {
reader_cannot_enter_mid_batch(2);
}
#[test]
fn reader_cannot_enter_mid_three_key_batch_and_observes_complete_state() {
reader_cannot_enter_mid_batch(3);
}
#[test]
fn snapshot_stripped_is_single_lock_and_strips_tenant_prefixes() {
let store = StateStore::new();
store.set("global", json!(1), "act");
store.scoped(Some("acme")).set("x", json!(2), "act");
store.scoped(Some("globex")).set("y", json!(3), "act");
let acme = store.scoped(Some("acme")).snapshot_stripped();
assert_eq!(acme, [("x".to_string(), json!(2))].into());
let unscoped = store.scoped(None).snapshot_stripped();
assert_eq!(unscoped, [("global".to_string(), json!(1))].into());
}
#[test]
fn set_batch_refused_while_journal_durability_unknown() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
let failures = RestoreFailureInjector::default();
let store =
StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
.unwrap();
store.set("existing", json!("old"), "setup");
let snapshot = store.snapshot();
let transition_count = store.transition_count();
store.set("existing", json!("new"), "candidate");
failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
assert!(matches!(
store.restore(snapshot, transition_count).unwrap(),
RestoreDurability::DurabilityUnknown { .. }
));
let refused = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
store.set_batch(
vec![
("must_not_write".to_string(), json!(true)),
("nor_this".to_string(), json!(true)),
],
"later",
);
}));
assert!(
refused.is_err(),
"set_batch must refuse mutation exactly like set while the journal is durability-unknown"
);
assert!(!store.exists("must_not_write"));
assert!(!store.exists("nor_this"));
}
#[test]
fn scoped_set_batch_prefixes_all_keys_into_tenant_namespace() {
let store = StateStore::new();
store.scoped(Some("acme")).set_batch(
vec![("a".to_string(), json!(1)), ("b".to_string(), json!(2))],
"act",
);
assert_eq!(store.scoped(Some("acme")).get("a"), Some(json!(1)));
assert_eq!(store.scoped(Some("acme")).get("b"), Some(json!(2)));
assert!(!store.scoped(Some("globex")).exists("a"));
assert!(!store.scoped(None).exists("a"));
assert_eq!(store.get("tenant:acme:b"), Some(json!(2)));
}
#[test]
fn batch_then_compaction_and_reopen_preserve_state_and_versions() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.jsonl");
{
let store = StateStore::durable(&path).unwrap();
store.set_batch(
vec![("a".to_string(), json!(1)), ("b".to_string(), json!(2))],
"batch",
);
store.set_with_ttl("tmp", json!(true), "ttl", 0);
store.sync().unwrap();
store
.reap_expired(Utc::now() + Duration::seconds(1))
.unwrap();
store.sync().unwrap();
}
let reopened = StateStore::durable(&path).unwrap();
assert_eq!(reopened.get("a"), Some(json!(1)));
assert_eq!(reopened.get("b"), Some(json!(2)));
assert_eq!(reopened.version("a"), Some(1));
assert_eq!(reopened.version("b"), Some(1));
assert!(!reopened.exists("tmp"));
}
}