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 delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
1008 let mut state = self.require_reconciled_state_for_mutation();
1009 let old = state.remove(key)?;
1010 let version = self.bump_version(key);
1011
1012 let t = StateTransition {
1013 key: key.to_string(),
1014 old_value: Some(old),
1015 new_value: None,
1016 action_id: action_id.to_string(),
1017 timestamp: Utc::now(),
1018 ttl_secs: None,
1019 version: Some(version),
1020 };
1021
1022 self.transitions.lock().push(t.clone());
1023 self.append_journal(&t);
1024 Some(t)
1025 }
1026
1027 pub fn snapshot(&self) -> HashMap<String, Value> {
1029 self.state.lock().clone()
1030 }
1031
1032 pub fn restore(
1036 &self,
1037 snapshot: HashMap<String, Value>,
1038 transition_count: usize,
1039 ) -> std::io::Result<RestoreDurability> {
1040 let restore_failures = self.restore_failures.as_ref();
1041 self.restore_with_parent_sync(snapshot, transition_count, |path| {
1042 if let Some(failures) = restore_failures {
1043 failures.check(RestoreFailurePoint::ParentDirectorySync)?;
1044 }
1045 sync_parent_directory(path)
1046 })
1047 }
1048
1049 fn restore_with_parent_sync<F>(
1050 &self,
1051 snapshot: HashMap<String, Value>,
1052 transition_count: usize,
1053 sync_parent: F,
1054 ) -> std::io::Result<RestoreDurability>
1055 where
1056 F: FnOnce(&Path) -> std::io::Result<()>,
1057 {
1058 let mut state = self.state.lock();
1059 let mut transitions = self.transitions.lock();
1060 let versions = self.versions.lock();
1061 let mut restored_transitions = transitions.clone();
1062 restored_transitions.truncate(transition_count);
1063 let durability = self.replace_journal_with_snapshot_and_sync(
1064 &snapshot,
1065 &restored_transitions,
1066 &versions,
1067 sync_parent,
1068 )?;
1069 *state = snapshot;
1070 *transitions = restored_transitions;
1071 Ok(durability)
1072 }
1073
1074 pub fn snapshot_scoped(&self, tenant: Option<&str>) -> HashMap<String, Value> {
1082 let state = self.state.lock();
1083 state
1084 .iter()
1085 .filter(|(k, _)| key_in_tenant_namespace(k, tenant))
1086 .map(|(k, v)| (k.clone(), v.clone()))
1087 .collect()
1088 }
1089
1090 pub fn restore_scoped(
1105 &self,
1106 tenant: Option<&str>,
1107 snapshot: HashMap<String, Value>,
1108 transition_count: usize,
1109 ) -> std::io::Result<RestoreDurability> {
1110 let mut state = self.state.lock();
1111 let mut transitions = self.transitions.lock();
1112 let versions = self.versions.lock();
1113 let mut restored_state = state.clone();
1114 restored_state.retain(|k, _| !key_in_tenant_namespace(k, tenant));
1115 restored_state.extend(snapshot);
1116 let mut restored_transitions = transitions.clone();
1117 if transition_count < restored_transitions.len() {
1118 let tail: Vec<StateTransition> = restored_transitions
1121 .drain(transition_count..)
1122 .filter(|transition| !key_in_tenant_namespace(&transition.key, tenant))
1123 .collect();
1124 restored_transitions.extend(tail);
1125 }
1126 let durability =
1127 self.replace_journal_with_snapshot(&restored_state, &restored_transitions, &versions)?;
1128 *state = restored_state;
1129 *transitions = restored_transitions;
1130 Ok(durability)
1131 }
1132
1133 pub fn transition_count(&self) -> usize {
1134 self.transitions.lock().len()
1135 }
1136
1137 pub fn transitions(&self) -> Vec<StateTransition> {
1138 self.transitions.lock().clone()
1139 }
1140
1141 pub fn transitions_since(&self, index: usize) -> Vec<StateTransition> {
1142 let transitions = self.transitions.lock();
1143 let start = index.min(transitions.len());
1144 transitions[start..].to_vec()
1145 }
1146
1147 pub fn keys(&self) -> Vec<String> {
1148 self.state.lock().keys().cloned().collect()
1149 }
1150
1151 pub fn replace_all(&self, snapshot: HashMap<String, Value>) {
1156 let mut state = self.require_reconciled_state_for_mutation();
1157 *state = snapshot;
1158 self.transitions.lock().clear();
1159 }
1160
1161 pub fn scoped<'a>(&'a self, tenant: Option<&'a str>) -> ScopedStateView<'a> {
1174 ScopedStateView {
1175 store: self,
1176 tenant,
1177 }
1178 }
1179}
1180
1181fn replay_transition(
1188 state: &mut HashMap<String, Value>,
1189 transitions: &mut Vec<StateTransition>,
1190 versions: &mut HashMap<String, u64>,
1191 now: DateTime<Utc>,
1192 t: StateTransition,
1193) {
1194 if let (Some(ttl), Some(value)) = (t.ttl_secs, &t.new_value) {
1195 if now.signed_duration_since(t.timestamp) > Duration::seconds(ttl as i64) {
1196 state.remove(&t.key);
1197 } else {
1198 state.insert(t.key.clone(), value.clone());
1199 }
1200 } else if let Some(value) = &t.new_value {
1201 state.insert(t.key.clone(), value.clone());
1202 } else {
1203 state.remove(&t.key);
1204 }
1205 let entry = versions.entry(t.key.clone()).or_insert(0);
1206 let restored = t.version.unwrap_or(*entry + 1);
1207 *entry = (*entry).max(restored);
1208 transitions.push(t);
1209}
1210
1211fn key_in_tenant_namespace(key: &str, tenant: Option<&str>) -> bool {
1219 match tenant {
1220 Some(t) if !t.is_empty() => key.starts_with(&format!("tenant:{t}:")),
1221 _ => !key.starts_with("tenant:"),
1222 }
1223}
1224
1225pub struct ScopedStateView<'a> {
1256 store: &'a StateStore,
1257 tenant: Option<&'a str>,
1258}
1259
1260impl<'a> ScopedStateView<'a> {
1261 fn full_key(&self, key: &str) -> String {
1262 match self.tenant {
1263 Some(t) if !t.is_empty() => format!("tenant:{t}:{key}"),
1264 _ => key.to_string(),
1265 }
1266 }
1267
1268 fn strip_prefix<'k>(&self, full: &'k str) -> Option<&'k str> {
1269 match self.tenant {
1270 Some(t) if !t.is_empty() => {
1271 let prefix = format!("tenant:{t}:");
1272 full.strip_prefix(&prefix)
1273 }
1274 _ => Some(full),
1275 }
1276 }
1277
1278 pub fn get(&self, key: &str) -> Option<Value> {
1279 self.store.get(&self.full_key(key))
1280 }
1281
1282 pub fn get_or(&self, key: &str, default: Value) -> Value {
1283 self.store.get_or(&self.full_key(key), default)
1284 }
1285
1286 pub fn snapshot(&self) -> HashMap<String, Value> {
1290 self.store.snapshot_scoped(self.tenant)
1291 }
1292
1293 pub fn snapshot_stripped(&self) -> HashMap<String, Value> {
1302 self.store
1303 .snapshot_scoped(self.tenant)
1304 .into_iter()
1305 .filter_map(|(key, value)| {
1306 self.strip_prefix(&key)
1307 .map(|stripped| (stripped.to_string(), value))
1308 })
1309 .collect()
1310 }
1311
1312 pub fn restore(
1315 &self,
1316 snapshot: HashMap<String, Value>,
1317 transition_count: usize,
1318 ) -> std::io::Result<RestoreDurability> {
1319 self.store
1320 .restore_scoped(self.tenant, snapshot, transition_count)
1321 }
1322
1323 pub fn exists(&self, key: &str) -> bool {
1324 self.store.exists(&self.full_key(key))
1325 }
1326
1327 pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
1328 self.store.set(&self.full_key(key), value, action_id)
1329 }
1330
1331 pub fn set_with_ttl(
1332 &self,
1333 key: &str,
1334 value: Value,
1335 action_id: &str,
1336 ttl_secs: u64,
1337 ) -> StateTransition {
1338 self.store
1339 .set_with_ttl(&self.full_key(key), value, action_id, ttl_secs)
1340 }
1341
1342 pub fn set_batch(
1346 &self,
1347 entries: Vec<(String, Value)>,
1348 action_id: &str,
1349 ) -> Vec<StateTransition> {
1350 let prefixed = entries
1351 .into_iter()
1352 .map(|(key, value)| (self.full_key(&key), value))
1353 .collect();
1354 self.store.set_batch(prefixed, action_id)
1355 }
1356
1357 pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
1358 self.store.delete(&self.full_key(key), action_id)
1359 }
1360
1361 pub fn keys(&self) -> Vec<String> {
1367 self.store
1368 .keys()
1369 .into_iter()
1370 .filter_map(|k| {
1371 if self.tenant.map(|t| !t.is_empty()).unwrap_or(false) {
1372 self.strip_prefix(&k).map(str::to_string)
1373 } else if k.starts_with("tenant:") {
1374 None
1375 } else {
1376 Some(k)
1377 }
1378 })
1379 .collect()
1380 }
1381}
1382
1383impl Default for StateStore {
1384 fn default() -> Self {
1385 Self::new()
1386 }
1387}
1388
1389impl car_ir::precondition::StateView for StateStore {
1390 fn get_value(&self, key: &str) -> Option<Value> {
1391 self.get(key)
1392 }
1393 fn key_exists(&self, key: &str) -> bool {
1394 self.exists(key)
1395 }
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400 use super::*;
1401 use serde_json::json;
1402
1403 #[test]
1404 fn set_and_get() {
1405 let store = StateStore::new();
1406 store.set("x", Value::from(42), "test");
1407 assert_eq!(store.get("x"), Some(Value::from(42)));
1408 }
1409
1410 #[test]
1411 fn exists() {
1412 let store = StateStore::new();
1413 assert!(!store.exists("x"));
1414 store.set("x", Value::from(1), "test");
1415 assert!(store.exists("x"));
1416 }
1417
1418 #[test]
1419 fn delete() {
1420 let store = StateStore::new();
1421 store.set("x", Value::from(1), "test");
1422 let t = store.delete("x", "test");
1423 assert!(t.is_some());
1424 assert!(!store.exists("x"));
1425 }
1426
1427 #[test]
1428 fn delete_nonexistent() {
1429 let store = StateStore::new();
1430 assert!(store.delete("x", "test").is_none());
1431 }
1432
1433 #[test]
1434 fn snapshot_and_restore() {
1435 let store = StateStore::new();
1436 store.set("x", Value::from(1), "a");
1437 let snap = store.snapshot();
1438 let tc = store.transition_count();
1439
1440 store.set("y", Value::from(2), "b");
1441 assert!(store.exists("y"));
1442
1443 store.restore(snap, tc).unwrap();
1444 assert!(store.exists("x"));
1445 assert!(!store.exists("y"));
1446 assert_eq!(store.transition_count(), 1);
1447 }
1448
1449 #[test]
1450 fn transitions_logged() {
1451 let store = StateStore::new();
1452 store.set("a", Value::from(1), "act1");
1453 store.set("b", Value::from(2), "act2");
1454
1455 let transitions = store.transitions();
1456 assert_eq!(transitions.len(), 2);
1457 assert_eq!(transitions[0].key, "a");
1458 assert_eq!(transitions[1].key, "b");
1459 }
1460
1461 #[test]
1462 fn transitions_since() {
1463 let store = StateStore::new();
1464 store.set("a", Value::from(1), "act1");
1465 let idx = store.transition_count();
1466 store.set("b", Value::from(2), "act2");
1467
1468 let since = store.transitions_since(idx);
1469 assert_eq!(since.len(), 1);
1470 assert_eq!(since[0].key, "b");
1471 }
1472
1473 #[test]
1474 fn transition_records_old_value() {
1475 let store = StateStore::new();
1476 store.set("x", Value::from(1), "first");
1477 store.set("x", Value::from(2), "second");
1478
1479 let transitions = store.transitions();
1480 assert_eq!(transitions[1].old_value, Some(Value::from(1)));
1481 assert_eq!(transitions[1].new_value, Some(Value::from(2)));
1482 }
1483
1484 #[test]
1485 fn keys() {
1486 let store = StateStore::new();
1487 store.set("a", Value::from(1), "t");
1488 store.set("b", Value::from(2), "t");
1489 let mut keys = store.keys();
1490 keys.sort();
1491 assert_eq!(keys, vec!["a", "b"]);
1492 }
1493
1494 #[test]
1495 fn transitions_since_after_restore_does_not_panic() {
1496 let store = StateStore::new();
1497 store.set("a", serde_json::json!(1), "test");
1498 store.set("b", serde_json::json!(2), "test");
1499 let count_before = store.transition_count(); store.restore(HashMap::new(), 0).unwrap();
1503
1504 let result = store.transitions_since(count_before);
1506 assert!(result.is_empty());
1507 }
1508
1509 #[test]
1510 fn transitions_since_normal_usage() {
1511 let store = StateStore::new();
1512 store.set("a", serde_json::json!(1), "test");
1513 let mark = store.transition_count();
1514 store.set("b", serde_json::json!(2), "test");
1515 let since = store.transitions_since(mark);
1516 assert_eq!(since.len(), 1);
1517 assert_eq!(since[0].key, "b");
1518 }
1519
1520 #[test]
1521 fn replace_all_swaps_state_without_transitions() {
1522 let store = StateStore::new();
1523 store.set("old_key", serde_json::json!("old"), "setup");
1524
1525 let mut new_state = HashMap::new();
1526 new_state.insert("new_key".to_string(), serde_json::json!("new"));
1527 store.replace_all(new_state);
1528
1529 assert_eq!(store.get("new_key"), Some(serde_json::json!("new")));
1530 assert_eq!(store.get("old_key"), None);
1531 assert_eq!(store.transition_count(), 0);
1533 }
1534
1535 #[test]
1536 fn durable_store_survives_reopen() {
1537 let dir = tempfile::tempdir().unwrap();
1538 let path = dir.path().join("state.jsonl");
1539 {
1540 let store = StateStore::durable(&path).unwrap();
1541 store.set("agent", serde_json::json!("planner"), "boot");
1542 store.set("turns", serde_json::json!(42), "tick");
1543 store.sync().unwrap();
1544 }
1545 let store = StateStore::durable(&path).unwrap();
1546 assert_eq!(store.get("agent"), Some(serde_json::json!("planner")));
1547 assert_eq!(store.get("turns"), Some(serde_json::json!(42)));
1548 }
1549
1550 #[test]
1551 fn durable_store_replays_deletes() {
1552 let dir = tempfile::tempdir().unwrap();
1553 let path = dir.path().join("state.jsonl");
1554 {
1555 let store = StateStore::durable(&path).unwrap();
1556 store.set("transient", serde_json::json!("x"), "boot");
1557 store.delete("transient", "rm");
1558 store.sync().unwrap();
1559 }
1560 let store = StateStore::durable(&path).unwrap();
1561 assert!(!store.exists("transient"));
1562 }
1563
1564 #[test]
1565 fn durable_restore_survives_reopen() {
1566 let dir = tempfile::tempdir().unwrap();
1567 let path = dir.path().join("state.jsonl");
1568 {
1569 let store = StateStore::durable(&path).unwrap();
1570 store.set("existing", serde_json::json!("old"), "setup");
1571 let snapshot = store.snapshot();
1572 let transition_count = store.transition_count();
1573
1574 store.set("existing", serde_json::json!("new"), "candidate");
1575 store.set("created", serde_json::json!(true), "candidate");
1576 store.restore(snapshot, transition_count).unwrap();
1577 store.sync().unwrap();
1578
1579 assert_eq!(store.get("existing"), Some(serde_json::json!("old")));
1580 assert!(!store.exists("created"));
1581 }
1582
1583 let reopened = StateStore::durable(&path).unwrap();
1584 assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1585 assert!(
1586 !reopened.exists("created"),
1587 "a full rollback must not resurrect candidate state after reopen"
1588 );
1589 }
1590
1591 #[test]
1592 fn abandoned_replacement_temps_do_not_block_restore_compaction_or_reopen() {
1593 let dir = tempfile::tempdir().unwrap();
1594 let path = dir.path().join("state.jsonl");
1595 {
1596 let store = StateStore::durable(&path).unwrap();
1597 store.set("existing", serde_json::json!("old"), "setup");
1598 store.sync().unwrap();
1599 }
1600
1601 let abandoned_transition = StateTransition {
1602 key: "intruder".to_string(),
1603 old_value: None,
1604 new_value: Some(serde_json::json!("must-not-replay")),
1605 action_id: "abandoned-temp".to_string(),
1606 timestamp: Utc::now(),
1607 ttl_secs: None,
1608 version: Some(99),
1609 };
1610 let abandoned_contents = format!(
1611 "{}\n",
1612 serde_json::to_string(&abandoned_transition).unwrap()
1613 );
1614 let abandoned_temps = [
1615 path.with_extension("jsonl.restore.tmp"),
1616 dir.path().join(".state.jsonl.restore.123.456.0.tmp"),
1617 dir.path().join(".state.jsonl.restore.789.012.1.tmp"),
1618 ];
1619 for temp in &abandoned_temps {
1620 std::fs::write(temp, &abandoned_contents).unwrap();
1621 }
1622
1623 let store = StateStore::durable(&path).unwrap();
1624 assert!(!store.exists("intruder"));
1625 let snapshot = store.snapshot();
1626 let transition_count = store.transition_count();
1627 store.set("existing", serde_json::json!("new"), "candidate");
1628 store.set("candidate_only", serde_json::json!(true), "candidate");
1629 assert_eq!(
1630 store.restore(snapshot, transition_count).unwrap(),
1631 RestoreDurability::Durable
1632 );
1633 store.set_with_ttl("expired", serde_json::json!(true), "ttl", 0);
1634 store.sync().unwrap();
1635 assert_eq!(
1636 store
1637 .reap_expired(Utc::now() + Duration::seconds(1))
1638 .unwrap(),
1639 vec!["expired".to_string()]
1640 );
1641 store.sync().unwrap();
1642 drop(store);
1643
1644 let reopened = StateStore::durable(&path).unwrap();
1645 assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1646 assert!(!reopened.exists("candidate_only"));
1647 assert!(!reopened.exists("expired"));
1648 assert!(!reopened.exists("intruder"));
1649 for temp in &abandoned_temps {
1650 assert!(temp.exists(), "abandoned sibling temp should be ignored");
1651 }
1652 }
1653
1654 #[test]
1655 fn durable_restore_parent_sync_failure_keeps_live_and_reopened_state_coherent() {
1656 let dir = tempfile::tempdir().unwrap();
1657 let path = dir.path().join("state.jsonl");
1658 let store = StateStore::durable(&path).unwrap();
1659 store.set("existing", serde_json::json!("old"), "setup");
1660 let snapshot = store.snapshot();
1661 let transition_count = store.transition_count();
1662 store.set("existing", serde_json::json!("new"), "candidate");
1663
1664 #[cfg(unix)]
1665 let original_mode = {
1666 use std::os::unix::fs::PermissionsExt;
1667 std::fs::metadata(&path).unwrap().permissions().mode()
1668 };
1669 let durability = store
1670 .restore_with_parent_sync(snapshot, transition_count, |_| {
1671 Err(std::io::Error::other(
1672 "injected parent directory sync failure",
1673 ))
1674 })
1675 .unwrap();
1676 assert!(matches!(
1677 durability,
1678 RestoreDurability::DurabilityUnknown { ref error }
1679 if error.contains("parent directory sync failure")
1680 ));
1681 assert_eq!(
1682 store.get("existing"),
1683 Some(serde_json::json!("old")),
1684 "once rename publishes the rollback journal, memory must adopt the same state"
1685 );
1686 assert!(
1687 !path.with_extension("jsonl.restore.tmp").exists(),
1688 "replacement temp must be cleaned after atomic publication"
1689 );
1690 #[cfg(unix)]
1691 {
1692 use std::os::unix::fs::PermissionsExt;
1693 assert_eq!(
1694 std::fs::metadata(&path).unwrap().permissions().mode(),
1695 original_mode,
1696 "replacement must preserve journal permissions"
1697 );
1698 }
1699 store.set("after_unknown", serde_json::json!("safe"), "later");
1700 store.sync().unwrap();
1701 drop(store);
1702
1703 let reopened = StateStore::durable(&path).unwrap();
1704 assert_eq!(
1705 reopened.get("existing"),
1706 Some(serde_json::json!("old")),
1707 "the visible journal and live rollback state must agree"
1708 );
1709 assert_eq!(
1710 reopened.get("after_unknown"),
1711 Some(serde_json::json!("safe")),
1712 "a later write must first reconcile the pending parent sync"
1713 );
1714 }
1715
1716 #[test]
1717 fn durability_unknown_store_refuses_write_until_parent_sync_reconciles() {
1718 let dir = tempfile::tempdir().unwrap();
1719 let path = dir.path().join("state.jsonl");
1720 let failures = RestoreFailureInjector::default();
1721 let store =
1722 StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
1723 .unwrap();
1724 store.set("existing", serde_json::json!("old"), "setup");
1725 let snapshot = store.snapshot();
1726 let transition_count = store.transition_count();
1727 store.set("existing", serde_json::json!("new"), "candidate");
1728 failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1729 failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1730
1731 assert!(matches!(
1732 store.restore(snapshot, transition_count).unwrap(),
1733 RestoreDurability::DurabilityUnknown { .. }
1734 ));
1735 let refused = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1736 store.set("must_not_write", serde_json::json!(true), "later");
1737 }));
1738 assert!(refused.is_err());
1739 assert!(!store.exists("must_not_write"));
1740 drop(store);
1741
1742 let reopened = StateStore::durable(&path).unwrap();
1743 assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1744 assert!(!reopened.exists("must_not_write"));
1745 }
1746
1747 #[test]
1748 fn mutation_waiting_for_state_reconciles_restore_durability_unknown() {
1749 let dir = tempfile::tempdir().unwrap();
1750 let path = dir.path().join("state.jsonl");
1751 let failures = RestoreFailureInjector::default();
1752 let mut store =
1753 StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
1754 .unwrap();
1755 store.set("existing", serde_json::json!("old"), "setup");
1756 let snapshot = store.snapshot();
1757 let transition_count = store.transition_count();
1758 store.set("existing", serde_json::json!("candidate"), "candidate");
1759
1760 let barrier = Arc::new(MutationRaceBarrier::new());
1761 store.mutation_before_state_lock = Some(barrier.clone());
1762 let store = Arc::new(store);
1763 failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1764 failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1765
1766 let mutation_store = store.clone();
1767 let mutation = std::thread::spawn(move || {
1768 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1769 mutation_store.set("must_not_write", serde_json::json!(true), "racing");
1770 }))
1771 });
1772
1773 barrier.reached.wait();
1774 assert!(matches!(
1775 store.restore(snapshot, transition_count).unwrap(),
1776 RestoreDurability::DurabilityUnknown { .. }
1777 ));
1778 barrier.release.wait();
1779
1780 assert!(
1781 mutation.join().unwrap().is_err(),
1782 "a mutation admitted before restore must reconcile the journal after acquiring state"
1783 );
1784 assert!(!store.exists("must_not_write"));
1785 drop(store);
1786
1787 let reopened = StateStore::durable(&path).unwrap();
1788 assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1789 assert!(!reopened.exists("must_not_write"));
1790 }
1791
1792 #[test]
1793 fn durable_scoped_restore_survives_reopen_without_clobbering_other_tenants() {
1794 let dir = tempfile::tempdir().unwrap();
1795 let path = dir.path().join("state.jsonl");
1796 {
1797 let store = StateStore::durable(&path).unwrap();
1798 store
1799 .scoped(Some("acme"))
1800 .set("existing", serde_json::json!("old"), "setup");
1801 store
1802 .scoped(Some("globex"))
1803 .set("survivor", serde_json::json!("before"), "setup");
1804 let snapshot = store.snapshot_scoped(Some("acme"));
1805 let transition_count = store.transition_count();
1806
1807 store
1808 .scoped(Some("acme"))
1809 .set("existing", serde_json::json!("new"), "candidate");
1810 store
1811 .scoped(Some("acme"))
1812 .set("created", serde_json::json!(true), "candidate");
1813 store.scoped(Some("globex")).set(
1814 "survivor",
1815 serde_json::json!("after"),
1816 "other-proposal",
1817 );
1818 store
1819 .restore_scoped(Some("acme"), snapshot, transition_count)
1820 .unwrap();
1821 store.sync().unwrap();
1822
1823 assert_eq!(
1824 store.scoped(Some("acme")).get("existing"),
1825 Some(serde_json::json!("old"))
1826 );
1827 assert!(!store.scoped(Some("acme")).exists("created"));
1828 assert_eq!(
1829 store.scoped(Some("globex")).get("survivor"),
1830 Some(serde_json::json!("after"))
1831 );
1832 }
1833
1834 let reopened = StateStore::durable(&path).unwrap();
1835 assert_eq!(
1836 reopened.scoped(Some("acme")).get("existing"),
1837 Some(serde_json::json!("old"))
1838 );
1839 assert!(!reopened.scoped(Some("acme")).exists("created"));
1840 assert_eq!(
1841 reopened.scoped(Some("globex")).get("survivor"),
1842 Some(serde_json::json!("after")),
1843 "scoped rollback must preserve unrelated tenant state after reopen"
1844 );
1845 }
1846
1847 #[test]
1848 fn ttl_reap_drops_expired_and_keeps_fresh() {
1849 let store = StateStore::new();
1850 store.set_with_ttl("short", serde_json::json!(1), "set", 0);
1851 store.set_with_ttl("long", serde_json::json!(2), "set", 3600);
1852 store.set("forever", serde_json::json!(3), "set");
1853 let reaped = store
1855 .reap_expired(Utc::now() + Duration::seconds(10))
1856 .unwrap();
1857 assert_eq!(reaped, vec!["short".to_string()]);
1858 assert!(!store.exists("short"));
1859 assert_eq!(store.get("long"), Some(serde_json::json!(2)));
1860 assert_eq!(store.get("forever"), Some(serde_json::json!(3)));
1861 }
1862
1863 #[test]
1864 fn scoped_reap_isolates_tenants() {
1865 let store = StateStore::new();
1869 store
1870 .scoped(Some("a"))
1871 .set_with_ttl("k", serde_json::json!(1), "set", 0);
1872 store
1873 .scoped(Some("b"))
1874 .set_with_ttl("k", serde_json::json!(2), "set", 0);
1875 store.set_with_ttl("global", serde_json::json!(3), "set", 0);
1876
1877 let future = Utc::now() + Duration::seconds(10);
1878 let reaped = store.reap_expired_scoped(future, Some("a")).unwrap();
1879 assert_eq!(reaped, vec!["tenant:a:k".to_string()]);
1880 assert!(!store.scoped(Some("a")).exists("k"));
1882 assert!(store.scoped(Some("b")).exists("k"));
1883 assert!(store.exists("global"));
1884
1885 let reaped = store.reap_expired_scoped(future, None).unwrap();
1887 assert_eq!(reaped, vec!["global".to_string()]);
1888 assert!(store.scoped(Some("b")).exists("k"));
1889 }
1890
1891 #[test]
1892 fn durable_ttl_compacts_journal() {
1893 let dir = tempfile::tempdir().unwrap();
1894 let path = dir.path().join("state.jsonl");
1895 {
1896 let store = StateStore::durable(&path).unwrap();
1897 for i in 0..50 {
1898 store.set_with_ttl(&format!("k{i}"), serde_json::json!(i), "set", 0);
1899 }
1900 store.set("survivor", serde_json::json!("kept"), "set");
1901 store.sync().unwrap();
1902 let pre = std::fs::metadata(&path).unwrap().len();
1903 let reaped = store
1905 .reap_expired(Utc::now() + Duration::seconds(1))
1906 .unwrap();
1907 assert_eq!(reaped.len(), 50);
1908 store.sync().unwrap();
1909 let post = std::fs::metadata(&path).unwrap().len();
1910 assert!(
1913 post < pre,
1914 "post={post} pre={pre} — compaction did not shrink"
1915 );
1916 }
1917 let store = StateStore::durable(&path).unwrap();
1919 assert!(!store.exists("k0"));
1920 assert!(!store.exists("k49"));
1921 assert_eq!(store.get("survivor"), Some(serde_json::json!("kept")));
1922 assert_eq!(store.version("survivor"), Some(1));
1926 }
1927
1928 #[test]
1929 fn version_is_monotonic_and_survives_compaction() {
1930 let dir = tempfile::tempdir().unwrap();
1931 let path = dir.path().join("v.jsonl");
1932 {
1933 let store = StateStore::durable(&path).unwrap();
1934 for i in 0..3 {
1935 store.set("cfg", serde_json::json!(i), "set");
1936 }
1937 assert_eq!(store.version("cfg"), Some(3));
1938 store.set_with_ttl("tmp", serde_json::json!(1), "set", 0);
1940 store.sync().unwrap();
1941 store
1942 .reap_expired(Utc::now() + Duration::seconds(1))
1943 .unwrap();
1944 store.sync().unwrap();
1945 }
1946 let store = StateStore::durable(&path).unwrap();
1949 assert_eq!(store.version("cfg"), Some(3));
1950 }
1951
1952 #[test]
1953 fn reap_bumps_version() {
1954 let store = StateStore::new();
1955 store.set("k", serde_json::json!("v"), "set");
1956 assert_eq!(store.version("k"), Some(1));
1957 store.set_with_ttl("k", serde_json::json!("v2"), "set", 0);
1958 assert_eq!(store.version("k"), Some(2));
1959 store
1960 .reap_expired(Utc::now() + Duration::seconds(1))
1961 .unwrap();
1962 assert_eq!(store.version("k"), Some(3));
1964 }
1965
1966 #[test]
1967 fn ttl_then_rewrite_without_ttl_does_not_reap() {
1968 let store = StateStore::new();
1969 store.set_with_ttl("k", serde_json::json!("a"), "first", 0);
1970 store.set("k", serde_json::json!("b"), "second"); let reaped = store
1972 .reap_expired(Utc::now() + Duration::seconds(10))
1973 .unwrap();
1974 assert!(reaped.is_empty());
1975 assert_eq!(store.get("k"), Some(serde_json::json!("b")));
1976 }
1977
1978 #[test]
1979 fn invalid_utf8_journal_line_is_skipped_never_applied_mangled() {
1980 let dir = tempfile::tempdir().unwrap();
1981 let path = dir.path().join("state.jsonl");
1982 let mut journal = Vec::new();
1988 journal.extend_from_slice(
1989 b"{\"key\":\"good\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
1990 );
1991 journal.extend_from_slice(
1992 b"{\"key\":\"corrupt\",\"old_value\":null,\"new_value\":\"va\xFFlue\",\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
1993 );
1994 journal.extend_from_slice(
1995 b"{\"batch\":[{\"key\":\"corrupt_batch\",\"old_value\":null,\"new_value\":\"a\xFFb\",\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}]}\n",
1996 );
1997 std::fs::write(&path, &journal).unwrap();
1998
1999 let store = StateStore::durable(&path).unwrap();
2000 assert_eq!(store.get("good"), Some(json!(1)));
2001 assert!(
2002 !store.exists("corrupt"),
2003 "an invalid-UTF-8 record must be skipped, not applied with U+FFFD"
2004 );
2005 assert!(!store.exists("corrupt_batch"));
2006 }
2007
2008 #[test]
2009 fn malformed_journal_line_is_skipped_not_fatal() {
2010 let dir = tempfile::tempdir().unwrap();
2011 let path = dir.path().join("state.jsonl");
2012 {
2014 std::fs::write(
2015 &path,
2016 "{\"key\":\"a\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n\
2017 not-json\n\
2018 {\"key\":\"b\",\"old_value\":null,\"new_value\":2,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
2019 )
2020 .unwrap();
2021 }
2022 let store = StateStore::durable(&path).unwrap();
2023 assert_eq!(store.get("a"), Some(serde_json::json!(1)));
2024 assert_eq!(store.get("b"), Some(serde_json::json!(2)));
2025 }
2026
2027 #[test]
2030 fn scoped_view_writes_isolate_between_tenants() {
2031 let store = StateStore::new();
2032 store.scoped(Some("acme")).set("config", json!("A"), "act");
2033 store
2034 .scoped(Some("globex"))
2035 .set("config", json!("G"), "act");
2036
2037 assert_eq!(store.scoped(Some("acme")).get("config"), Some(json!("A")));
2039 assert_eq!(store.scoped(Some("globex")).get("config"), Some(json!("G")));
2040 }
2041
2042 #[test]
2043 fn scoped_view_isolates_existence_check() {
2044 let store = StateStore::new();
2045 store.scoped(Some("acme")).set("k", json!(1), "act");
2046 assert!(store.scoped(Some("acme")).exists("k"));
2047 assert!(!store.scoped(Some("globex")).exists("k"));
2048 }
2049
2050 #[test]
2051 fn scoped_view_keys_filters_to_tenant() {
2052 let store = StateStore::new();
2053 store.scoped(Some("acme")).set("a", json!(1), "act");
2054 store.scoped(Some("acme")).set("b", json!(2), "act");
2055 store.scoped(Some("globex")).set("g", json!(9), "act");
2056 store.set("unscoped", json!(0), "act");
2057
2058 let mut acme_keys = store.scoped(Some("acme")).keys();
2059 acme_keys.sort();
2060 assert_eq!(acme_keys, vec!["a", "b"]);
2061
2062 let globex_keys = store.scoped(Some("globex")).keys();
2063 assert_eq!(globex_keys, vec!["g"]);
2064 }
2065
2066 #[test]
2067 fn unscoped_view_skips_tenant_prefixed_keys() {
2068 let store = StateStore::new();
2074 store.set("legacy", json!("ok"), "act");
2075 store.scoped(Some("acme")).set("hidden", json!(42), "act");
2076
2077 let unscoped = store.scoped(None).keys();
2078 assert_eq!(unscoped, vec!["legacy"]);
2079 assert!(store.scoped(None).get("hidden").is_none());
2080 }
2081
2082 #[test]
2083 fn scoped_restore_does_not_clobber_other_tenants() {
2084 let store = StateStore::new();
2087 store.scoped(Some("acme")).set("k", json!("acme-v1"), "a");
2088 store
2089 .scoped(Some("globex"))
2090 .set("k", json!("globex-v1"), "a");
2091 store.set("global", json!("g-v1"), "a");
2092
2093 let acme_snap = store.scoped(Some("acme")).snapshot();
2095 store.scoped(Some("acme")).set("k", json!("acme-v2"), "a");
2096 store
2097 .scoped(Some("globex"))
2098 .set("k", json!("globex-v2"), "a");
2099 store.set("global", json!("g-v2"), "a");
2100
2101 store.scoped(Some("acme")).restore(acme_snap, 0).unwrap();
2103 assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme-v1")));
2104 assert_eq!(
2105 store.scoped(Some("globex")).get("k"),
2106 Some(json!("globex-v2"))
2107 );
2108 assert_eq!(store.get("global"), Some(json!("g-v2")));
2109 }
2110
2111 #[test]
2112 fn snapshot_scoped_captures_only_its_namespace() {
2113 let store = StateStore::new();
2114 store.set("global", json!(1), "a");
2115 store.scoped(Some("acme")).set("x", json!(2), "a");
2116 store.scoped(Some("globex")).set("y", json!(3), "a");
2117
2118 let acme = store.snapshot_scoped(Some("acme"));
2119 assert_eq!(acme.len(), 1);
2120 assert!(acme.contains_key("tenant:acme:x"));
2121
2122 let global = store.snapshot_scoped(None);
2123 assert_eq!(global.len(), 1);
2124 assert!(global.contains_key("global"));
2125 }
2126
2127 #[test]
2128 fn unscoped_restore_leaves_tenant_keys_intact() {
2129 let store = StateStore::new();
2131 store.set("g", json!("v1"), "a");
2132 store.scoped(Some("acme")).set("k", json!("acme"), "a");
2133
2134 let snap = store.snapshot_scoped(None);
2135 store.set("g", json!("v2"), "a");
2136 store.restore_scoped(None, snap, 0).unwrap();
2137
2138 assert_eq!(store.get("g"), Some(json!("v1")));
2139 assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme")));
2141 }
2142
2143 #[test]
2144 fn scoped_restore_preserves_other_tenants_transitions() {
2145 let store = StateStore::new();
2150 store.scoped(Some("acme")).set("k", json!("a1"), "act");
2151
2152 let snap = store.snapshot_scoped(Some("acme"));
2154 let count = store.transition_count();
2155
2156 store.scoped(Some("acme")).set("k", json!("a2"), "act");
2159 store.scoped(Some("globex")).set("g", json!("gv"), "act");
2160
2161 store.restore_scoped(Some("acme"), snap, count).unwrap();
2162
2163 let tail = store.transitions_since(count);
2165 assert_eq!(
2166 tail.len(),
2167 1,
2168 "exactly globex's transition survives: {tail:?}"
2169 );
2170 assert_eq!(tail[0].key, "tenant:globex:g");
2171 assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("a1")));
2173 assert_eq!(store.scoped(Some("globex")).get("g"), Some(json!("gv")));
2174 }
2175
2176 #[test]
2177 fn scoped_view_delete_doesnt_touch_other_tenants() {
2178 let store = StateStore::new();
2179 store.scoped(Some("acme")).set("shared", json!(1), "act");
2180 store.scoped(Some("globex")).set("shared", json!(2), "act");
2181
2182 store.scoped(Some("acme")).delete("shared", "act");
2183 assert!(!store.scoped(Some("acme")).exists("shared"));
2184 assert!(store.scoped(Some("globex")).exists("shared"));
2185 }
2186
2187 #[test]
2188 fn empty_tenant_string_treated_as_unscoped() {
2189 let store = StateStore::new();
2193 store.scoped(Some("")).set("k", json!(1), "act");
2194 assert_eq!(store.get("k"), Some(json!(1)));
2195 assert_eq!(store.scoped(None).get("k"), Some(json!(1)));
2196 }
2197
2198 #[test]
2201 fn set_batch_applies_all_entries_with_versions_and_transitions() {
2202 let store = StateStore::new();
2203 store.set("a", json!("old"), "setup");
2204
2205 let batch = store.set_batch(
2206 vec![("a".to_string(), json!("new")), ("b".to_string(), json!(2))],
2207 "callback-action",
2208 );
2209
2210 assert_eq!(store.get("a"), Some(json!("new")));
2211 assert_eq!(store.get("b"), Some(json!(2)));
2212 assert_eq!(store.version("a"), Some(2));
2213 assert_eq!(store.version("b"), Some(1));
2214 assert_eq!(batch.len(), 2);
2217 assert_eq!(batch[0].old_value, Some(json!("old")));
2218 assert_eq!(batch[1].old_value, None);
2219 assert!(batch.iter().all(|t| t.action_id == "callback-action"));
2220 assert_eq!(store.transition_count(), 3);
2222 }
2223
2224 #[test]
2225 fn empty_batch_is_a_noop() {
2226 let store = StateStore::new();
2227 assert!(store.set_batch(Vec::new(), "noop").is_empty());
2228 assert_eq!(store.transition_count(), 0);
2229 }
2230
2231 #[test]
2232 fn durable_two_and_three_key_batches_survive_reopen_with_versions() {
2233 let dir = tempfile::tempdir().unwrap();
2234 let path = dir.path().join("state.jsonl");
2235 {
2236 let store = StateStore::durable(&path).unwrap();
2237 store.set_batch(
2238 vec![("x".to_string(), json!(1)), ("y".to_string(), json!(2))],
2239 "two-key",
2240 );
2241 store.set_batch(
2242 vec![
2243 ("x".to_string(), json!(10)),
2244 ("y".to_string(), json!(20)),
2245 ("z".to_string(), json!(30)),
2246 ],
2247 "three-key",
2248 );
2249 store.sync().unwrap();
2250 }
2251 let reopened = StateStore::durable(&path).unwrap();
2252 assert_eq!(reopened.get("x"), Some(json!(10)));
2253 assert_eq!(reopened.get("y"), Some(json!(20)));
2254 assert_eq!(reopened.get("z"), Some(json!(30)));
2255 assert_eq!(reopened.version("x"), Some(2));
2256 assert_eq!(reopened.version("y"), Some(2));
2257 assert_eq!(reopened.version("z"), Some(1));
2258 }
2259
2260 #[test]
2261 fn multi_key_batch_is_one_journal_line_and_single_key_keeps_legacy_shape() {
2262 let dir = tempfile::tempdir().unwrap();
2263 let path = dir.path().join("state.jsonl");
2264 let store = StateStore::durable(&path).unwrap();
2265
2266 store.set_batch(
2267 vec![
2268 ("k1".to_string(), json!(1)),
2269 ("k2".to_string(), json!(2)),
2270 ("k3".to_string(), json!(3)),
2271 ],
2272 "multi",
2273 );
2274 store.set_batch(vec![("solo".to_string(), json!(4))], "single");
2275 store.sync().unwrap();
2276
2277 let journal = std::fs::read_to_string(&path).unwrap();
2278 let lines: Vec<&str> = journal.lines().filter(|l| !l.trim().is_empty()).collect();
2279 assert_eq!(
2280 lines.len(),
2281 2,
2282 "a 3-key batch must be ONE line and a 1-key batch one line: {journal}"
2283 );
2284 let multi: Value = serde_json::from_str(lines[0]).unwrap();
2285 assert_eq!(
2286 multi["batch"].as_array().map(Vec::len),
2287 Some(3),
2288 "the multi-key line is a batch record"
2289 );
2290 let single: StateTransition = serde_json::from_str(lines[1]).unwrap();
2294 assert_eq!(single.key, "solo");
2295 }
2296
2297 fn torn_batch_replays_complete_old_state(n_keys: usize) {
2302 let dir = tempfile::tempdir().unwrap();
2303 let path = dir.path().join("state.jsonl");
2304 let torn_keys: Vec<String> = (1..=n_keys).map(|i| format!("t{i}")).collect();
2305 {
2306 let store = StateStore::durable(&path).unwrap();
2307 store.set("base", json!("kept"), "setup");
2308 store.set_batch(
2309 vec![("p".to_string(), json!(1)), ("q".to_string(), json!(2))],
2310 "complete-batch",
2311 );
2312 store.set_batch(
2313 torn_keys
2314 .iter()
2315 .map(|key| (key.clone(), json!(format!("{key}-value"))))
2316 .collect(),
2317 "torn-batch",
2318 );
2319 store.sync().unwrap();
2320 }
2321 let full = std::fs::read_to_string(&path).unwrap();
2322 let last_line_start = full.trim_end().rfind('\n').unwrap() + 1;
2323 let torn_cut = last_line_start + (full.trim_end().len() - last_line_start) / 2;
2324 std::fs::write(&path, &full.as_bytes()[..torn_cut]).unwrap();
2325
2326 let store = StateStore::durable(&path).unwrap();
2327 assert_eq!(store.get("base"), Some(json!("kept")));
2328 assert_eq!(store.get("p"), Some(json!(1)));
2329 assert_eq!(store.get("q"), Some(json!(2)));
2330 for torn_key in &torn_keys {
2331 assert!(
2332 !store.exists(torn_key),
2333 "an interrupted {n_keys}-key batch append must replay as \
2334 complete-old — no key of the torn batch may surface ({torn_key} did)"
2335 );
2336 }
2337 }
2338
2339 #[test]
2340 fn torn_two_key_batch_journal_line_replays_complete_old_state_never_a_prefix() {
2341 torn_batch_replays_complete_old_state(2);
2342 }
2343
2344 #[test]
2345 fn torn_three_key_batch_journal_line_replays_complete_old_state_never_a_prefix() {
2346 torn_batch_replays_complete_old_state(3);
2347 }
2348
2349 #[test]
2350 fn valid_unterminated_tail_is_terminated_so_the_next_append_survives_reopen() {
2351 let dir = tempfile::tempdir().unwrap();
2352 let path = dir.path().join("state.jsonl");
2353 {
2354 let store = StateStore::durable(&path).unwrap();
2355 store.set("base", json!("kept"), "setup");
2356 store.set_batch(
2357 vec![("b1".to_string(), json!(1)), ("b2".to_string(), json!(2))],
2358 "tail-batch",
2359 );
2360 store.sync().unwrap();
2361 }
2362 let full = std::fs::read_to_string(&path).unwrap();
2365 std::fs::write(&path, full.trim_end().as_bytes()).unwrap();
2366
2367 {
2368 let store = StateStore::durable(&path).unwrap();
2369 assert_eq!(store.get("b1"), Some(json!(1)), "the complete tail replays");
2370 store.set("after", json!("survives"), "later");
2371 store.sync().unwrap();
2372 }
2373 let reopened = StateStore::durable(&path).unwrap();
2377 assert_eq!(reopened.get("base"), Some(json!("kept")));
2378 assert_eq!(reopened.get("b1"), Some(json!(1)));
2379 assert_eq!(reopened.get("b2"), Some(json!(2)));
2380 assert_eq!(reopened.get("after"), Some(json!("survives")));
2381 }
2382
2383 #[test]
2384 fn torn_unterminated_tail_is_truncated_so_the_next_append_is_not_merged() {
2385 let dir = tempfile::tempdir().unwrap();
2386 let path = dir.path().join("state.jsonl");
2387 {
2388 let store = StateStore::durable(&path).unwrap();
2389 store.set("base", json!("kept"), "setup");
2390 store.set_batch(
2391 vec![
2392 ("t1".to_string(), json!(1)),
2393 ("t2".to_string(), json!(2)),
2394 ("t3".to_string(), json!(3)),
2395 ],
2396 "torn-batch",
2397 );
2398 store.sync().unwrap();
2399 }
2400 let full = std::fs::read_to_string(&path).unwrap();
2402 let last_line_start = full.trim_end().rfind('\n').unwrap() + 1;
2403 let torn_cut = last_line_start + (full.trim_end().len() - last_line_start) / 2;
2404 std::fs::write(&path, &full.as_bytes()[..torn_cut]).unwrap();
2405
2406 {
2407 let store = StateStore::durable(&path).unwrap();
2408 assert!(
2409 !store.exists("t1"),
2410 "the torn batch replays as complete-old"
2411 );
2412 store.set("after", json!("survives"), "later");
2413 store.sync().unwrap();
2414 }
2415 let reopened = StateStore::durable(&path).unwrap();
2416 assert_eq!(reopened.get("base"), Some(json!("kept")));
2417 assert_eq!(
2418 reopened.get("after"),
2419 Some(json!("survives")),
2420 "the record appended after a torn tail must not merge into it and be lost"
2421 );
2422 for torn_key in ["t1", "t2", "t3"] {
2423 assert!(!reopened.exists(torn_key));
2424 }
2425 }
2426
2427 fn reader_cannot_enter_mid_batch(n_keys: usize) {
2434 let keys: Vec<String> = (1..=n_keys).map(|i| format!("k{i}")).collect();
2435 let mut store = StateStore::new();
2436 for key in &keys {
2437 store.set(key, json!("old"), "setup");
2438 }
2439 let barrier = Arc::new(MutationRaceBarrier::new());
2440 store.batch_mid_apply = Some(barrier.clone());
2441 let store = Arc::new(store);
2442
2443 let batch_store = store.clone();
2444 let batch_keys = keys.clone();
2445 let batch = std::thread::spawn(move || {
2446 batch_store.set_batch(
2447 batch_keys
2448 .into_iter()
2449 .map(|key| (key, json!("new")))
2450 .collect(),
2451 "batch",
2452 );
2453 });
2454
2455 barrier.reached.wait();
2458 assert!(
2459 store.state.try_lock().is_none(),
2460 "the state lock must be held for the whole batch — a reader \
2461 admitted here would observe a partial {n_keys}-key set"
2462 );
2463 let reader_store = store.clone();
2464 let reader_keys = keys.clone();
2465 let reader = std::thread::spawn(move || {
2466 reader_keys
2467 .iter()
2468 .rev()
2469 .map(|key| reader_store.get(key))
2470 .collect::<Vec<_>>()
2471 });
2472 barrier.release.wait();
2473 batch.join().unwrap();
2474
2475 let observed = reader.join().unwrap();
2476 assert!(
2477 observed.iter().all(|value| value == &Some(json!("new"))),
2478 "a reader admitted during a batch must see the complete new \
2479 state, got {observed:?}"
2480 );
2481 }
2482
2483 #[test]
2484 fn reader_cannot_enter_mid_two_key_batch_and_observes_complete_state() {
2485 reader_cannot_enter_mid_batch(2);
2486 }
2487
2488 #[test]
2489 fn reader_cannot_enter_mid_three_key_batch_and_observes_complete_state() {
2490 reader_cannot_enter_mid_batch(3);
2491 }
2492
2493 #[test]
2494 fn snapshot_stripped_is_single_lock_and_strips_tenant_prefixes() {
2495 let store = StateStore::new();
2499 store.set("global", json!(1), "act");
2500 store.scoped(Some("acme")).set("x", json!(2), "act");
2501 store.scoped(Some("globex")).set("y", json!(3), "act");
2502
2503 let acme = store.scoped(Some("acme")).snapshot_stripped();
2504 assert_eq!(acme, [("x".to_string(), json!(2))].into());
2505
2506 let unscoped = store.scoped(None).snapshot_stripped();
2507 assert_eq!(unscoped, [("global".to_string(), json!(1))].into());
2508 }
2509
2510 #[test]
2511 fn set_batch_refused_while_journal_durability_unknown() {
2512 let dir = tempfile::tempdir().unwrap();
2513 let path = dir.path().join("state.jsonl");
2514 let failures = RestoreFailureInjector::default();
2515 let store =
2516 StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
2517 .unwrap();
2518 store.set("existing", json!("old"), "setup");
2519 let snapshot = store.snapshot();
2520 let transition_count = store.transition_count();
2521 store.set("existing", json!("new"), "candidate");
2522 failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
2523 failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
2524
2525 assert!(matches!(
2526 store.restore(snapshot, transition_count).unwrap(),
2527 RestoreDurability::DurabilityUnknown { .. }
2528 ));
2529 let refused = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2530 store.set_batch(
2531 vec![
2532 ("must_not_write".to_string(), json!(true)),
2533 ("nor_this".to_string(), json!(true)),
2534 ],
2535 "later",
2536 );
2537 }));
2538 assert!(
2539 refused.is_err(),
2540 "set_batch must refuse mutation exactly like set while the journal is durability-unknown"
2541 );
2542 assert!(!store.exists("must_not_write"));
2543 assert!(!store.exists("nor_this"));
2544 }
2545
2546 #[test]
2547 fn scoped_set_batch_prefixes_all_keys_into_tenant_namespace() {
2548 let store = StateStore::new();
2549 store.scoped(Some("acme")).set_batch(
2550 vec![("a".to_string(), json!(1)), ("b".to_string(), json!(2))],
2551 "act",
2552 );
2553 assert_eq!(store.scoped(Some("acme")).get("a"), Some(json!(1)));
2554 assert_eq!(store.scoped(Some("acme")).get("b"), Some(json!(2)));
2555 assert!(!store.scoped(Some("globex")).exists("a"));
2556 assert!(!store.scoped(None).exists("a"));
2557 assert_eq!(store.get("tenant:acme:b"), Some(json!(2)));
2558 }
2559
2560 #[test]
2561 fn batch_then_compaction_and_reopen_preserve_state_and_versions() {
2562 let dir = tempfile::tempdir().unwrap();
2563 let path = dir.path().join("state.jsonl");
2564 {
2565 let store = StateStore::durable(&path).unwrap();
2566 store.set_batch(
2567 vec![("a".to_string(), json!(1)), ("b".to_string(), json!(2))],
2568 "batch",
2569 );
2570 store.set_with_ttl("tmp", json!(true), "ttl", 0);
2573 store.sync().unwrap();
2574 store
2575 .reap_expired(Utc::now() + Duration::seconds(1))
2576 .unwrap();
2577 store.sync().unwrap();
2578 }
2579 let reopened = StateStore::durable(&path).unwrap();
2580 assert_eq!(reopened.get("a"), Some(json!(1)));
2581 assert_eq!(reopened.get("b"), Some(json!(2)));
2582 assert_eq!(reopened.version("a"), Some(1));
2583 assert_eq!(reopened.version("b"), Some(1));
2584 assert!(!reopened.exists("tmp"));
2585 }
2586}