1pub mod crdt;
22
23use chrono::{DateTime, Duration, Utc};
24use parking_lot::{Mutex, MutexGuard};
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27use std::collections::HashMap;
28use std::ffi::OsString;
29use std::fs::{File, OpenOptions};
30use std::io::{BufRead, BufReader, BufWriter, Write};
31use std::path::{Path, PathBuf};
32use std::sync::atomic::{AtomicU64, Ordering};
33use std::sync::Arc;
34use std::time::{SystemTime, UNIX_EPOCH};
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum RestoreDurability {
39 Durable,
41 DurabilityUnknown { error: String },
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum RestoreFailurePoint {
49 BeforePublication,
50 ParentDirectorySync,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct RestoreFailureInjector {
55 failures: Arc<std::sync::Mutex<Vec<RestoreFailurePoint>>>,
56}
57
58impl RestoreFailureInjector {
59 pub fn fail_next(&self, point: RestoreFailurePoint) {
60 self.failures
61 .lock()
62 .expect("restore failure injector lock poisoned")
63 .push(point);
64 }
65
66 fn check(&self, point: RestoreFailurePoint) -> std::io::Result<()> {
67 let mut failures = self
68 .failures
69 .lock()
70 .map_err(|_| std::io::Error::other("restore failure injector lock poisoned"))?;
71 if failures.first() == Some(&point) {
72 failures.remove(0);
73 let message = match point {
74 RestoreFailurePoint::BeforePublication => {
75 "injected restore failure before publication"
76 }
77 RestoreFailurePoint::ParentDirectorySync => {
78 "injected restore parent directory sync failure"
79 }
80 };
81 return Err(std::io::Error::other(message));
82 }
83 Ok(())
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct StateTransition {
95 pub key: String,
96 pub old_value: Option<Value>,
97 pub new_value: Option<Value>,
98 pub action_id: String,
99 pub timestamp: DateTime<Utc>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub ttl_secs: Option<u64>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub version: Option<u64>,
110}
111
112enum TailRepair {
118 None,
120 Terminate,
123 TruncateTo(u64),
126}
127
128#[derive(Debug, Deserialize)]
137struct BatchTransitionRecord {
138 batch: Vec<StateTransition>,
139}
140
141#[derive(Serialize)]
144struct BatchTransitionRecordRef<'a> {
145 batch: &'a [StateTransition],
146}
147
148pub struct StateStore {
155 proposal_execution: tokio::sync::Mutex<()>,
160 state: Mutex<HashMap<String, Value>>,
161 transitions: Mutex<Vec<StateTransition>>,
162 versions: Mutex<HashMap<String, u64>>,
168 journal: Mutex<Option<Journal>>,
173 restore_failures: Option<RestoreFailureInjector>,
174 #[cfg(test)]
175 mutation_before_state_lock: Option<Arc<MutationRaceBarrier>>,
176 #[cfg(test)]
180 batch_mid_apply: Option<Arc<MutationRaceBarrier>>,
181}
182
183#[cfg(test)]
184struct MutationRaceBarrier {
185 reached: std::sync::Barrier,
186 release: std::sync::Barrier,
187}
188
189#[cfg(test)]
190impl MutationRaceBarrier {
191 fn new() -> Self {
192 Self {
193 reached: std::sync::Barrier::new(2),
194 release: std::sync::Barrier::new(2),
195 }
196 }
197
198 fn pause_mutation(&self) {
199 self.reached.wait();
200 self.release.wait();
201 }
202}
203
204struct Journal {
205 path: PathBuf,
206 writer: Option<BufWriter<File>>,
207 pending_parent_sync: Option<String>,
208}
209
210impl Journal {
211 fn reopen_writer(&mut self) -> std::io::Result<()> {
212 let file = OpenOptions::new().append(true).open(&self.path)?;
213 self.writer = Some(BufWriter::new(file));
214 Ok(())
215 }
216
217 fn writer_mut(&mut self) -> std::io::Result<&mut BufWriter<File>> {
218 if self.writer.is_none() {
219 self.reopen_writer()?;
220 }
221 self.writer
222 .as_mut()
223 .ok_or_else(|| std::io::Error::other("state journal writer is unavailable"))
224 }
225}
226
227const REPLACEMENT_TEMP_ATTEMPTS: usize = 32;
228static NEXT_REPLACEMENT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
229
230struct TempFileCleanup {
231 path: PathBuf,
232 armed: bool,
233}
234
235impl TempFileCleanup {
236 fn new(path: PathBuf) -> Self {
237 Self { path, armed: true }
238 }
239
240 fn disarm(&mut self) {
241 self.armed = false;
242 }
243}
244
245impl Drop for TempFileCleanup {
246 fn drop(&mut self) {
247 if self.armed {
248 let _ = std::fs::remove_file(&self.path);
249 }
250 }
251}
252
253fn create_replacement_temp(destination: &Path) -> std::io::Result<(PathBuf, File)> {
254 let parent = destination.parent().unwrap_or_else(|| Path::new("."));
255 let file_name = destination.file_name().ok_or_else(|| {
256 std::io::Error::new(
257 std::io::ErrorKind::InvalidInput,
258 "state journal path has no file name",
259 )
260 })?;
261 let timestamp = SystemTime::now()
262 .duration_since(UNIX_EPOCH)
263 .unwrap_or_default()
264 .as_nanos();
265
266 for _ in 0..REPLACEMENT_TEMP_ATTEMPTS {
267 let id = NEXT_REPLACEMENT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
268 let mut temp_name = OsString::from(".");
269 temp_name.push(file_name);
270 temp_name.push(format!(
271 ".restore.{}.{}.{}.tmp",
272 std::process::id(),
273 timestamp,
274 id
275 ));
276 let temp_path = parent.join(temp_name);
277 let mut options = OpenOptions::new();
278 options.write(true).create_new(true);
279 #[cfg(unix)]
280 {
281 use std::os::unix::fs::OpenOptionsExt;
282 options.mode(0o600);
283 }
284 match options.open(&temp_path) {
285 Ok(file) => return Ok((temp_path, file)),
286 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
287 Err(error) => return Err(error),
288 }
289 }
290
291 Err(std::io::Error::new(
292 std::io::ErrorKind::AlreadyExists,
293 format!(
294 "could not allocate a unique state journal replacement after {REPLACEMENT_TEMP_ATTEMPTS} attempts"
295 ),
296 ))
297}
298
299#[cfg(unix)]
300fn replace_file_atomically(temp: &Path, destination: &Path) -> std::io::Result<()> {
301 std::fs::rename(temp, destination)
302}
303
304#[cfg(target_os = "windows")]
305fn replace_file_atomically(temp: &Path, destination: &Path) -> std::io::Result<()> {
306 use std::os::windows::ffi::OsStrExt;
307 use windows::core::PCWSTR;
308 use windows::Win32::Storage::FileSystem::{
309 MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
310 };
311
312 let temp: Vec<u16> = temp.as_os_str().encode_wide().chain(Some(0)).collect();
313 let destination: Vec<u16> = destination
314 .as_os_str()
315 .encode_wide()
316 .chain(Some(0))
317 .collect();
318 unsafe {
319 MoveFileExW(
320 PCWSTR(temp.as_ptr()),
321 PCWSTR(destination.as_ptr()),
322 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
323 )
324 }
325 .map_err(|error| std::io::Error::other(error.to_string()))
326}
327
328#[cfg(not(any(unix, target_os = "windows")))]
329fn replace_file_atomically(temp: &Path, destination: &Path) -> std::io::Result<()> {
330 std::fs::rename(temp, destination)
331}
332
333#[cfg(unix)]
334fn sync_parent_directory(path: &Path) -> std::io::Result<()> {
335 File::open(path.parent().unwrap_or_else(|| Path::new(".")))?.sync_all()
336}
337
338#[cfg(target_os = "windows")]
339fn sync_parent_directory(_path: &Path) -> std::io::Result<()> {
340 Ok(())
345}
346
347#[cfg(not(any(unix, target_os = "windows")))]
348fn sync_parent_directory(_path: &Path) -> std::io::Result<()> {
349 Ok(())
350}
351
352impl StateStore {
353 pub fn new() -> Self {
354 Self {
355 proposal_execution: tokio::sync::Mutex::new(()),
356 state: Mutex::new(HashMap::new()),
357 transitions: Mutex::new(Vec::new()),
358 versions: Mutex::new(HashMap::new()),
359 journal: Mutex::new(None),
360 restore_failures: None,
361 #[cfg(test)]
362 mutation_before_state_lock: None,
363 #[cfg(test)]
364 batch_mid_apply: None,
365 }
366 }
367
368 pub async fn lock_proposal_execution(&self) -> tokio::sync::MutexGuard<'_, ()> {
372 self.proposal_execution.lock().await
373 }
374
375 pub fn durable(path: impl Into<PathBuf>) -> std::io::Result<Self> {
386 Self::durable_with_restore_failure_injector(path, None)
387 }
388
389 #[doc(hidden)]
390 pub fn durable_with_restore_failure_injector(
391 path: impl Into<PathBuf>,
392 restore_failures: impl Into<Option<RestoreFailureInjector>>,
393 ) -> std::io::Result<Self> {
394 let path = path.into();
395 if let Some(parent) = path.parent() {
396 if !parent.as_os_str().is_empty() {
397 std::fs::create_dir_all(parent)?;
398 }
399 }
400 let mut store = Self::new();
401 store.restore_failures = restore_failures.into();
402 match store.replay_journal(&path)? {
403 TailRepair::None => {}
404 TailRepair::Terminate => {
408 let mut file = OpenOptions::new().append(true).open(&path)?;
409 file.write_all(b"\n")?;
410 file.sync_all()?;
411 }
412 TailRepair::TruncateTo(offset) => {
417 let file = OpenOptions::new().write(true).open(&path)?;
418 file.set_len(offset)?;
419 file.sync_all()?;
420 }
421 }
422 let file = OpenOptions::new().create(true).append(true).open(&path)?;
423 *store.journal.lock() = Some(Journal {
424 path,
425 writer: Some(BufWriter::new(file)),
426 pending_parent_sync: None,
427 });
428 Ok(store)
429 }
430
431 fn replay_journal(&self, path: &Path) -> std::io::Result<TailRepair> {
432 if !path.exists() {
433 return Ok(TailRepair::None);
434 }
435 let file = File::open(path)?;
436 let mut reader = BufReader::new(file);
437 let now = Utc::now();
438 let mut state = self.state.lock();
439 let mut transitions = self.transitions.lock();
440 let mut versions = self.versions.lock();
441 let mut buf = Vec::new();
442 let mut offset: u64 = 0;
443 let mut repair = TailRepair::None;
444 loop {
445 buf.clear();
446 let read = match reader.read_until(b'\n', &mut buf) {
447 Ok(0) => break,
448 Ok(n) => n,
449 Err(_) => break,
450 };
451 let line_start = offset;
452 offset += read as u64;
453 let terminated = buf.ends_with(b"\n");
462 if buf.iter().all(|byte| byte.is_ascii_whitespace()) {
468 if !terminated {
469 repair = TailRepair::TruncateTo(line_start);
470 break;
471 }
472 continue;
473 }
474 if let Ok(t) = serde_json::from_slice::<StateTransition>(&buf) {
475 replay_transition(&mut state, &mut transitions, &mut versions, now, t);
476 if !terminated {
477 repair = TailRepair::Terminate;
478 break;
479 }
480 continue;
481 }
482 if let Ok(record) = serde_json::from_slice::<BatchTransitionRecord>(&buf) {
483 for t in record.batch {
488 replay_transition(&mut state, &mut transitions, &mut versions, now, t);
489 }
490 if !terminated {
491 repair = TailRepair::Terminate;
492 break;
493 }
494 continue;
495 }
496 if !terminated {
497 repair = TailRepair::TruncateTo(line_start);
498 break;
499 }
500 tracing::warn!(
502 journal = %path.display(),
503 "skipping malformed StateStore journal line"
504 );
505 }
506 Ok(repair)
507 }
508
509 fn append_journal(&self, transition: &StateTransition) {
510 let Ok(json) = serde_json::to_string(transition) else {
511 return;
512 };
513 self.append_journal_line(json);
514 }
515
516 fn append_journal_batch(&self, batch: &[StateTransition]) {
522 match batch {
523 [] => {}
524 [single] => self.append_journal(single),
525 _ => {
526 let Ok(json) = serde_json::to_string(&BatchTransitionRecordRef { batch }) else {
527 return;
528 };
529 self.append_journal_line(json);
530 }
531 }
532 }
533
534 fn append_journal_line(&self, json: String) {
535 let mut journal = self.journal.lock();
536 let Some(journal) = journal.as_mut() else {
537 return;
538 };
539 let path = journal.path.clone();
543 let writer = match journal.writer_mut() {
544 Ok(writer) => writer,
545 Err(e) => {
546 tracing::warn!(
547 journal = %path.display(),
548 error = %e,
549 "StateStore journal writer reopen failed"
550 );
551 return;
552 }
553 };
554 if let Err(e) = writeln!(writer, "{json}") {
555 tracing::warn!(
556 journal = %path.display(),
557 error = %e,
558 "StateStore journal append failed"
559 );
560 return;
561 }
562 let _ = writer.flush();
563 }
564
565 fn replace_journal_with_snapshot(
574 &self,
575 state: &HashMap<String, Value>,
576 transitions: &[StateTransition],
577 versions: &HashMap<String, u64>,
578 ) -> std::io::Result<RestoreDurability> {
579 let restore_failures = self.restore_failures.as_ref();
580 self.replace_journal_with_snapshot_and_sync(state, transitions, versions, |path| {
581 if let Some(failures) = restore_failures {
582 failures.check(RestoreFailurePoint::ParentDirectorySync)?;
583 }
584 sync_parent_directory(path)
585 })
586 }
587
588 fn replace_journal_with_snapshot_and_sync<F>(
589 &self,
590 state: &HashMap<String, Value>,
591 transitions: &[StateTransition],
592 versions: &HashMap<String, u64>,
593 sync_parent: F,
594 ) -> std::io::Result<RestoreDurability>
595 where
596 F: FnOnce(&Path) -> std::io::Result<()>,
597 {
598 let mut journal = self.journal.lock();
599 let Some(journal) = journal.as_mut() else {
600 return Ok(RestoreDurability::Durable);
601 };
602 journal.writer_mut()?.flush()?;
603
604 let journal_permissions = std::fs::metadata(&journal.path)?.permissions();
605 let (tmp_path, tmp_file) = create_replacement_temp(&journal.path)?;
606 let mut cleanup = TempFileCleanup::new(tmp_path.clone());
607 let mut replacement = BufWriter::new(tmp_file);
608 let latest_by_key: HashMap<&str, &StateTransition> = transitions
609 .iter()
610 .map(|transition| (transition.key.as_str(), transition))
611 .collect();
612 let mut keys: Vec<&String> = state.keys().collect();
613 keys.sort();
614 for key in keys {
615 let previous = latest_by_key.get(key.as_str()).copied();
616 let transition = StateTransition {
617 key: key.clone(),
618 old_value: None,
619 new_value: state.get(key).cloned(),
620 action_id: previous
621 .map(|transition| transition.action_id.clone())
622 .unwrap_or_else(|| "restore".to_string()),
623 timestamp: previous
624 .map(|transition| transition.timestamp)
625 .unwrap_or_else(Utc::now),
626 ttl_secs: previous.and_then(|transition| transition.ttl_secs),
627 version: versions.get(key).copied(),
628 };
629 let line = serde_json::to_string(&transition)?;
630 writeln!(replacement, "{line}")?;
631 }
632 replacement.flush()?;
633 replacement.get_ref().set_permissions(journal_permissions)?;
634 replacement.get_ref().sync_all()?;
635 if let Some(failures) = &self.restore_failures {
636 failures.check(RestoreFailurePoint::BeforePublication)?;
637 }
638 #[cfg(target_os = "windows")]
639 {
640 drop(journal.writer.take());
646 drop(replacement);
647 if let Err(publication_error) = replace_file_atomically(&tmp_path, &journal.path) {
648 return match journal.reopen_writer() {
649 Ok(()) => Err(publication_error),
650 Err(reopen_error) => Err(std::io::Error::other(format!(
651 "state journal replacement failed ({publication_error}); reopening the original journal also failed ({reopen_error})"
652 ))),
653 };
654 }
655 cleanup.disarm();
656 if let Err(error) = journal.reopen_writer() {
657 let error = format!(
658 "replacement journal was published but its append writer could not be reopened: {error}"
659 );
660 journal.pending_parent_sync = Some(error.clone());
661 return Ok(RestoreDurability::DurabilityUnknown { error });
662 }
663 }
664 #[cfg(not(target_os = "windows"))]
665 {
666 replace_file_atomically(&tmp_path, &journal.path)?;
667 cleanup.disarm();
668 journal.writer = Some(replacement);
669 }
670 match sync_parent(&journal.path) {
671 Ok(()) => {
672 journal.pending_parent_sync = None;
673 Ok(RestoreDurability::Durable)
674 }
675 Err(error) => {
676 let error = error.to_string();
677 journal.pending_parent_sync = Some(error.clone());
678 Ok(RestoreDurability::DurabilityUnknown { error })
679 }
680 }
681 }
682
683 fn reconcile_pending_parent_sync(&self) -> std::io::Result<()> {
684 let mut journal = self.journal.lock();
685 let Some(journal) = journal.as_mut() else {
686 return Ok(());
687 };
688 if journal.pending_parent_sync.is_none() {
689 return Ok(());
690 }
691 let writer = journal.writer_mut()?;
692 writer.flush()?;
693 writer.get_ref().sync_all()?;
694 if let Some(failures) = &self.restore_failures {
695 failures.check(RestoreFailurePoint::ParentDirectorySync)?;
696 }
697 sync_parent_directory(&journal.path)?;
698 journal.pending_parent_sync = None;
699 Ok(())
700 }
701
702 #[cfg(test)]
703 fn pause_before_mutation_state_lock(&self) {
704 if let Some(barrier) = &self.mutation_before_state_lock {
705 barrier.pause_mutation();
706 }
707 }
708
709 fn lock_reconciled_state_for_mutation(
710 &self,
711 ) -> std::io::Result<MutexGuard<'_, HashMap<String, Value>>> {
712 #[cfg(test)]
713 self.pause_before_mutation_state_lock();
714 let state = self.state.lock();
715 self.reconcile_pending_parent_sync()?;
716 Ok(state)
717 }
718
719 fn require_reconciled_state_for_mutation(&self) -> MutexGuard<'_, HashMap<String, Value>> {
720 self.lock_reconciled_state_for_mutation()
721 .unwrap_or_else(|error| {
722 panic!(
723 "StateStore journal is durability-unknown; refusing mutation until parent sync succeeds: {error}"
724 )
725 })
726 }
727
728 pub fn sync(&self) -> std::io::Result<()> {
731 let mut journal = self.journal.lock();
732 let Some(journal) = journal.as_mut() else {
733 return Ok(());
734 };
735 let writer = journal.writer_mut()?;
736 writer.flush()?;
737 writer.get_ref().sync_all()?;
738 if journal.pending_parent_sync.is_some() {
739 sync_parent_directory(&journal.path)?;
740 journal.pending_parent_sync = None;
741 }
742 Ok(())
743 }
744
745 pub fn reap_expired(&self, now: DateTime<Utc>) -> std::io::Result<Vec<String>> {
761 self.reap_expired_where(now, |_| true)
762 }
763
764 pub fn reap_expired_scoped(
772 &self,
773 now: DateTime<Utc>,
774 tenant: Option<&str>,
775 ) -> std::io::Result<Vec<String>> {
776 self.reap_expired_where(now, |k| key_in_tenant_namespace(k, tenant))
777 }
778
779 fn reap_expired_where(
782 &self,
783 now: DateTime<Utc>,
784 keep: impl Fn(&str) -> bool,
785 ) -> std::io::Result<Vec<String>> {
786 let mut state = self.lock_reconciled_state_for_mutation()?;
787 let mut transitions = self.transitions.lock();
788 let mut latest_by_key: HashMap<&str, &StateTransition> = HashMap::new();
792 for t in transitions.iter() {
793 latest_by_key.insert(t.key.as_str(), t);
794 }
795 let expired: Vec<String> = latest_by_key
796 .values()
797 .filter_map(|t| {
798 if !keep(&t.key) {
799 return None;
800 }
801 let ttl = t.ttl_secs?;
802 t.new_value.as_ref()?;
803 let age = now.signed_duration_since(t.timestamp);
804 (age > Duration::seconds(ttl as i64)).then(|| t.key.clone())
805 })
806 .collect();
807 let mut reaped = Vec::new();
808 for key in expired {
809 if state.remove(&key).is_some() {
810 let version = self.bump_version(&key);
814 reaped.push(key.clone());
815 transitions.push(StateTransition {
816 key,
817 old_value: None,
818 new_value: None,
819 action_id: "reap".to_string(),
820 timestamp: now,
821 ttl_secs: None,
822 version: Some(version),
823 });
824 }
825 }
826 drop(state);
827 drop(transitions);
828 if !reaped.is_empty() {
829 self.compact_journal()?;
830 }
831 Ok(reaped)
832 }
833
834 pub(crate) fn compact_journal(&self) -> std::io::Result<()> {
841 let state = self.state.lock();
842 let transitions = self.transitions.lock();
843 let versions = self.versions.lock();
844 match self.replace_journal_with_snapshot(&state, &transitions, &versions)? {
845 RestoreDurability::Durable => Ok(()),
846 RestoreDurability::DurabilityUnknown { error } => Err(std::io::Error::other(format!(
847 "state journal compaction is durability-unknown: {error}"
848 ))),
849 }
850 }
851
852 pub fn get(&self, key: &str) -> Option<Value> {
853 self.state.lock().get(key).cloned()
854 }
855
856 pub fn get_or(&self, key: &str, default: Value) -> Value {
857 self.state.lock().get(key).cloned().unwrap_or(default)
858 }
859
860 pub fn exists(&self, key: &str) -> bool {
861 self.state.lock().contains_key(key)
862 }
863
864 pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
865 self.set_inner(key, value, action_id, None)
866 }
867
868 pub fn set_with_ttl(
880 &self,
881 key: &str,
882 value: Value,
883 action_id: &str,
884 ttl_secs: u64,
885 ) -> StateTransition {
886 self.set_inner(key, value, action_id, Some(ttl_secs))
887 }
888
889 fn set_inner(
890 &self,
891 key: &str,
892 value: Value,
893 action_id: &str,
894 ttl_secs: Option<u64>,
895 ) -> StateTransition {
896 let mut state = self.require_reconciled_state_for_mutation();
897 let old = state.get(key).cloned();
898 state.insert(key.to_string(), value.clone());
899 let version = self.bump_version(key);
900
901 let t = StateTransition {
902 key: key.to_string(),
903 old_value: old,
904 new_value: Some(value),
905 action_id: action_id.to_string(),
906 timestamp: Utc::now(),
907 ttl_secs,
908 version: Some(version),
909 };
910
911 self.transitions.lock().push(t.clone());
912 self.append_journal(&t);
913 t
914 }
915
916 pub fn set_batch(
929 &self,
930 entries: Vec<(String, Value)>,
931 action_id: &str,
932 ) -> Vec<StateTransition> {
933 if entries.is_empty() {
934 return Vec::new();
935 }
936 let state = self.require_reconciled_state_for_mutation();
937 self.set_batch_locked(state, entries, action_id)
938 }
939
940 fn set_batch_locked(
941 &self,
942 mut state: MutexGuard<'_, HashMap<String, Value>>,
943 entries: Vec<(String, Value)>,
944 action_id: &str,
945 ) -> Vec<StateTransition> {
946 let timestamp = Utc::now();
947 let mut batch = Vec::with_capacity(entries.len());
948 for (key, value) in entries {
949 let old = state.get(&key).cloned();
950 state.insert(key.clone(), value.clone());
951 let version = self.bump_version(&key);
952 batch.push(StateTransition {
953 key,
954 old_value: old,
955 new_value: Some(value),
956 action_id: action_id.to_string(),
957 timestamp,
958 ttl_secs: None,
959 version: Some(version),
960 });
961 #[cfg(test)]
962 if batch.len() == 1 {
963 if let Some(barrier) = &self.batch_mid_apply {
964 barrier.pause_mutation();
965 }
966 }
967 }
968 self.transitions.lock().extend(batch.iter().cloned());
969 self.append_journal_batch(&batch);
970 batch
971 }
972
973 fn bump_version(&self, key: &str) -> u64 {
976 let mut versions = self.versions.lock();
977 let v = versions.entry(key.to_string()).or_insert(0);
978 *v += 1;
979 *v
980 }
981
982 pub fn version(&self, key: &str) -> Option<u64> {
986 self.versions.lock().get(key).copied()
987 }
988
989 pub fn versions(&self) -> HashMap<String, u64> {
992 self.versions.lock().clone()
993 }
994
995 pub fn versioned_snapshot(&self) -> (HashMap<String, Value>, HashMap<String, u64>) {
1002 let state = self.state.lock();
1003 let versions = self.versions.lock();
1004 (state.clone(), versions.clone())
1005 }
1006
1007 pub fn versioned_snapshot_visible_to(
1014 &self,
1015 tenant: Option<&str>,
1016 ) -> (HashMap<String, Value>, HashMap<String, u64>) {
1017 let state = self.state.lock();
1018 let versions = self.versions.lock();
1019 (
1020 state
1021 .iter()
1022 .filter(|(k, _)| key_visible_to_tenant(k, tenant))
1023 .map(|(k, v)| (k.clone(), v.clone()))
1024 .collect(),
1025 versions
1026 .iter()
1027 .filter(|(k, _)| key_visible_to_tenant(k, tenant))
1028 .map(|(k, v)| (k.clone(), *v))
1029 .collect(),
1030 )
1031 }
1032
1033 pub fn visible_to<'a>(&'a self, tenant: Option<&'a str>) -> TenantVisibleView<'a> {
1038 TenantVisibleView {
1039 store: self,
1040 tenant,
1041 }
1042 }
1043
1044 pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
1045 let mut state = self.require_reconciled_state_for_mutation();
1046 let old = state.remove(key)?;
1047 let version = self.bump_version(key);
1048
1049 let t = StateTransition {
1050 key: key.to_string(),
1051 old_value: Some(old),
1052 new_value: None,
1053 action_id: action_id.to_string(),
1054 timestamp: Utc::now(),
1055 ttl_secs: None,
1056 version: Some(version),
1057 };
1058
1059 self.transitions.lock().push(t.clone());
1060 self.append_journal(&t);
1061 Some(t)
1062 }
1063
1064 pub fn snapshot(&self) -> HashMap<String, Value> {
1066 self.state.lock().clone()
1067 }
1068
1069 pub fn restore(
1073 &self,
1074 snapshot: HashMap<String, Value>,
1075 transition_count: usize,
1076 ) -> std::io::Result<RestoreDurability> {
1077 let restore_failures = self.restore_failures.as_ref();
1078 self.restore_with_parent_sync(snapshot, transition_count, |path| {
1079 if let Some(failures) = restore_failures {
1080 failures.check(RestoreFailurePoint::ParentDirectorySync)?;
1081 }
1082 sync_parent_directory(path)
1083 })
1084 }
1085
1086 fn restore_with_parent_sync<F>(
1087 &self,
1088 snapshot: HashMap<String, Value>,
1089 transition_count: usize,
1090 sync_parent: F,
1091 ) -> std::io::Result<RestoreDurability>
1092 where
1093 F: FnOnce(&Path) -> std::io::Result<()>,
1094 {
1095 let mut state = self.state.lock();
1096 let mut transitions = self.transitions.lock();
1097 let versions = self.versions.lock();
1098 let mut restored_transitions = transitions.clone();
1099 restored_transitions.truncate(transition_count);
1100 let durability = self.replace_journal_with_snapshot_and_sync(
1101 &snapshot,
1102 &restored_transitions,
1103 &versions,
1104 sync_parent,
1105 )?;
1106 *state = snapshot;
1107 *transitions = restored_transitions;
1108 Ok(durability)
1109 }
1110
1111 pub fn snapshot_scoped(&self, tenant: Option<&str>) -> HashMap<String, Value> {
1119 let state = self.state.lock();
1120 state
1121 .iter()
1122 .filter(|(k, _)| key_in_tenant_namespace(k, tenant))
1123 .map(|(k, v)| (k.clone(), v.clone()))
1124 .collect()
1125 }
1126
1127 pub fn restore_scoped(
1142 &self,
1143 tenant: Option<&str>,
1144 snapshot: HashMap<String, Value>,
1145 transition_count: usize,
1146 ) -> std::io::Result<RestoreDurability> {
1147 let mut state = self.state.lock();
1148 let mut transitions = self.transitions.lock();
1149 let versions = self.versions.lock();
1150 let mut restored_state = state.clone();
1151 restored_state.retain(|k, _| !key_in_tenant_namespace(k, tenant));
1152 restored_state.extend(snapshot);
1153 let mut restored_transitions = transitions.clone();
1154 if transition_count < restored_transitions.len() {
1155 let tail: Vec<StateTransition> = restored_transitions
1158 .drain(transition_count..)
1159 .filter(|transition| !key_in_tenant_namespace(&transition.key, tenant))
1160 .collect();
1161 restored_transitions.extend(tail);
1162 }
1163 let durability =
1164 self.replace_journal_with_snapshot(&restored_state, &restored_transitions, &versions)?;
1165 *state = restored_state;
1166 *transitions = restored_transitions;
1167 Ok(durability)
1168 }
1169
1170 pub fn transition_count(&self) -> usize {
1171 self.transitions.lock().len()
1172 }
1173
1174 pub fn transitions(&self) -> Vec<StateTransition> {
1175 self.transitions.lock().clone()
1176 }
1177
1178 pub fn transitions_since(&self, index: usize) -> Vec<StateTransition> {
1179 let transitions = self.transitions.lock();
1180 let start = index.min(transitions.len());
1181 transitions[start..].to_vec()
1182 }
1183
1184 pub fn keys(&self) -> Vec<String> {
1185 self.state.lock().keys().cloned().collect()
1186 }
1187
1188 pub fn replace_all(&self, snapshot: HashMap<String, Value>) {
1193 let mut state = self.require_reconciled_state_for_mutation();
1194 *state = snapshot;
1195 self.transitions.lock().clear();
1196 }
1197
1198 pub fn scoped<'a>(&'a self, tenant: Option<&'a str>) -> ScopedStateView<'a> {
1211 ScopedStateView {
1212 store: self,
1213 tenant,
1214 }
1215 }
1216}
1217
1218fn replay_transition(
1225 state: &mut HashMap<String, Value>,
1226 transitions: &mut Vec<StateTransition>,
1227 versions: &mut HashMap<String, u64>,
1228 now: DateTime<Utc>,
1229 t: StateTransition,
1230) {
1231 if let (Some(ttl), Some(value)) = (t.ttl_secs, &t.new_value) {
1232 if now.signed_duration_since(t.timestamp) > Duration::seconds(ttl as i64) {
1233 state.remove(&t.key);
1234 } else {
1235 state.insert(t.key.clone(), value.clone());
1236 }
1237 } else if let Some(value) = &t.new_value {
1238 state.insert(t.key.clone(), value.clone());
1239 } else {
1240 state.remove(&t.key);
1241 }
1242 let entry = versions.entry(t.key.clone()).or_insert(0);
1243 let restored = t.version.unwrap_or(*entry + 1);
1244 *entry = (*entry).max(restored);
1245 transitions.push(t);
1246}
1247
1248pub fn tenant_key(tenant: Option<&str>, key: &str) -> String {
1254 match tenant {
1255 Some(t) if !t.is_empty() => format!("tenant:{t}:{key}"),
1256 _ => key.to_string(),
1257 }
1258}
1259
1260pub fn key_visible_to_tenant(key: &str, tenant: Option<&str>) -> bool {
1264 match tenant {
1265 Some(t) if !t.is_empty() => {
1266 !key.starts_with("tenant:") || key_in_tenant_namespace(key, tenant)
1267 }
1268 _ => true,
1269 }
1270}
1271
1272pub struct TenantVisibleView<'a> {
1274 store: &'a StateStore,
1275 tenant: Option<&'a str>,
1276}
1277
1278impl car_ir::precondition::StateView for TenantVisibleView<'_> {
1279 fn get_value(&self, key: &str) -> Option<Value> {
1280 key_visible_to_tenant(key, self.tenant)
1281 .then(|| self.store.get(key))
1282 .flatten()
1283 }
1284 fn key_exists(&self, key: &str) -> bool {
1285 key_visible_to_tenant(key, self.tenant) && self.store.exists(key)
1286 }
1287}
1288
1289fn key_in_tenant_namespace(key: &str, tenant: Option<&str>) -> bool {
1297 match tenant {
1298 Some(t) if !t.is_empty() => key.starts_with(&format!("tenant:{t}:")),
1299 _ => !key.starts_with("tenant:"),
1300 }
1301}
1302
1303pub struct ScopedStateView<'a> {
1334 store: &'a StateStore,
1335 tenant: Option<&'a str>,
1336}
1337
1338impl<'a> ScopedStateView<'a> {
1339 fn full_key(&self, key: &str) -> String {
1340 tenant_key(self.tenant, key)
1341 }
1342
1343 fn strip_prefix<'k>(&self, full: &'k str) -> Option<&'k str> {
1344 match self.tenant {
1345 Some(t) if !t.is_empty() => {
1346 let prefix = format!("tenant:{t}:");
1347 full.strip_prefix(&prefix)
1348 }
1349 _ => Some(full),
1350 }
1351 }
1352
1353 pub fn get(&self, key: &str) -> Option<Value> {
1354 self.store.get(&self.full_key(key))
1355 }
1356
1357 pub fn get_or(&self, key: &str, default: Value) -> Value {
1358 self.store.get_or(&self.full_key(key), default)
1359 }
1360
1361 pub fn snapshot(&self) -> HashMap<String, Value> {
1365 self.store.snapshot_scoped(self.tenant)
1366 }
1367
1368 pub fn snapshot_stripped(&self) -> HashMap<String, Value> {
1377 self.store
1378 .snapshot_scoped(self.tenant)
1379 .into_iter()
1380 .filter_map(|(key, value)| {
1381 self.strip_prefix(&key)
1382 .map(|stripped| (stripped.to_string(), value))
1383 })
1384 .collect()
1385 }
1386
1387 pub fn restore(
1390 &self,
1391 snapshot: HashMap<String, Value>,
1392 transition_count: usize,
1393 ) -> std::io::Result<RestoreDurability> {
1394 self.store
1395 .restore_scoped(self.tenant, snapshot, transition_count)
1396 }
1397
1398 pub fn exists(&self, key: &str) -> bool {
1399 self.store.exists(&self.full_key(key))
1400 }
1401
1402 pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
1403 self.store.set(&self.full_key(key), value, action_id)
1404 }
1405
1406 pub fn set_with_ttl(
1407 &self,
1408 key: &str,
1409 value: Value,
1410 action_id: &str,
1411 ttl_secs: u64,
1412 ) -> StateTransition {
1413 self.store
1414 .set_with_ttl(&self.full_key(key), value, action_id, ttl_secs)
1415 }
1416
1417 pub fn set_batch(
1421 &self,
1422 entries: Vec<(String, Value)>,
1423 action_id: &str,
1424 ) -> Vec<StateTransition> {
1425 let prefixed = entries
1426 .into_iter()
1427 .map(|(key, value)| (self.full_key(&key), value))
1428 .collect();
1429 self.store.set_batch(prefixed, action_id)
1430 }
1431
1432 pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
1433 self.store.delete(&self.full_key(key), action_id)
1434 }
1435
1436 pub fn keys(&self) -> Vec<String> {
1442 self.store
1443 .keys()
1444 .into_iter()
1445 .filter_map(|k| {
1446 if self.tenant.map(|t| !t.is_empty()).unwrap_or(false) {
1447 self.strip_prefix(&k).map(str::to_string)
1448 } else if k.starts_with("tenant:") {
1449 None
1450 } else {
1451 Some(k)
1452 }
1453 })
1454 .collect()
1455 }
1456}
1457
1458impl Default for StateStore {
1459 fn default() -> Self {
1460 Self::new()
1461 }
1462}
1463
1464impl car_ir::precondition::StateView for StateStore {
1465 fn get_value(&self, key: &str) -> Option<Value> {
1466 self.get(key)
1467 }
1468 fn key_exists(&self, key: &str) -> bool {
1469 self.exists(key)
1470 }
1471}
1472
1473impl car_ir::precondition::StateView for ScopedStateView<'_> {
1476 fn get_value(&self, key: &str) -> Option<Value> {
1477 self.get(key)
1478 }
1479 fn key_exists(&self, key: &str) -> bool {
1480 self.exists(key)
1481 }
1482}
1483
1484#[cfg(test)]
1485mod tests {
1486 use super::*;
1487 use serde_json::json;
1488
1489 #[test]
1490 fn set_and_get() {
1491 let store = StateStore::new();
1492 store.set("x", Value::from(42), "test");
1493 assert_eq!(store.get("x"), Some(Value::from(42)));
1494 }
1495
1496 #[test]
1497 fn exists() {
1498 let store = StateStore::new();
1499 assert!(!store.exists("x"));
1500 store.set("x", Value::from(1), "test");
1501 assert!(store.exists("x"));
1502 }
1503
1504 #[test]
1505 fn delete() {
1506 let store = StateStore::new();
1507 store.set("x", Value::from(1), "test");
1508 let t = store.delete("x", "test");
1509 assert!(t.is_some());
1510 assert!(!store.exists("x"));
1511 }
1512
1513 #[test]
1514 fn delete_nonexistent() {
1515 let store = StateStore::new();
1516 assert!(store.delete("x", "test").is_none());
1517 }
1518
1519 #[test]
1520 fn snapshot_and_restore() {
1521 let store = StateStore::new();
1522 store.set("x", Value::from(1), "a");
1523 let snap = store.snapshot();
1524 let tc = store.transition_count();
1525
1526 store.set("y", Value::from(2), "b");
1527 assert!(store.exists("y"));
1528
1529 store.restore(snap, tc).unwrap();
1530 assert!(store.exists("x"));
1531 assert!(!store.exists("y"));
1532 assert_eq!(store.transition_count(), 1);
1533 }
1534
1535 #[test]
1536 fn transitions_logged() {
1537 let store = StateStore::new();
1538 store.set("a", Value::from(1), "act1");
1539 store.set("b", Value::from(2), "act2");
1540
1541 let transitions = store.transitions();
1542 assert_eq!(transitions.len(), 2);
1543 assert_eq!(transitions[0].key, "a");
1544 assert_eq!(transitions[1].key, "b");
1545 }
1546
1547 #[test]
1548 fn transitions_since() {
1549 let store = StateStore::new();
1550 store.set("a", Value::from(1), "act1");
1551 let idx = store.transition_count();
1552 store.set("b", Value::from(2), "act2");
1553
1554 let since = store.transitions_since(idx);
1555 assert_eq!(since.len(), 1);
1556 assert_eq!(since[0].key, "b");
1557 }
1558
1559 #[test]
1560 fn transition_records_old_value() {
1561 let store = StateStore::new();
1562 store.set("x", Value::from(1), "first");
1563 store.set("x", Value::from(2), "second");
1564
1565 let transitions = store.transitions();
1566 assert_eq!(transitions[1].old_value, Some(Value::from(1)));
1567 assert_eq!(transitions[1].new_value, Some(Value::from(2)));
1568 }
1569
1570 #[test]
1571 fn keys() {
1572 let store = StateStore::new();
1573 store.set("a", Value::from(1), "t");
1574 store.set("b", Value::from(2), "t");
1575 let mut keys = store.keys();
1576 keys.sort();
1577 assert_eq!(keys, vec!["a", "b"]);
1578 }
1579
1580 #[test]
1581 fn transitions_since_after_restore_does_not_panic() {
1582 let store = StateStore::new();
1583 store.set("a", serde_json::json!(1), "test");
1584 store.set("b", serde_json::json!(2), "test");
1585 let count_before = store.transition_count(); store.restore(HashMap::new(), 0).unwrap();
1589
1590 let result = store.transitions_since(count_before);
1592 assert!(result.is_empty());
1593 }
1594
1595 #[test]
1596 fn transitions_since_normal_usage() {
1597 let store = StateStore::new();
1598 store.set("a", serde_json::json!(1), "test");
1599 let mark = store.transition_count();
1600 store.set("b", serde_json::json!(2), "test");
1601 let since = store.transitions_since(mark);
1602 assert_eq!(since.len(), 1);
1603 assert_eq!(since[0].key, "b");
1604 }
1605
1606 #[test]
1607 fn replace_all_swaps_state_without_transitions() {
1608 let store = StateStore::new();
1609 store.set("old_key", serde_json::json!("old"), "setup");
1610
1611 let mut new_state = HashMap::new();
1612 new_state.insert("new_key".to_string(), serde_json::json!("new"));
1613 store.replace_all(new_state);
1614
1615 assert_eq!(store.get("new_key"), Some(serde_json::json!("new")));
1616 assert_eq!(store.get("old_key"), None);
1617 assert_eq!(store.transition_count(), 0);
1619 }
1620
1621 #[test]
1622 fn durable_store_survives_reopen() {
1623 let dir = tempfile::tempdir().unwrap();
1624 let path = dir.path().join("state.jsonl");
1625 {
1626 let store = StateStore::durable(&path).unwrap();
1627 store.set("agent", serde_json::json!("planner"), "boot");
1628 store.set("turns", serde_json::json!(42), "tick");
1629 store.sync().unwrap();
1630 }
1631 let store = StateStore::durable(&path).unwrap();
1632 assert_eq!(store.get("agent"), Some(serde_json::json!("planner")));
1633 assert_eq!(store.get("turns"), Some(serde_json::json!(42)));
1634 }
1635
1636 #[test]
1637 fn durable_store_replays_deletes() {
1638 let dir = tempfile::tempdir().unwrap();
1639 let path = dir.path().join("state.jsonl");
1640 {
1641 let store = StateStore::durable(&path).unwrap();
1642 store.set("transient", serde_json::json!("x"), "boot");
1643 store.delete("transient", "rm");
1644 store.sync().unwrap();
1645 }
1646 let store = StateStore::durable(&path).unwrap();
1647 assert!(!store.exists("transient"));
1648 }
1649
1650 #[test]
1651 fn durable_restore_survives_reopen() {
1652 let dir = tempfile::tempdir().unwrap();
1653 let path = dir.path().join("state.jsonl");
1654 {
1655 let store = StateStore::durable(&path).unwrap();
1656 store.set("existing", serde_json::json!("old"), "setup");
1657 let snapshot = store.snapshot();
1658 let transition_count = store.transition_count();
1659
1660 store.set("existing", serde_json::json!("new"), "candidate");
1661 store.set("created", serde_json::json!(true), "candidate");
1662 store.restore(snapshot, transition_count).unwrap();
1663 store.sync().unwrap();
1664
1665 assert_eq!(store.get("existing"), Some(serde_json::json!("old")));
1666 assert!(!store.exists("created"));
1667 }
1668
1669 let reopened = StateStore::durable(&path).unwrap();
1670 assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1671 assert!(
1672 !reopened.exists("created"),
1673 "a full rollback must not resurrect candidate state after reopen"
1674 );
1675 }
1676
1677 #[test]
1678 fn abandoned_replacement_temps_do_not_block_restore_compaction_or_reopen() {
1679 let dir = tempfile::tempdir().unwrap();
1680 let path = dir.path().join("state.jsonl");
1681 {
1682 let store = StateStore::durable(&path).unwrap();
1683 store.set("existing", serde_json::json!("old"), "setup");
1684 store.sync().unwrap();
1685 }
1686
1687 let abandoned_transition = StateTransition {
1688 key: "intruder".to_string(),
1689 old_value: None,
1690 new_value: Some(serde_json::json!("must-not-replay")),
1691 action_id: "abandoned-temp".to_string(),
1692 timestamp: Utc::now(),
1693 ttl_secs: None,
1694 version: Some(99),
1695 };
1696 let abandoned_contents = format!(
1697 "{}\n",
1698 serde_json::to_string(&abandoned_transition).unwrap()
1699 );
1700 let abandoned_temps = [
1701 path.with_extension("jsonl.restore.tmp"),
1702 dir.path().join(".state.jsonl.restore.123.456.0.tmp"),
1703 dir.path().join(".state.jsonl.restore.789.012.1.tmp"),
1704 ];
1705 for temp in &abandoned_temps {
1706 std::fs::write(temp, &abandoned_contents).unwrap();
1707 }
1708
1709 let store = StateStore::durable(&path).unwrap();
1710 assert!(!store.exists("intruder"));
1711 let snapshot = store.snapshot();
1712 let transition_count = store.transition_count();
1713 store.set("existing", serde_json::json!("new"), "candidate");
1714 store.set("candidate_only", serde_json::json!(true), "candidate");
1715 assert_eq!(
1716 store.restore(snapshot, transition_count).unwrap(),
1717 RestoreDurability::Durable
1718 );
1719 store.set_with_ttl("expired", serde_json::json!(true), "ttl", 0);
1720 store.sync().unwrap();
1721 assert_eq!(
1722 store
1723 .reap_expired(Utc::now() + Duration::seconds(1))
1724 .unwrap(),
1725 vec!["expired".to_string()]
1726 );
1727 store.sync().unwrap();
1728 drop(store);
1729
1730 let reopened = StateStore::durable(&path).unwrap();
1731 assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1732 assert!(!reopened.exists("candidate_only"));
1733 assert!(!reopened.exists("expired"));
1734 assert!(!reopened.exists("intruder"));
1735 for temp in &abandoned_temps {
1736 assert!(temp.exists(), "abandoned sibling temp should be ignored");
1737 }
1738 }
1739
1740 #[test]
1741 fn durable_restore_parent_sync_failure_keeps_live_and_reopened_state_coherent() {
1742 let dir = tempfile::tempdir().unwrap();
1743 let path = dir.path().join("state.jsonl");
1744 let store = StateStore::durable(&path).unwrap();
1745 store.set("existing", serde_json::json!("old"), "setup");
1746 let snapshot = store.snapshot();
1747 let transition_count = store.transition_count();
1748 store.set("existing", serde_json::json!("new"), "candidate");
1749
1750 #[cfg(unix)]
1751 let original_mode = {
1752 use std::os::unix::fs::PermissionsExt;
1753 std::fs::metadata(&path).unwrap().permissions().mode()
1754 };
1755 let durability = store
1756 .restore_with_parent_sync(snapshot, transition_count, |_| {
1757 Err(std::io::Error::other(
1758 "injected parent directory sync failure",
1759 ))
1760 })
1761 .unwrap();
1762 assert!(matches!(
1763 durability,
1764 RestoreDurability::DurabilityUnknown { ref error }
1765 if error.contains("parent directory sync failure")
1766 ));
1767 assert_eq!(
1768 store.get("existing"),
1769 Some(serde_json::json!("old")),
1770 "once rename publishes the rollback journal, memory must adopt the same state"
1771 );
1772 assert!(
1773 !path.with_extension("jsonl.restore.tmp").exists(),
1774 "replacement temp must be cleaned after atomic publication"
1775 );
1776 #[cfg(unix)]
1777 {
1778 use std::os::unix::fs::PermissionsExt;
1779 assert_eq!(
1780 std::fs::metadata(&path).unwrap().permissions().mode(),
1781 original_mode,
1782 "replacement must preserve journal permissions"
1783 );
1784 }
1785 store.set("after_unknown", serde_json::json!("safe"), "later");
1786 store.sync().unwrap();
1787 drop(store);
1788
1789 let reopened = StateStore::durable(&path).unwrap();
1790 assert_eq!(
1791 reopened.get("existing"),
1792 Some(serde_json::json!("old")),
1793 "the visible journal and live rollback state must agree"
1794 );
1795 assert_eq!(
1796 reopened.get("after_unknown"),
1797 Some(serde_json::json!("safe")),
1798 "a later write must first reconcile the pending parent sync"
1799 );
1800 }
1801
1802 #[test]
1803 fn durability_unknown_store_refuses_write_until_parent_sync_reconciles() {
1804 let dir = tempfile::tempdir().unwrap();
1805 let path = dir.path().join("state.jsonl");
1806 let failures = RestoreFailureInjector::default();
1807 let store =
1808 StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
1809 .unwrap();
1810 store.set("existing", serde_json::json!("old"), "setup");
1811 let snapshot = store.snapshot();
1812 let transition_count = store.transition_count();
1813 store.set("existing", serde_json::json!("new"), "candidate");
1814 failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1815 failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1816
1817 assert!(matches!(
1818 store.restore(snapshot, transition_count).unwrap(),
1819 RestoreDurability::DurabilityUnknown { .. }
1820 ));
1821 let refused = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1822 store.set("must_not_write", serde_json::json!(true), "later");
1823 }));
1824 assert!(refused.is_err());
1825 assert!(!store.exists("must_not_write"));
1826 drop(store);
1827
1828 let reopened = StateStore::durable(&path).unwrap();
1829 assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1830 assert!(!reopened.exists("must_not_write"));
1831 }
1832
1833 #[test]
1834 fn mutation_waiting_for_state_reconciles_restore_durability_unknown() {
1835 let dir = tempfile::tempdir().unwrap();
1836 let path = dir.path().join("state.jsonl");
1837 let failures = RestoreFailureInjector::default();
1838 let mut store =
1839 StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
1840 .unwrap();
1841 store.set("existing", serde_json::json!("old"), "setup");
1842 let snapshot = store.snapshot();
1843 let transition_count = store.transition_count();
1844 store.set("existing", serde_json::json!("candidate"), "candidate");
1845
1846 let barrier = Arc::new(MutationRaceBarrier::new());
1847 store.mutation_before_state_lock = Some(barrier.clone());
1848 let store = Arc::new(store);
1849 failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1850 failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1851
1852 let mutation_store = store.clone();
1853 let mutation = std::thread::spawn(move || {
1854 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1855 mutation_store.set("must_not_write", serde_json::json!(true), "racing");
1856 }))
1857 });
1858
1859 barrier.reached.wait();
1860 assert!(matches!(
1861 store.restore(snapshot, transition_count).unwrap(),
1862 RestoreDurability::DurabilityUnknown { .. }
1863 ));
1864 barrier.release.wait();
1865
1866 assert!(
1867 mutation.join().unwrap().is_err(),
1868 "a mutation admitted before restore must reconcile the journal after acquiring state"
1869 );
1870 assert!(!store.exists("must_not_write"));
1871 drop(store);
1872
1873 let reopened = StateStore::durable(&path).unwrap();
1874 assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1875 assert!(!reopened.exists("must_not_write"));
1876 }
1877
1878 #[test]
1879 fn durable_scoped_restore_survives_reopen_without_clobbering_other_tenants() {
1880 let dir = tempfile::tempdir().unwrap();
1881 let path = dir.path().join("state.jsonl");
1882 {
1883 let store = StateStore::durable(&path).unwrap();
1884 store
1885 .scoped(Some("acme"))
1886 .set("existing", serde_json::json!("old"), "setup");
1887 store
1888 .scoped(Some("globex"))
1889 .set("survivor", serde_json::json!("before"), "setup");
1890 let snapshot = store.snapshot_scoped(Some("acme"));
1891 let transition_count = store.transition_count();
1892
1893 store
1894 .scoped(Some("acme"))
1895 .set("existing", serde_json::json!("new"), "candidate");
1896 store
1897 .scoped(Some("acme"))
1898 .set("created", serde_json::json!(true), "candidate");
1899 store.scoped(Some("globex")).set(
1900 "survivor",
1901 serde_json::json!("after"),
1902 "other-proposal",
1903 );
1904 store
1905 .restore_scoped(Some("acme"), snapshot, transition_count)
1906 .unwrap();
1907 store.sync().unwrap();
1908
1909 assert_eq!(
1910 store.scoped(Some("acme")).get("existing"),
1911 Some(serde_json::json!("old"))
1912 );
1913 assert!(!store.scoped(Some("acme")).exists("created"));
1914 assert_eq!(
1915 store.scoped(Some("globex")).get("survivor"),
1916 Some(serde_json::json!("after"))
1917 );
1918 }
1919
1920 let reopened = StateStore::durable(&path).unwrap();
1921 assert_eq!(
1922 reopened.scoped(Some("acme")).get("existing"),
1923 Some(serde_json::json!("old"))
1924 );
1925 assert!(!reopened.scoped(Some("acme")).exists("created"));
1926 assert_eq!(
1927 reopened.scoped(Some("globex")).get("survivor"),
1928 Some(serde_json::json!("after")),
1929 "scoped rollback must preserve unrelated tenant state after reopen"
1930 );
1931 }
1932
1933 #[test]
1934 fn ttl_reap_drops_expired_and_keeps_fresh() {
1935 let store = StateStore::new();
1936 store.set_with_ttl("short", serde_json::json!(1), "set", 0);
1937 store.set_with_ttl("long", serde_json::json!(2), "set", 3600);
1938 store.set("forever", serde_json::json!(3), "set");
1939 let reaped = store
1941 .reap_expired(Utc::now() + Duration::seconds(10))
1942 .unwrap();
1943 assert_eq!(reaped, vec!["short".to_string()]);
1944 assert!(!store.exists("short"));
1945 assert_eq!(store.get("long"), Some(serde_json::json!(2)));
1946 assert_eq!(store.get("forever"), Some(serde_json::json!(3)));
1947 }
1948
1949 #[test]
1950 fn scoped_reap_isolates_tenants() {
1951 let store = StateStore::new();
1955 store
1956 .scoped(Some("a"))
1957 .set_with_ttl("k", serde_json::json!(1), "set", 0);
1958 store
1959 .scoped(Some("b"))
1960 .set_with_ttl("k", serde_json::json!(2), "set", 0);
1961 store.set_with_ttl("global", serde_json::json!(3), "set", 0);
1962
1963 let future = Utc::now() + Duration::seconds(10);
1964 let reaped = store.reap_expired_scoped(future, Some("a")).unwrap();
1965 assert_eq!(reaped, vec!["tenant:a:k".to_string()]);
1966 assert!(!store.scoped(Some("a")).exists("k"));
1968 assert!(store.scoped(Some("b")).exists("k"));
1969 assert!(store.exists("global"));
1970
1971 let reaped = store.reap_expired_scoped(future, None).unwrap();
1973 assert_eq!(reaped, vec!["global".to_string()]);
1974 assert!(store.scoped(Some("b")).exists("k"));
1975 }
1976
1977 #[test]
1978 fn durable_ttl_compacts_journal() {
1979 let dir = tempfile::tempdir().unwrap();
1980 let path = dir.path().join("state.jsonl");
1981 {
1982 let store = StateStore::durable(&path).unwrap();
1983 for i in 0..50 {
1984 store.set_with_ttl(&format!("k{i}"), serde_json::json!(i), "set", 0);
1985 }
1986 store.set("survivor", serde_json::json!("kept"), "set");
1987 store.sync().unwrap();
1988 let pre = std::fs::metadata(&path).unwrap().len();
1989 let reaped = store
1991 .reap_expired(Utc::now() + Duration::seconds(1))
1992 .unwrap();
1993 assert_eq!(reaped.len(), 50);
1994 store.sync().unwrap();
1995 let post = std::fs::metadata(&path).unwrap().len();
1996 assert!(
1999 post < pre,
2000 "post={post} pre={pre} — compaction did not shrink"
2001 );
2002 }
2003 let store = StateStore::durable(&path).unwrap();
2005 assert!(!store.exists("k0"));
2006 assert!(!store.exists("k49"));
2007 assert_eq!(store.get("survivor"), Some(serde_json::json!("kept")));
2008 assert_eq!(store.version("survivor"), Some(1));
2012 }
2013
2014 #[test]
2015 fn version_is_monotonic_and_survives_compaction() {
2016 let dir = tempfile::tempdir().unwrap();
2017 let path = dir.path().join("v.jsonl");
2018 {
2019 let store = StateStore::durable(&path).unwrap();
2020 for i in 0..3 {
2021 store.set("cfg", serde_json::json!(i), "set");
2022 }
2023 assert_eq!(store.version("cfg"), Some(3));
2024 store.set_with_ttl("tmp", serde_json::json!(1), "set", 0);
2026 store.sync().unwrap();
2027 store
2028 .reap_expired(Utc::now() + Duration::seconds(1))
2029 .unwrap();
2030 store.sync().unwrap();
2031 }
2032 let store = StateStore::durable(&path).unwrap();
2035 assert_eq!(store.version("cfg"), Some(3));
2036 }
2037
2038 #[test]
2039 fn reap_bumps_version() {
2040 let store = StateStore::new();
2041 store.set("k", serde_json::json!("v"), "set");
2042 assert_eq!(store.version("k"), Some(1));
2043 store.set_with_ttl("k", serde_json::json!("v2"), "set", 0);
2044 assert_eq!(store.version("k"), Some(2));
2045 store
2046 .reap_expired(Utc::now() + Duration::seconds(1))
2047 .unwrap();
2048 assert_eq!(store.version("k"), Some(3));
2050 }
2051
2052 #[test]
2053 fn ttl_then_rewrite_without_ttl_does_not_reap() {
2054 let store = StateStore::new();
2055 store.set_with_ttl("k", serde_json::json!("a"), "first", 0);
2056 store.set("k", serde_json::json!("b"), "second"); let reaped = store
2058 .reap_expired(Utc::now() + Duration::seconds(10))
2059 .unwrap();
2060 assert!(reaped.is_empty());
2061 assert_eq!(store.get("k"), Some(serde_json::json!("b")));
2062 }
2063
2064 #[test]
2065 fn invalid_utf8_journal_line_is_skipped_never_applied_mangled() {
2066 let dir = tempfile::tempdir().unwrap();
2067 let path = dir.path().join("state.jsonl");
2068 let mut journal = Vec::new();
2074 journal.extend_from_slice(
2075 b"{\"key\":\"good\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
2076 );
2077 journal.extend_from_slice(
2078 b"{\"key\":\"corrupt\",\"old_value\":null,\"new_value\":\"va\xFFlue\",\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
2079 );
2080 journal.extend_from_slice(
2081 b"{\"batch\":[{\"key\":\"corrupt_batch\",\"old_value\":null,\"new_value\":\"a\xFFb\",\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}]}\n",
2082 );
2083 std::fs::write(&path, &journal).unwrap();
2084
2085 let store = StateStore::durable(&path).unwrap();
2086 assert_eq!(store.get("good"), Some(json!(1)));
2087 assert!(
2088 !store.exists("corrupt"),
2089 "an invalid-UTF-8 record must be skipped, not applied with U+FFFD"
2090 );
2091 assert!(!store.exists("corrupt_batch"));
2092 }
2093
2094 #[test]
2095 fn malformed_journal_line_is_skipped_not_fatal() {
2096 let dir = tempfile::tempdir().unwrap();
2097 let path = dir.path().join("state.jsonl");
2098 {
2100 std::fs::write(
2101 &path,
2102 "{\"key\":\"a\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n\
2103 not-json\n\
2104 {\"key\":\"b\",\"old_value\":null,\"new_value\":2,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
2105 )
2106 .unwrap();
2107 }
2108 let store = StateStore::durable(&path).unwrap();
2109 assert_eq!(store.get("a"), Some(serde_json::json!(1)));
2110 assert_eq!(store.get("b"), Some(serde_json::json!(2)));
2111 }
2112
2113 #[test]
2116 fn scoped_view_writes_isolate_between_tenants() {
2117 let store = StateStore::new();
2118 store.scoped(Some("acme")).set("config", json!("A"), "act");
2119 store
2120 .scoped(Some("globex"))
2121 .set("config", json!("G"), "act");
2122
2123 assert_eq!(store.scoped(Some("acme")).get("config"), Some(json!("A")));
2125 assert_eq!(store.scoped(Some("globex")).get("config"), Some(json!("G")));
2126 }
2127
2128 #[test]
2129 fn scoped_view_isolates_existence_check() {
2130 let store = StateStore::new();
2131 store.scoped(Some("acme")).set("k", json!(1), "act");
2132 assert!(store.scoped(Some("acme")).exists("k"));
2133 assert!(!store.scoped(Some("globex")).exists("k"));
2134 }
2135
2136 #[test]
2137 fn scoped_view_keys_filters_to_tenant() {
2138 let store = StateStore::new();
2139 store.scoped(Some("acme")).set("a", json!(1), "act");
2140 store.scoped(Some("acme")).set("b", json!(2), "act");
2141 store.scoped(Some("globex")).set("g", json!(9), "act");
2142 store.set("unscoped", json!(0), "act");
2143
2144 let mut acme_keys = store.scoped(Some("acme")).keys();
2145 acme_keys.sort();
2146 assert_eq!(acme_keys, vec!["a", "b"]);
2147
2148 let globex_keys = store.scoped(Some("globex")).keys();
2149 assert_eq!(globex_keys, vec!["g"]);
2150 }
2151
2152 #[test]
2153 fn unscoped_view_skips_tenant_prefixed_keys() {
2154 let store = StateStore::new();
2160 store.set("legacy", json!("ok"), "act");
2161 store.scoped(Some("acme")).set("hidden", json!(42), "act");
2162
2163 let unscoped = store.scoped(None).keys();
2164 assert_eq!(unscoped, vec!["legacy"]);
2165 assert!(store.scoped(None).get("hidden").is_none());
2166 }
2167
2168 #[test]
2169 fn scoped_restore_does_not_clobber_other_tenants() {
2170 let store = StateStore::new();
2173 store.scoped(Some("acme")).set("k", json!("acme-v1"), "a");
2174 store
2175 .scoped(Some("globex"))
2176 .set("k", json!("globex-v1"), "a");
2177 store.set("global", json!("g-v1"), "a");
2178
2179 let acme_snap = store.scoped(Some("acme")).snapshot();
2181 store.scoped(Some("acme")).set("k", json!("acme-v2"), "a");
2182 store
2183 .scoped(Some("globex"))
2184 .set("k", json!("globex-v2"), "a");
2185 store.set("global", json!("g-v2"), "a");
2186
2187 store.scoped(Some("acme")).restore(acme_snap, 0).unwrap();
2189 assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme-v1")));
2190 assert_eq!(
2191 store.scoped(Some("globex")).get("k"),
2192 Some(json!("globex-v2"))
2193 );
2194 assert_eq!(store.get("global"), Some(json!("g-v2")));
2195 }
2196
2197 #[test]
2198 fn snapshot_scoped_captures_only_its_namespace() {
2199 let store = StateStore::new();
2200 store.set("global", json!(1), "a");
2201 store.scoped(Some("acme")).set("x", json!(2), "a");
2202 store.scoped(Some("globex")).set("y", json!(3), "a");
2203
2204 let acme = store.snapshot_scoped(Some("acme"));
2205 assert_eq!(acme.len(), 1);
2206 assert!(acme.contains_key("tenant:acme:x"));
2207
2208 let global = store.snapshot_scoped(None);
2209 assert_eq!(global.len(), 1);
2210 assert!(global.contains_key("global"));
2211 }
2212
2213 #[test]
2214 fn unscoped_restore_leaves_tenant_keys_intact() {
2215 let store = StateStore::new();
2217 store.set("g", json!("v1"), "a");
2218 store.scoped(Some("acme")).set("k", json!("acme"), "a");
2219
2220 let snap = store.snapshot_scoped(None);
2221 store.set("g", json!("v2"), "a");
2222 store.restore_scoped(None, snap, 0).unwrap();
2223
2224 assert_eq!(store.get("g"), Some(json!("v1")));
2225 assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme")));
2227 }
2228
2229 #[test]
2230 fn scoped_restore_preserves_other_tenants_transitions() {
2231 let store = StateStore::new();
2236 store.scoped(Some("acme")).set("k", json!("a1"), "act");
2237
2238 let snap = store.snapshot_scoped(Some("acme"));
2240 let count = store.transition_count();
2241
2242 store.scoped(Some("acme")).set("k", json!("a2"), "act");
2245 store.scoped(Some("globex")).set("g", json!("gv"), "act");
2246
2247 store.restore_scoped(Some("acme"), snap, count).unwrap();
2248
2249 let tail = store.transitions_since(count);
2251 assert_eq!(
2252 tail.len(),
2253 1,
2254 "exactly globex's transition survives: {tail:?}"
2255 );
2256 assert_eq!(tail[0].key, "tenant:globex:g");
2257 assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("a1")));
2259 assert_eq!(store.scoped(Some("globex")).get("g"), Some(json!("gv")));
2260 }
2261
2262 #[test]
2263 fn scoped_view_delete_doesnt_touch_other_tenants() {
2264 let store = StateStore::new();
2265 store.scoped(Some("acme")).set("shared", json!(1), "act");
2266 store.scoped(Some("globex")).set("shared", json!(2), "act");
2267
2268 store.scoped(Some("acme")).delete("shared", "act");
2269 assert!(!store.scoped(Some("acme")).exists("shared"));
2270 assert!(store.scoped(Some("globex")).exists("shared"));
2271 }
2272
2273 #[test]
2274 fn empty_tenant_string_treated_as_unscoped() {
2275 let store = StateStore::new();
2279 store.scoped(Some("")).set("k", json!(1), "act");
2280 assert_eq!(store.get("k"), Some(json!(1)));
2281 assert_eq!(store.scoped(None).get("k"), Some(json!(1)));
2282 }
2283
2284 #[test]
2287 fn set_batch_applies_all_entries_with_versions_and_transitions() {
2288 let store = StateStore::new();
2289 store.set("a", json!("old"), "setup");
2290
2291 let batch = store.set_batch(
2292 vec![("a".to_string(), json!("new")), ("b".to_string(), json!(2))],
2293 "callback-action",
2294 );
2295
2296 assert_eq!(store.get("a"), Some(json!("new")));
2297 assert_eq!(store.get("b"), Some(json!(2)));
2298 assert_eq!(store.version("a"), Some(2));
2299 assert_eq!(store.version("b"), Some(1));
2300 assert_eq!(batch.len(), 2);
2303 assert_eq!(batch[0].old_value, Some(json!("old")));
2304 assert_eq!(batch[1].old_value, None);
2305 assert!(batch.iter().all(|t| t.action_id == "callback-action"));
2306 assert_eq!(store.transition_count(), 3);
2308 }
2309
2310 #[test]
2311 fn empty_batch_is_a_noop() {
2312 let store = StateStore::new();
2313 assert!(store.set_batch(Vec::new(), "noop").is_empty());
2314 assert_eq!(store.transition_count(), 0);
2315 }
2316
2317 #[test]
2318 fn durable_two_and_three_key_batches_survive_reopen_with_versions() {
2319 let dir = tempfile::tempdir().unwrap();
2320 let path = dir.path().join("state.jsonl");
2321 {
2322 let store = StateStore::durable(&path).unwrap();
2323 store.set_batch(
2324 vec![("x".to_string(), json!(1)), ("y".to_string(), json!(2))],
2325 "two-key",
2326 );
2327 store.set_batch(
2328 vec![
2329 ("x".to_string(), json!(10)),
2330 ("y".to_string(), json!(20)),
2331 ("z".to_string(), json!(30)),
2332 ],
2333 "three-key",
2334 );
2335 store.sync().unwrap();
2336 }
2337 let reopened = StateStore::durable(&path).unwrap();
2338 assert_eq!(reopened.get("x"), Some(json!(10)));
2339 assert_eq!(reopened.get("y"), Some(json!(20)));
2340 assert_eq!(reopened.get("z"), Some(json!(30)));
2341 assert_eq!(reopened.version("x"), Some(2));
2342 assert_eq!(reopened.version("y"), Some(2));
2343 assert_eq!(reopened.version("z"), Some(1));
2344 }
2345
2346 #[test]
2347 fn multi_key_batch_is_one_journal_line_and_single_key_keeps_legacy_shape() {
2348 let dir = tempfile::tempdir().unwrap();
2349 let path = dir.path().join("state.jsonl");
2350 let store = StateStore::durable(&path).unwrap();
2351
2352 store.set_batch(
2353 vec![
2354 ("k1".to_string(), json!(1)),
2355 ("k2".to_string(), json!(2)),
2356 ("k3".to_string(), json!(3)),
2357 ],
2358 "multi",
2359 );
2360 store.set_batch(vec![("solo".to_string(), json!(4))], "single");
2361 store.sync().unwrap();
2362
2363 let journal = std::fs::read_to_string(&path).unwrap();
2364 let lines: Vec<&str> = journal.lines().filter(|l| !l.trim().is_empty()).collect();
2365 assert_eq!(
2366 lines.len(),
2367 2,
2368 "a 3-key batch must be ONE line and a 1-key batch one line: {journal}"
2369 );
2370 let multi: Value = serde_json::from_str(lines[0]).unwrap();
2371 assert_eq!(
2372 multi["batch"].as_array().map(Vec::len),
2373 Some(3),
2374 "the multi-key line is a batch record"
2375 );
2376 let single: StateTransition = serde_json::from_str(lines[1]).unwrap();
2380 assert_eq!(single.key, "solo");
2381 }
2382
2383 fn torn_batch_replays_complete_old_state(n_keys: usize) {
2388 let dir = tempfile::tempdir().unwrap();
2389 let path = dir.path().join("state.jsonl");
2390 let torn_keys: Vec<String> = (1..=n_keys).map(|i| format!("t{i}")).collect();
2391 {
2392 let store = StateStore::durable(&path).unwrap();
2393 store.set("base", json!("kept"), "setup");
2394 store.set_batch(
2395 vec![("p".to_string(), json!(1)), ("q".to_string(), json!(2))],
2396 "complete-batch",
2397 );
2398 store.set_batch(
2399 torn_keys
2400 .iter()
2401 .map(|key| (key.clone(), json!(format!("{key}-value"))))
2402 .collect(),
2403 "torn-batch",
2404 );
2405 store.sync().unwrap();
2406 }
2407 let full = std::fs::read_to_string(&path).unwrap();
2408 let last_line_start = full.trim_end().rfind('\n').unwrap() + 1;
2409 let torn_cut = last_line_start + (full.trim_end().len() - last_line_start) / 2;
2410 std::fs::write(&path, &full.as_bytes()[..torn_cut]).unwrap();
2411
2412 let store = StateStore::durable(&path).unwrap();
2413 assert_eq!(store.get("base"), Some(json!("kept")));
2414 assert_eq!(store.get("p"), Some(json!(1)));
2415 assert_eq!(store.get("q"), Some(json!(2)));
2416 for torn_key in &torn_keys {
2417 assert!(
2418 !store.exists(torn_key),
2419 "an interrupted {n_keys}-key batch append must replay as \
2420 complete-old — no key of the torn batch may surface ({torn_key} did)"
2421 );
2422 }
2423 }
2424
2425 #[test]
2426 fn torn_two_key_batch_journal_line_replays_complete_old_state_never_a_prefix() {
2427 torn_batch_replays_complete_old_state(2);
2428 }
2429
2430 #[test]
2431 fn torn_three_key_batch_journal_line_replays_complete_old_state_never_a_prefix() {
2432 torn_batch_replays_complete_old_state(3);
2433 }
2434
2435 #[test]
2436 fn valid_unterminated_tail_is_terminated_so_the_next_append_survives_reopen() {
2437 let dir = tempfile::tempdir().unwrap();
2438 let path = dir.path().join("state.jsonl");
2439 {
2440 let store = StateStore::durable(&path).unwrap();
2441 store.set("base", json!("kept"), "setup");
2442 store.set_batch(
2443 vec![("b1".to_string(), json!(1)), ("b2".to_string(), json!(2))],
2444 "tail-batch",
2445 );
2446 store.sync().unwrap();
2447 }
2448 let full = std::fs::read_to_string(&path).unwrap();
2451 std::fs::write(&path, full.trim_end().as_bytes()).unwrap();
2452
2453 {
2454 let store = StateStore::durable(&path).unwrap();
2455 assert_eq!(store.get("b1"), Some(json!(1)), "the complete tail replays");
2456 store.set("after", json!("survives"), "later");
2457 store.sync().unwrap();
2458 }
2459 let reopened = StateStore::durable(&path).unwrap();
2463 assert_eq!(reopened.get("base"), Some(json!("kept")));
2464 assert_eq!(reopened.get("b1"), Some(json!(1)));
2465 assert_eq!(reopened.get("b2"), Some(json!(2)));
2466 assert_eq!(reopened.get("after"), Some(json!("survives")));
2467 }
2468
2469 #[test]
2470 fn torn_unterminated_tail_is_truncated_so_the_next_append_is_not_merged() {
2471 let dir = tempfile::tempdir().unwrap();
2472 let path = dir.path().join("state.jsonl");
2473 {
2474 let store = StateStore::durable(&path).unwrap();
2475 store.set("base", json!("kept"), "setup");
2476 store.set_batch(
2477 vec![
2478 ("t1".to_string(), json!(1)),
2479 ("t2".to_string(), json!(2)),
2480 ("t3".to_string(), json!(3)),
2481 ],
2482 "torn-batch",
2483 );
2484 store.sync().unwrap();
2485 }
2486 let full = std::fs::read_to_string(&path).unwrap();
2488 let last_line_start = full.trim_end().rfind('\n').unwrap() + 1;
2489 let torn_cut = last_line_start + (full.trim_end().len() - last_line_start) / 2;
2490 std::fs::write(&path, &full.as_bytes()[..torn_cut]).unwrap();
2491
2492 {
2493 let store = StateStore::durable(&path).unwrap();
2494 assert!(
2495 !store.exists("t1"),
2496 "the torn batch replays as complete-old"
2497 );
2498 store.set("after", json!("survives"), "later");
2499 store.sync().unwrap();
2500 }
2501 let reopened = StateStore::durable(&path).unwrap();
2502 assert_eq!(reopened.get("base"), Some(json!("kept")));
2503 assert_eq!(
2504 reopened.get("after"),
2505 Some(json!("survives")),
2506 "the record appended after a torn tail must not merge into it and be lost"
2507 );
2508 for torn_key in ["t1", "t2", "t3"] {
2509 assert!(!reopened.exists(torn_key));
2510 }
2511 }
2512
2513 fn reader_cannot_enter_mid_batch(n_keys: usize) {
2520 let keys: Vec<String> = (1..=n_keys).map(|i| format!("k{i}")).collect();
2521 let mut store = StateStore::new();
2522 for key in &keys {
2523 store.set(key, json!("old"), "setup");
2524 }
2525 let barrier = Arc::new(MutationRaceBarrier::new());
2526 store.batch_mid_apply = Some(barrier.clone());
2527 let store = Arc::new(store);
2528
2529 let batch_store = store.clone();
2530 let batch_keys = keys.clone();
2531 let batch = std::thread::spawn(move || {
2532 batch_store.set_batch(
2533 batch_keys
2534 .into_iter()
2535 .map(|key| (key, json!("new")))
2536 .collect(),
2537 "batch",
2538 );
2539 });
2540
2541 barrier.reached.wait();
2544 assert!(
2545 store.state.try_lock().is_none(),
2546 "the state lock must be held for the whole batch — a reader \
2547 admitted here would observe a partial {n_keys}-key set"
2548 );
2549 let reader_store = store.clone();
2550 let reader_keys = keys.clone();
2551 let reader = std::thread::spawn(move || {
2552 reader_keys
2553 .iter()
2554 .rev()
2555 .map(|key| reader_store.get(key))
2556 .collect::<Vec<_>>()
2557 });
2558 barrier.release.wait();
2559 batch.join().unwrap();
2560
2561 let observed = reader.join().unwrap();
2562 assert!(
2563 observed.iter().all(|value| value == &Some(json!("new"))),
2564 "a reader admitted during a batch must see the complete new \
2565 state, got {observed:?}"
2566 );
2567 }
2568
2569 #[test]
2570 fn reader_cannot_enter_mid_two_key_batch_and_observes_complete_state() {
2571 reader_cannot_enter_mid_batch(2);
2572 }
2573
2574 #[test]
2575 fn reader_cannot_enter_mid_three_key_batch_and_observes_complete_state() {
2576 reader_cannot_enter_mid_batch(3);
2577 }
2578
2579 #[test]
2580 fn snapshot_stripped_is_single_lock_and_strips_tenant_prefixes() {
2581 let store = StateStore::new();
2585 store.set("global", json!(1), "act");
2586 store.scoped(Some("acme")).set("x", json!(2), "act");
2587 store.scoped(Some("globex")).set("y", json!(3), "act");
2588
2589 let acme = store.scoped(Some("acme")).snapshot_stripped();
2590 assert_eq!(acme, [("x".to_string(), json!(2))].into());
2591
2592 let unscoped = store.scoped(None).snapshot_stripped();
2593 assert_eq!(unscoped, [("global".to_string(), json!(1))].into());
2594 }
2595
2596 #[test]
2597 fn a_tenant_sees_shared_keys_and_its_own_never_another_tenants() {
2598 use car_ir::precondition::StateView;
2599 let store = StateStore::new();
2600 store.set("global", json!(1), "act");
2601 store.scoped(Some("acme")).set("x", json!(2), "act");
2602 store.scoped(Some("globex")).set("y", json!(3), "act");
2603
2604 let (values, versions) = store.versioned_snapshot_visible_to(Some("acme"));
2605 let expected: HashMap<String, Value> = [
2606 ("global".to_string(), json!(1)),
2607 ("tenant:acme:x".to_string(), json!(2)),
2608 ]
2609 .into();
2610 assert_eq!(values, expected);
2611 assert_eq!(
2612 versions.keys().collect::<std::collections::HashSet<_>>(),
2613 expected.keys().collect()
2614 );
2615
2616 let view = store.visible_to(Some("acme"));
2617 assert!(view.key_exists("global"));
2618 assert!(view.key_exists("tenant:acme:x"));
2619 assert!(!view.key_exists("tenant:globex:y"));
2620 assert_eq!(view.get_value("tenant:globex:y"), None);
2621
2622 assert_eq!(
2624 store.versioned_snapshot_visible_to(None),
2625 store.versioned_snapshot()
2626 );
2627 assert!(store.visible_to(None).key_exists("tenant:globex:y"));
2628 }
2629
2630 #[test]
2631 fn set_batch_refused_while_journal_durability_unknown() {
2632 let dir = tempfile::tempdir().unwrap();
2633 let path = dir.path().join("state.jsonl");
2634 let failures = RestoreFailureInjector::default();
2635 let store =
2636 StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
2637 .unwrap();
2638 store.set("existing", json!("old"), "setup");
2639 let snapshot = store.snapshot();
2640 let transition_count = store.transition_count();
2641 store.set("existing", json!("new"), "candidate");
2642 failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
2643 failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
2644
2645 assert!(matches!(
2646 store.restore(snapshot, transition_count).unwrap(),
2647 RestoreDurability::DurabilityUnknown { .. }
2648 ));
2649 let refused = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2650 store.set_batch(
2651 vec![
2652 ("must_not_write".to_string(), json!(true)),
2653 ("nor_this".to_string(), json!(true)),
2654 ],
2655 "later",
2656 );
2657 }));
2658 assert!(
2659 refused.is_err(),
2660 "set_batch must refuse mutation exactly like set while the journal is durability-unknown"
2661 );
2662 assert!(!store.exists("must_not_write"));
2663 assert!(!store.exists("nor_this"));
2664 }
2665
2666 #[test]
2667 fn scoped_set_batch_prefixes_all_keys_into_tenant_namespace() {
2668 let store = StateStore::new();
2669 store.scoped(Some("acme")).set_batch(
2670 vec![("a".to_string(), json!(1)), ("b".to_string(), json!(2))],
2671 "act",
2672 );
2673 assert_eq!(store.scoped(Some("acme")).get("a"), Some(json!(1)));
2674 assert_eq!(store.scoped(Some("acme")).get("b"), Some(json!(2)));
2675 assert!(!store.scoped(Some("globex")).exists("a"));
2676 assert!(!store.scoped(None).exists("a"));
2677 assert_eq!(store.get("tenant:acme:b"), Some(json!(2)));
2678 }
2679
2680 #[test]
2681 fn batch_then_compaction_and_reopen_preserve_state_and_versions() {
2682 let dir = tempfile::tempdir().unwrap();
2683 let path = dir.path().join("state.jsonl");
2684 {
2685 let store = StateStore::durable(&path).unwrap();
2686 store.set_batch(
2687 vec![("a".to_string(), json!(1)), ("b".to_string(), json!(2))],
2688 "batch",
2689 );
2690 store.set_with_ttl("tmp", json!(true), "ttl", 0);
2693 store.sync().unwrap();
2694 store
2695 .reap_expired(Utc::now() + Duration::seconds(1))
2696 .unwrap();
2697 store.sync().unwrap();
2698 }
2699 let reopened = StateStore::durable(&path).unwrap();
2700 assert_eq!(reopened.get("a"), Some(json!(1)));
2701 assert_eq!(reopened.get("b"), Some(json!(2)));
2702 assert_eq!(reopened.version("a"), Some(1));
2703 assert_eq!(reopened.version("b"), Some(1));
2704 assert!(!reopened.exists("tmp"));
2705 }
2706}