1use std::collections::{HashMap, HashSet};
27use std::path::{Path, PathBuf};
28use std::sync::Mutex;
29
30use async_trait::async_trait;
31use serde::{Deserialize, Serialize};
32use tokio::fs;
33use tokio::io::AsyncWriteExt;
34
35#[derive(Debug, thiserror::Error)]
49pub enum JournalError {
50 #[error("journal CAS conflict: {0}")]
52 CasConflict(String),
53 #[error("journal integrity fault: {0}")]
57 Integrity(String),
58 #[error("{message}: {source}")]
62 Io {
63 message: String,
64 #[source]
65 source: std::io::Error,
66 },
67}
68
69impl JournalError {
70 pub fn conflict(message: impl Into<String>) -> Self {
71 Self::CasConflict(message.into())
72 }
73
74 pub fn integrity(message: impl Into<String>) -> Self {
75 Self::Integrity(message.into())
76 }
77
78 pub fn io(message: impl Into<String>, source: std::io::Error) -> Self {
79 Self::Io {
80 message: message.into(),
81 source,
82 }
83 }
84
85 pub fn is_retryable(&self) -> bool {
89 matches!(self, Self::CasConflict(_))
90 }
91}
92
93impl From<JournalError> for crate::Error {
94 fn from(err: JournalError) -> Self {
100 match err {
101 JournalError::Io { message, source } => crate::Error::Io(std::io::Error::new(
102 source.kind(),
103 format!("{message}: {source}"),
104 )),
105 other => crate::Error::Other(other.to_string()),
106 }
107 }
108}
109
110pub type JournalResult<T> = std::result::Result<T, JournalError>;
111
112#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct JournalRecordInput {
119 pub step_seq: u64,
121 pub record_digest: String,
123 pub record_bytes: Vec<u8>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct JournalEntry {
130 pub step_seq: u64,
131 pub record_digest: String,
132 pub previous_record_digest: Option<String>,
134 pub record_bytes: Vec<u8>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct JournalHead {
143 pub step_seq: u64,
144 pub record_digest: String,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct JournalAppendReceipt {
149 pub step_seq: u64,
150 pub record_digest: String,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct CheckpointCandidate {
160 pub checkpoint_id: String,
162 pub through_step_seq: u64,
164 pub state_digest: String,
166 pub checkpoint_bytes: Vec<u8>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct InstalledCheckpoint {
173 pub ordinal: u64,
175 pub checkpoint_id: String,
176 pub previous_checkpoint_id: Option<String>,
177 pub covered_head: String,
179 pub through_step_seq: u64,
180 pub state_digest: String,
181 pub checkpoint_bytes: Vec<u8>,
182 pub acknowledged: bool,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct JournalPruneReceipt {
190 pub pruned_through_step_seq: Option<u64>,
193 pub pruned_count: u64,
194}
195
196#[async_trait]
212pub trait KernelJournal: Send + Sync {
213 async fn stage_outbound_envelope(
218 &self,
219 operation_id: &str,
220 envelope_json: &str,
221 ) -> JournalResult<()>;
222
223 async fn read_outbound_envelope(&self, operation_id: &str) -> JournalResult<Option<String>>;
225
226 async fn clear_outbound_envelope(&self, operation_id: &str) -> JournalResult<()>;
228
229 async fn compare_and_append(
237 &self,
238 operation_id: &str,
239 expected_head: Option<&str>,
240 record: JournalRecordInput,
241 ) -> JournalResult<JournalAppendReceipt>;
242
243 async fn head(&self, operation_id: &str) -> JournalResult<Option<JournalHead>>;
245
246 async fn read_from(
249 &self,
250 operation_id: &str,
251 from_step_seq: u64,
252 ) -> JournalResult<Vec<JournalEntry>>;
253
254 async fn records_after(
261 &self,
262 operation_id: &str,
263 after_head: Option<&str>,
264 ) -> JournalResult<Vec<JournalEntry>>;
265
266 async fn compare_and_install_checkpoint(
277 &self,
278 operation_id: &str,
279 previous_checkpoint_id: Option<&str>,
280 covered_head: &str,
281 checkpoint: CheckpointCandidate,
282 ) -> JournalResult<InstalledCheckpoint>;
283
284 async fn latest_checkpoint(
286 &self,
287 operation_id: &str,
288 ) -> JournalResult<Option<InstalledCheckpoint>>;
289
290 async fn ack_checkpoint(
294 &self,
295 operation_id: &str,
296 checkpoint_id: &str,
297 ) -> JournalResult<InstalledCheckpoint>;
298
299 async fn prune_acked_prefix(&self, operation_id: &str) -> JournalResult<JournalPruneReceipt>;
303}
304
305const SEQ_DIGITS: usize = 12;
311const MAX_CHAIN_POSITION: u64 = 1_000_000_000_000;
315
316fn validate_record(record: &JournalRecordInput) -> JournalResult<()> {
317 if record.step_seq >= MAX_CHAIN_POSITION {
318 return Err(JournalError::integrity(format!(
319 "journal record step_seq {} exceeds the {SEQ_DIGITS}-digit chain-position space",
320 record.step_seq
321 )));
322 }
323 if record.record_digest.is_empty() {
324 return Err(JournalError::integrity(
325 "journal record requires a record_digest",
326 ));
327 }
328 Ok(())
329}
330
331fn validate_candidate(checkpoint: &CheckpointCandidate) -> JournalResult<()> {
332 if checkpoint.checkpoint_id.is_empty() {
333 return Err(JournalError::integrity(
334 "checkpoint requires a checkpoint_id",
335 ));
336 }
337 if checkpoint.through_step_seq >= MAX_CHAIN_POSITION {
338 return Err(JournalError::integrity(format!(
339 "checkpoint through_step_seq {} exceeds the {SEQ_DIGITS}-digit chain-position space",
340 checkpoint.through_step_seq
341 )));
342 }
343 if checkpoint.state_digest.is_empty() {
344 return Err(JournalError::integrity(
345 "checkpoint requires a state_digest",
346 ));
347 }
348 Ok(())
349}
350
351fn check_append_precondition(
354 head: Option<&JournalHead>,
355 expected_head: Option<&str>,
356 record: &JournalRecordInput,
357) -> JournalResult<()> {
358 let Some(expected_head) = expected_head else {
359 if head.is_some() {
360 return Err(JournalError::conflict(
361 "journal genesis append requires an empty chain, but the operation already has a head",
362 ));
363 }
364 if record.step_seq != 0 {
365 return Err(JournalError::integrity(
366 "journal genesis record must have step_seq 0",
367 ));
368 }
369 return Ok(());
370 };
371 let Some(head) = head else {
372 return Err(JournalError::conflict(
373 "journal compare-and-append expected a head, but the chain is empty",
374 ));
375 };
376 if head.record_digest != expected_head {
377 return Err(JournalError::conflict(
378 "journal head changed before compare-and-append",
379 ));
380 }
381 if record.step_seq != head.step_seq + 1 {
382 return Err(JournalError::integrity(format!(
383 "journal record step_seq {} does not follow head step_seq {}",
384 record.step_seq, head.step_seq
385 )));
386 }
387 Ok(())
388}
389
390fn check_checkpoint_precondition(
391 latest: Option<&InstalledCheckpoint>,
392 previous_checkpoint_id: Option<&str>,
393 checkpoint: &CheckpointCandidate,
394) -> JournalResult<()> {
395 let Some(previous_checkpoint_id) = previous_checkpoint_id else {
396 if latest.is_some() {
397 return Err(JournalError::conflict(
398 "checkpoint install without a predecessor requires an empty checkpoint pointer",
399 ));
400 }
401 return Ok(());
402 };
403 let Some(latest) = latest else {
404 return Err(JournalError::conflict(
405 "checkpoint install named a predecessor, but none is installed",
406 ));
407 };
408 if latest.checkpoint_id != previous_checkpoint_id {
409 return Err(JournalError::conflict(
410 "checkpoint pointer changed before compare-and-install",
411 ));
412 }
413 if checkpoint.through_step_seq < latest.through_step_seq {
414 return Err(JournalError::integrity(
415 "checkpoint pointer must advance monotonically",
416 ));
417 }
418 Ok(())
419}
420
421fn verify_covered_head(
425 covered: Option<&JournalEntry>,
426 pruned: Option<&PrunedAnchor>,
427 covered_head: &str,
428 through_step_seq: u64,
429) -> JournalResult<()> {
430 if let Some(covered) = covered {
431 if covered.record_digest != covered_head {
432 return Err(JournalError::integrity(
433 "checkpoint covered_head does not match the record at its through_step_seq",
434 ));
435 }
436 return Ok(());
437 }
438 if let Some(pruned) = pruned {
439 if pruned.through_step_seq == through_step_seq && pruned.covered_head == covered_head {
440 return Ok(());
441 }
442 }
443 Err(JournalError::integrity(
444 "checkpoint through_step_seq names no retained record",
445 ))
446}
447
448fn verify_chain(entries: &[JournalEntry]) -> JournalResult<()> {
450 for window in entries.windows(2) {
451 let (previous, entry) = (&window[0], &window[1]);
452 if entry.step_seq != previous.step_seq + 1 {
453 return Err(JournalError::integrity(format!(
454 "journal chain has a gap: step_seq {} follows {}",
455 entry.step_seq, previous.step_seq
456 )));
457 }
458 if entry.previous_record_digest.as_deref() != Some(previous.record_digest.as_str()) {
459 return Err(JournalError::integrity(
460 "journal chain digest linkage is not continuous",
461 ));
462 }
463 }
464 Ok(())
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
469struct PrunedAnchor {
470 through_step_seq: u64,
471 covered_head: String,
472}
473
474#[derive(Default)]
479struct OperationState {
480 records: Vec<JournalEntry>,
481 checkpoints: Vec<InstalledCheckpoint>,
482 pruned: Option<PrunedAnchor>,
483 outbound_envelope: Option<String>,
484}
485
486impl OperationState {
487 fn head(&self) -> Option<JournalHead> {
488 if let Some(last) = self.records.last() {
489 return Some(JournalHead {
490 step_seq: last.step_seq,
491 record_digest: last.record_digest.clone(),
492 });
493 }
494 self.pruned.as_ref().map(|pruned| JournalHead {
495 step_seq: pruned.through_step_seq,
496 record_digest: pruned.covered_head.clone(),
497 })
498 }
499
500 fn read_from(&self, from_step_seq: u64) -> JournalResult<Vec<JournalEntry>> {
501 let base = self.records.first().map_or(0, |entry| entry.step_seq);
505 let start = (from_step_seq.saturating_sub(base) as usize).min(self.records.len());
506 let entries = self.records[start..].to_vec();
507 verify_chain(&entries)?;
508 Ok(entries)
509 }
510}
511
512pub struct InMemoryKernelJournal {
521 operations: Mutex<HashMap<String, OperationState>>,
522}
523
524impl Default for InMemoryKernelJournal {
525 fn default() -> Self {
526 Self {
527 operations: Mutex::new(HashMap::new()),
528 }
529 }
530}
531
532impl InMemoryKernelJournal {
533 pub fn new() -> Self {
534 Self::default()
535 }
536
537 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, OperationState>> {
543 self.operations
544 .lock()
545 .unwrap_or_else(|poisoned| poisoned.into_inner())
546 }
547}
548
549#[async_trait]
550impl KernelJournal for InMemoryKernelJournal {
551 async fn stage_outbound_envelope(
552 &self,
553 operation_id: &str,
554 envelope_json: &str,
555 ) -> JournalResult<()> {
556 self.lock()
557 .entry(operation_id.to_string())
558 .or_default()
559 .outbound_envelope = Some(envelope_json.to_string());
560 Ok(())
561 }
562
563 async fn read_outbound_envelope(&self, operation_id: &str) -> JournalResult<Option<String>> {
564 Ok(self
565 .lock()
566 .get(operation_id)
567 .and_then(|state| state.outbound_envelope.clone()))
568 }
569
570 async fn clear_outbound_envelope(&self, operation_id: &str) -> JournalResult<()> {
571 if let Some(state) = self.lock().get_mut(operation_id) {
572 state.outbound_envelope = None;
573 }
574 Ok(())
575 }
576
577 async fn compare_and_append(
578 &self,
579 operation_id: &str,
580 expected_head: Option<&str>,
581 record: JournalRecordInput,
582 ) -> JournalResult<JournalAppendReceipt> {
583 validate_record(&record)?;
584 let mut operations = self.lock();
587 let state = operations.entry(operation_id.to_string()).or_default();
588 check_append_precondition(state.head().as_ref(), expected_head, &record)?;
589 state.records.push(JournalEntry {
590 step_seq: record.step_seq,
591 record_digest: record.record_digest.clone(),
592 previous_record_digest: expected_head.map(str::to_string),
593 record_bytes: record.record_bytes,
594 });
595 Ok(JournalAppendReceipt {
596 step_seq: record.step_seq,
597 record_digest: record.record_digest,
598 })
599 }
600
601 async fn head(&self, operation_id: &str) -> JournalResult<Option<JournalHead>> {
602 Ok(self.lock().get(operation_id).and_then(OperationState::head))
603 }
604
605 async fn read_from(
606 &self,
607 operation_id: &str,
608 from_step_seq: u64,
609 ) -> JournalResult<Vec<JournalEntry>> {
610 match self.lock().get(operation_id) {
611 Some(state) => state.read_from(from_step_seq),
612 None => Ok(Vec::new()),
613 }
614 }
615
616 async fn records_after(
617 &self,
618 operation_id: &str,
619 after_head: Option<&str>,
620 ) -> JournalResult<Vec<JournalEntry>> {
621 let Some(after_head) = after_head else {
622 return self.read_from(operation_id, 0).await;
623 };
624 let operations = self.lock();
625 let Some(state) = operations.get(operation_id) else {
626 return Err(JournalError::integrity(
627 "journal cursor digest names no retained record",
628 ));
629 };
630 if let Some(anchor) = state
631 .records
632 .iter()
633 .find(|entry| entry.record_digest == after_head)
634 {
635 return state.read_from(anchor.step_seq + 1);
636 }
637 if let Some(pruned) = &state.pruned {
638 if pruned.covered_head == after_head {
639 return state.read_from(pruned.through_step_seq + 1);
640 }
641 }
642 Err(JournalError::integrity(
643 "journal cursor digest names no retained record",
644 ))
645 }
646
647 async fn compare_and_install_checkpoint(
648 &self,
649 operation_id: &str,
650 previous_checkpoint_id: Option<&str>,
651 covered_head: &str,
652 checkpoint: CheckpointCandidate,
653 ) -> JournalResult<InstalledCheckpoint> {
654 validate_candidate(&checkpoint)?;
655 let mut operations = self.lock();
657 let state = operations.entry(operation_id.to_string()).or_default();
658 let latest = state.checkpoints.last();
659 check_checkpoint_precondition(latest, previous_checkpoint_id, &checkpoint)?;
660 let ordinal = latest.map_or(0, |latest| latest.ordinal + 1);
661 verify_covered_head(
662 state
663 .records
664 .iter()
665 .find(|entry| entry.step_seq == checkpoint.through_step_seq),
666 state.pruned.as_ref(),
667 covered_head,
668 checkpoint.through_step_seq,
669 )?;
670 let installed = InstalledCheckpoint {
671 ordinal,
672 checkpoint_id: checkpoint.checkpoint_id,
673 previous_checkpoint_id: previous_checkpoint_id.map(str::to_string),
674 covered_head: covered_head.to_string(),
675 through_step_seq: checkpoint.through_step_seq,
676 state_digest: checkpoint.state_digest,
677 checkpoint_bytes: checkpoint.checkpoint_bytes,
678 acknowledged: false,
679 };
680 state.checkpoints.push(installed.clone());
681 Ok(installed)
682 }
683
684 async fn latest_checkpoint(
685 &self,
686 operation_id: &str,
687 ) -> JournalResult<Option<InstalledCheckpoint>> {
688 Ok(self
689 .lock()
690 .get(operation_id)
691 .and_then(|state| state.checkpoints.last().cloned()))
692 }
693
694 async fn ack_checkpoint(
695 &self,
696 operation_id: &str,
697 checkpoint_id: &str,
698 ) -> JournalResult<InstalledCheckpoint> {
699 let mut operations = self.lock();
700 let installed = operations
701 .get_mut(operation_id)
702 .and_then(|state| {
703 state
704 .checkpoints
705 .iter_mut()
706 .find(|entry| entry.checkpoint_id == checkpoint_id)
707 })
708 .ok_or_else(|| {
709 JournalError::integrity("cannot acknowledge an uninstalled checkpoint")
710 })?;
711 installed.acknowledged = true;
712 Ok(installed.clone())
713 }
714
715 async fn prune_acked_prefix(&self, operation_id: &str) -> JournalResult<JournalPruneReceipt> {
716 let mut operations = self.lock();
717 let Some(state) = operations.get_mut(operation_id) else {
718 return Ok(JournalPruneReceipt {
719 pruned_through_step_seq: None,
720 pruned_count: 0,
721 });
722 };
723 let Some(boundary) = state
724 .checkpoints
725 .iter()
726 .rev()
727 .find(|entry| entry.acknowledged)
728 .cloned()
729 else {
730 return Ok(JournalPruneReceipt {
731 pruned_through_step_seq: state.pruned.as_ref().map(|p| p.through_step_seq),
732 pruned_count: 0,
733 });
734 };
735 let before = state.records.len();
736 state
737 .records
738 .retain(|entry| entry.step_seq > boundary.through_step_seq);
739 if state
740 .pruned
741 .as_ref()
742 .is_none_or(|pruned| pruned.through_step_seq < boundary.through_step_seq)
743 {
744 state.pruned = Some(PrunedAnchor {
745 through_step_seq: boundary.through_step_seq,
746 covered_head: boundary.covered_head.clone(),
747 });
748 }
749 Ok(JournalPruneReceipt {
750 pruned_through_step_seq: state.pruned.as_ref().map(|p| p.through_step_seq),
751 pruned_count: (before - state.records.len()) as u64,
752 })
753 }
754}
755
756const RECORD_SUFFIX: &str = ".rec";
761const CHECKPOINT_SUFFIX: &str = ".ckpt";
762const ACK_SUFFIX: &str = ".ack";
763const OUTBOUND_ENVELOPE_FILE: &str = "outbound-envelope.json";
764
765fn pad(value: u64) -> String {
766 format!("{value:0SEQ_DIGITS$}")
767}
768
769fn parse_position(name: &str, suffix: &str) -> Option<u64> {
772 let stem = name.strip_suffix(suffix)?;
773 if stem.len() != SEQ_DIGITS || !stem.bytes().all(|byte| byte.is_ascii_digit()) {
774 return None;
775 }
776 stem.parse().ok()
777}
778
779fn safe_segment(value: &str) -> String {
786 if value.is_empty() {
787 return "~~".to_string();
788 }
789 let mut out = String::with_capacity(value.len());
790 for ch in value.chars() {
791 if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
792 out.push(ch);
793 } else {
794 out.push_str(&format!("~{:x}", ch as u32));
795 }
796 }
797 if out.bytes().all(|byte| byte == b'.') {
798 return out.chars().map(|_| "~2e").collect();
799 }
800 out
801}
802
803#[derive(Serialize, Deserialize)]
804struct PersistedRecord {
805 step_seq: u64,
806 record_digest: String,
807 #[serde(default, skip_serializing_if = "Option::is_none")]
808 previous_record_digest: Option<String>,
809 record_bytes: String,
811}
812
813#[derive(Serialize, Deserialize)]
814struct PersistedCheckpoint {
815 ordinal: u64,
816 checkpoint_id: String,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
818 previous_checkpoint_id: Option<String>,
819 covered_head: String,
820 through_step_seq: u64,
821 state_digest: String,
822 checkpoint_bytes: String,
823}
824
825pub struct FileKernelJournal {
852 root: PathBuf,
853}
854
855impl FileKernelJournal {
856 pub fn new(root: impl AsRef<Path>) -> Self {
857 Self {
858 root: root.as_ref().to_path_buf(),
859 }
860 }
861
862 fn operation_dir(&self, operation_id: &str) -> PathBuf {
863 self.root.join(safe_segment(operation_id))
864 }
865
866 fn records_dir(&self, operation_id: &str) -> PathBuf {
867 self.operation_dir(operation_id).join("records")
868 }
869
870 fn checkpoints_dir(&self, operation_id: &str) -> PathBuf {
871 self.operation_dir(operation_id).join("checkpoints")
872 }
873
874 fn tmp_dir(&self, operation_id: &str) -> PathBuf {
875 self.operation_dir(operation_id).join("tmp")
876 }
877
878 fn pruned_path(&self, operation_id: &str) -> PathBuf {
879 self.operation_dir(operation_id).join("pruned.json")
880 }
881
882 fn outbound_envelope_path(&self, operation_id: &str) -> PathBuf {
883 self.operation_dir(operation_id)
884 .join(OUTBOUND_ENVELOPE_FILE)
885 }
886
887 async fn publish(
891 &self,
892 operation_id: &str,
893 target: &Path,
894 payload: &str,
895 ) -> JournalResult<bool> {
896 let tmp_dir = self.tmp_dir(operation_id);
897 fs::create_dir_all(&tmp_dir)
898 .await
899 .map_err(|err| JournalError::io("journal could not stage a durable record", err))?;
900 let tmp_path = tmp_dir.join(format!("{}.tmp", uuid::Uuid::new_v4()));
901 if let Err(err) = stage(&tmp_path, payload).await {
902 let _ = fs::remove_file(&tmp_path).await;
903 return Err(err);
904 }
905 let outcome = match fs::hard_link(&tmp_path, target).await {
906 Ok(()) => Ok(true),
907 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
908 Err(err) => Err(JournalError::io(
909 "journal could not publish a durable record",
910 err,
911 )),
912 };
913 let _ = fs::remove_file(&tmp_path).await;
914 if matches!(outcome, Ok(true)) {
915 if let Some(parent) = target.parent() {
916 sync_dir(parent).await;
917 }
918 }
919 outcome
920 }
921
922 async fn record_positions(&self, operation_id: &str) -> JournalResult<Vec<u64>> {
925 list_positions(&self.records_dir(operation_id), RECORD_SUFFIX, "records").await
926 }
927
928 async fn read_record(
929 &self,
930 operation_id: &str,
931 step_seq: u64,
932 ) -> JournalResult<Option<JournalEntry>> {
933 let name = format!("{}{RECORD_SUFFIX}", pad(step_seq));
934 let Some(raw) =
935 read_optional(&self.records_dir(operation_id).join(&name), "a record").await?
936 else {
937 return Ok(None);
938 };
939 let persisted: PersistedRecord = serde_json::from_str(&raw).map_err(|err| {
940 JournalError::integrity(format!("journal record {name} is not readable: {err}"))
941 })?;
942 if persisted.step_seq != step_seq {
943 return Err(JournalError::integrity(format!(
944 "journal record {name} disagrees with its own step_seq"
945 )));
946 }
947 Ok(Some(JournalEntry {
948 step_seq: persisted.step_seq,
949 record_digest: persisted.record_digest,
950 previous_record_digest: persisted.previous_record_digest,
951 record_bytes: base64_decode(&persisted.record_bytes).ok_or_else(|| {
952 JournalError::integrity(format!("journal record {name} has unreadable bytes"))
953 })?,
954 }))
955 }
956
957 async fn pruned_anchor(&self, operation_id: &str) -> JournalResult<Option<PrunedAnchor>> {
958 let Some(raw) = read_optional(&self.pruned_path(operation_id), "its pruned anchor").await?
959 else {
960 return Ok(None);
961 };
962 serde_json::from_str(&raw).map(Some).map_err(|err| {
963 JournalError::integrity(format!("journal pruned anchor is not readable: {err}"))
964 })
965 }
966
967 async fn write_anchor(&self, operation_id: &str, anchor: &PrunedAnchor) -> JournalResult<()> {
970 let tmp_dir = self.tmp_dir(operation_id);
971 fs::create_dir_all(&tmp_dir)
972 .await
973 .map_err(|err| JournalError::io("journal could not record its pruned anchor", err))?;
974 let tmp_path = tmp_dir.join(format!("{}.tmp", uuid::Uuid::new_v4()));
975 let payload = serde_json::to_string(anchor).unwrap_or_default();
976 let result = async {
977 stage(&tmp_path, &payload).await?;
978 fs::rename(&tmp_path, self.pruned_path(operation_id))
979 .await
980 .map_err(|err| JournalError::io("journal could not record its pruned anchor", err))
981 }
982 .await;
983 if result.is_err() {
984 let _ = fs::remove_file(&tmp_path).await;
985 }
986 result
987 }
988
989 async fn publish_record(
993 &self,
994 operation_id: &str,
995 expected_head: Option<&str>,
996 record: &JournalRecordInput,
997 ) -> JournalResult<JournalAppendReceipt> {
998 let records_dir = self.records_dir(operation_id);
999 fs::create_dir_all(&records_dir).await.map_err(|err| {
1000 JournalError::io("journal could not create its record directory", err)
1001 })?;
1002 let persisted = PersistedRecord {
1003 step_seq: record.step_seq,
1004 record_digest: record.record_digest.clone(),
1005 previous_record_digest: expected_head.map(str::to_string),
1006 record_bytes: base64_encode(&record.record_bytes),
1007 };
1008 let target = records_dir.join(format!("{}{RECORD_SUFFIX}", pad(record.step_seq)));
1009 let payload = serde_json::to_string(&persisted).map_err(|err| {
1010 JournalError::integrity(format!("journal record is not encodable: {err}"))
1011 })?;
1012 if !self.publish(operation_id, &target, &payload).await? {
1013 return Err(JournalError::conflict(format!(
1014 "journal step_seq {} was claimed by a concurrent writer",
1015 record.step_seq
1016 )));
1017 }
1018 Ok(JournalAppendReceipt {
1019 step_seq: record.step_seq,
1020 record_digest: record.record_digest.clone(),
1021 })
1022 }
1023
1024 async fn publish_checkpoint(
1027 &self,
1028 operation_id: &str,
1029 ordinal: u64,
1030 previous_checkpoint_id: Option<&str>,
1031 covered_head: &str,
1032 checkpoint: &CheckpointCandidate,
1033 ) -> JournalResult<InstalledCheckpoint> {
1034 let checkpoints_dir = self.checkpoints_dir(operation_id);
1035 fs::create_dir_all(&checkpoints_dir).await.map_err(|err| {
1036 JournalError::io("journal could not create its checkpoint directory", err)
1037 })?;
1038 let persisted = PersistedCheckpoint {
1039 ordinal,
1040 checkpoint_id: checkpoint.checkpoint_id.clone(),
1041 previous_checkpoint_id: previous_checkpoint_id.map(str::to_string),
1042 covered_head: covered_head.to_string(),
1043 through_step_seq: checkpoint.through_step_seq,
1044 state_digest: checkpoint.state_digest.clone(),
1045 checkpoint_bytes: base64_encode(&checkpoint.checkpoint_bytes),
1046 };
1047 let target = checkpoints_dir.join(format!("{}{CHECKPOINT_SUFFIX}", pad(ordinal)));
1048 let payload = serde_json::to_string(&persisted).map_err(|err| {
1049 JournalError::integrity(format!("checkpoint is not encodable: {err}"))
1050 })?;
1051 if !self.publish(operation_id, &target, &payload).await? {
1052 return Err(JournalError::conflict(format!(
1053 "checkpoint ordinal {ordinal} was claimed by a concurrent installer"
1054 )));
1055 }
1056 Ok(InstalledCheckpoint {
1057 ordinal,
1058 checkpoint_id: checkpoint.checkpoint_id.clone(),
1059 previous_checkpoint_id: previous_checkpoint_id.map(str::to_string),
1060 covered_head: covered_head.to_string(),
1061 through_step_seq: checkpoint.through_step_seq,
1062 state_digest: checkpoint.state_digest.clone(),
1063 checkpoint_bytes: checkpoint.checkpoint_bytes.clone(),
1064 acknowledged: false,
1065 })
1066 }
1067
1068 async fn checkpoint_ordinals(&self, operation_id: &str) -> JournalResult<Vec<u64>> {
1069 list_positions(
1070 &self.checkpoints_dir(operation_id),
1071 CHECKPOINT_SUFFIX,
1072 "checkpoints",
1073 )
1074 .await
1075 }
1076
1077 async fn acked_ordinals(&self, operation_id: &str) -> JournalResult<HashSet<u64>> {
1078 Ok(list_positions(
1079 &self.checkpoints_dir(operation_id),
1080 ACK_SUFFIX,
1081 "checkpoints",
1082 )
1083 .await?
1084 .into_iter()
1085 .collect())
1086 }
1087
1088 async fn read_checkpoint(
1089 &self,
1090 operation_id: &str,
1091 ordinal: u64,
1092 acked: Option<&HashSet<u64>>,
1093 ) -> JournalResult<Option<InstalledCheckpoint>> {
1094 let name = format!("{}{CHECKPOINT_SUFFIX}", pad(ordinal));
1095 let Some(raw) = read_optional(
1096 &self.checkpoints_dir(operation_id).join(&name),
1097 "a checkpoint",
1098 )
1099 .await?
1100 else {
1101 return Ok(None);
1102 };
1103 let persisted: PersistedCheckpoint = serde_json::from_str(&raw).map_err(|err| {
1104 JournalError::integrity(format!("checkpoint {name} is not readable: {err}"))
1105 })?;
1106 let acknowledged = match acked {
1107 Some(acked) => acked.contains(&ordinal),
1108 None => self.acked_ordinals(operation_id).await?.contains(&ordinal),
1109 };
1110 Ok(Some(InstalledCheckpoint {
1111 ordinal: persisted.ordinal,
1112 checkpoint_id: persisted.checkpoint_id,
1113 previous_checkpoint_id: persisted.previous_checkpoint_id,
1114 covered_head: persisted.covered_head,
1115 through_step_seq: persisted.through_step_seq,
1116 state_digest: persisted.state_digest,
1117 checkpoint_bytes: base64_decode(&persisted.checkpoint_bytes).ok_or_else(|| {
1118 JournalError::integrity(format!("checkpoint {name} has unreadable bytes"))
1119 })?,
1120 acknowledged,
1121 }))
1122 }
1123}
1124
1125#[async_trait]
1126impl KernelJournal for FileKernelJournal {
1127 async fn stage_outbound_envelope(
1128 &self,
1129 operation_id: &str,
1130 envelope_json: &str,
1131 ) -> JournalResult<()> {
1132 let tmp_dir = self.tmp_dir(operation_id);
1133 fs::create_dir_all(&tmp_dir)
1134 .await
1135 .map_err(|err| JournalError::io("journal could not stage an outbound envelope", err))?;
1136 let tmp_path = tmp_dir.join(format!("{}.tmp", uuid::Uuid::new_v4()));
1137 let result = async {
1138 stage(&tmp_path, envelope_json).await?;
1139 fs::rename(&tmp_path, self.outbound_envelope_path(operation_id))
1140 .await
1141 .map_err(|err| {
1142 JournalError::io("journal could not publish an outbound envelope", err)
1143 })?;
1144 sync_dir(&self.operation_dir(operation_id)).await;
1145 Ok(())
1146 }
1147 .await;
1148 if result.is_err() {
1149 let _ = fs::remove_file(&tmp_path).await;
1150 }
1151 result
1152 }
1153
1154 async fn read_outbound_envelope(&self, operation_id: &str) -> JournalResult<Option<String>> {
1155 read_optional(
1156 &self.outbound_envelope_path(operation_id),
1157 "a staged outbound envelope",
1158 )
1159 .await
1160 }
1161
1162 async fn clear_outbound_envelope(&self, operation_id: &str) -> JournalResult<()> {
1163 match fs::remove_file(self.outbound_envelope_path(operation_id)).await {
1164 Ok(()) => Ok(()),
1165 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1166 Err(err) => Err(JournalError::io(
1167 "journal could not clear a staged outbound envelope",
1168 err,
1169 )),
1170 }
1171 }
1172
1173 async fn compare_and_append(
1174 &self,
1175 operation_id: &str,
1176 expected_head: Option<&str>,
1177 record: JournalRecordInput,
1178 ) -> JournalResult<JournalAppendReceipt> {
1179 validate_record(&record)?;
1180 check_append_precondition(
1181 self.head(operation_id).await?.as_ref(),
1182 expected_head,
1183 &record,
1184 )?;
1185 self.publish_record(operation_id, expected_head, &record)
1186 .await
1187 }
1188
1189 async fn head(&self, operation_id: &str) -> JournalResult<Option<JournalHead>> {
1190 if let Some(last) = self.record_positions(operation_id).await?.last().copied() {
1191 if let Some(entry) = self.read_record(operation_id, last).await? {
1192 return Ok(Some(JournalHead {
1193 step_seq: entry.step_seq,
1194 record_digest: entry.record_digest,
1195 }));
1196 }
1197 }
1198 Ok(self
1199 .pruned_anchor(operation_id)
1200 .await?
1201 .map(|pruned| JournalHead {
1202 step_seq: pruned.through_step_seq,
1203 record_digest: pruned.covered_head,
1204 }))
1205 }
1206
1207 async fn read_from(
1208 &self,
1209 operation_id: &str,
1210 from_step_seq: u64,
1211 ) -> JournalResult<Vec<JournalEntry>> {
1212 let mut entries = Vec::new();
1213 for position in self.record_positions(operation_id).await? {
1214 if position < from_step_seq {
1215 continue;
1216 }
1217 if let Some(entry) = self.read_record(operation_id, position).await? {
1218 entries.push(entry);
1219 }
1220 }
1221 verify_chain(&entries)?;
1222 Ok(entries)
1223 }
1224
1225 async fn records_after(
1226 &self,
1227 operation_id: &str,
1228 after_head: Option<&str>,
1229 ) -> JournalResult<Vec<JournalEntry>> {
1230 let Some(after_head) = after_head else {
1231 return self.read_from(operation_id, 0).await;
1232 };
1233 for position in self.record_positions(operation_id).await? {
1234 if let Some(entry) = self.read_record(operation_id, position).await? {
1235 if entry.record_digest == after_head {
1236 return self.read_from(operation_id, position + 1).await;
1237 }
1238 }
1239 }
1240 if let Some(pruned) = self.pruned_anchor(operation_id).await? {
1241 if pruned.covered_head == after_head {
1242 return self
1243 .read_from(operation_id, pruned.through_step_seq + 1)
1244 .await;
1245 }
1246 }
1247 Err(JournalError::integrity(
1248 "journal cursor digest names no retained record",
1249 ))
1250 }
1251
1252 async fn compare_and_install_checkpoint(
1253 &self,
1254 operation_id: &str,
1255 previous_checkpoint_id: Option<&str>,
1256 covered_head: &str,
1257 checkpoint: CheckpointCandidate,
1258 ) -> JournalResult<InstalledCheckpoint> {
1259 validate_candidate(&checkpoint)?;
1260 let latest = self.latest_checkpoint(operation_id).await?;
1261 check_checkpoint_precondition(latest.as_ref(), previous_checkpoint_id, &checkpoint)?;
1262 verify_covered_head(
1263 self.read_record(operation_id, checkpoint.through_step_seq)
1264 .await?
1265 .as_ref(),
1266 self.pruned_anchor(operation_id).await?.as_ref(),
1267 covered_head,
1268 checkpoint.through_step_seq,
1269 )?;
1270 let ordinal = latest.map_or(0, |latest| latest.ordinal + 1);
1271 self.publish_checkpoint(
1272 operation_id,
1273 ordinal,
1274 previous_checkpoint_id,
1275 covered_head,
1276 &checkpoint,
1277 )
1278 .await
1279 }
1280
1281 async fn latest_checkpoint(
1282 &self,
1283 operation_id: &str,
1284 ) -> JournalResult<Option<InstalledCheckpoint>> {
1285 match self
1286 .checkpoint_ordinals(operation_id)
1287 .await?
1288 .last()
1289 .copied()
1290 {
1291 Some(ordinal) => self.read_checkpoint(operation_id, ordinal, None).await,
1292 None => Ok(None),
1293 }
1294 }
1295
1296 async fn ack_checkpoint(
1297 &self,
1298 operation_id: &str,
1299 checkpoint_id: &str,
1300 ) -> JournalResult<InstalledCheckpoint> {
1301 let mut ordinals = self.checkpoint_ordinals(operation_id).await?;
1302 ordinals.reverse();
1303 for ordinal in ordinals {
1304 let Some(installed) = self.read_checkpoint(operation_id, ordinal, None).await? else {
1305 continue;
1306 };
1307 if installed.checkpoint_id != checkpoint_id {
1308 continue;
1309 }
1310 if !installed.acknowledged {
1311 let target = self
1314 .checkpoints_dir(operation_id)
1315 .join(format!("{}{ACK_SUFFIX}", pad(ordinal)));
1316 let payload =
1317 serde_json::json!({ "ordinal": ordinal, "checkpoint_id": checkpoint_id })
1318 .to_string();
1319 self.publish(operation_id, &target, &payload).await?;
1320 }
1321 return Ok(InstalledCheckpoint {
1322 acknowledged: true,
1323 ..installed
1324 });
1325 }
1326 Err(JournalError::integrity(
1327 "cannot acknowledge an uninstalled checkpoint",
1328 ))
1329 }
1330
1331 async fn prune_acked_prefix(&self, operation_id: &str) -> JournalResult<JournalPruneReceipt> {
1332 let acked = self.acked_ordinals(operation_id).await?;
1333 let existing = self.pruned_anchor(operation_id).await?;
1334 let mut ordinals = self.checkpoint_ordinals(operation_id).await?;
1335 ordinals.reverse();
1336 let mut boundary = None;
1337 for ordinal in ordinals {
1338 if !acked.contains(&ordinal) {
1339 continue;
1340 }
1341 boundary = self
1342 .read_checkpoint(operation_id, ordinal, Some(&acked))
1343 .await?;
1344 break;
1345 }
1346 let Some(boundary) = boundary else {
1347 return Ok(JournalPruneReceipt {
1348 pruned_through_step_seq: existing.map(|anchor| anchor.through_step_seq),
1349 pruned_count: 0,
1350 });
1351 };
1352 if existing
1354 .as_ref()
1355 .is_none_or(|anchor| anchor.through_step_seq < boundary.through_step_seq)
1356 {
1357 self.write_anchor(
1358 operation_id,
1359 &PrunedAnchor {
1360 through_step_seq: boundary.through_step_seq,
1361 covered_head: boundary.covered_head.clone(),
1362 },
1363 )
1364 .await?;
1365 }
1366 let mut pruned_count = 0;
1367 for position in self.record_positions(operation_id).await? {
1368 if position > boundary.through_step_seq {
1369 break;
1370 }
1371 let _ = fs::remove_file(
1372 self.records_dir(operation_id)
1373 .join(format!("{}{RECORD_SUFFIX}", pad(position))),
1374 )
1375 .await;
1376 pruned_count += 1;
1377 }
1378 Ok(JournalPruneReceipt {
1379 pruned_through_step_seq: Some(
1380 boundary.through_step_seq.max(
1381 existing
1382 .as_ref()
1383 .map_or(boundary.through_step_seq, |anchor| anchor.through_step_seq),
1384 ),
1385 ),
1386 pruned_count,
1387 })
1388 }
1389}
1390
1391async fn stage(tmp_path: &Path, payload: &str) -> JournalResult<()> {
1398 let mut file = fs::OpenOptions::new()
1399 .create_new(true)
1400 .write(true)
1401 .open(tmp_path)
1402 .await
1403 .map_err(|err| JournalError::io("journal could not stage a durable record", err))?;
1404 file.write_all(payload.as_bytes())
1405 .await
1406 .map_err(|err| JournalError::io("journal could not stage a durable record", err))?;
1407 file.sync_all()
1408 .await
1409 .map_err(|err| JournalError::io("journal could not stage a durable record", err))?;
1410 Ok(())
1411}
1412
1413async fn sync_dir(dir: &Path) {
1415 if let Ok(handle) = fs::File::open(dir).await {
1416 let _ = handle.sync_all().await;
1417 }
1418}
1419
1420async fn list_positions(dir: &Path, suffix: &str, what: &str) -> JournalResult<Vec<u64>> {
1421 let mut read_dir = match fs::read_dir(dir).await {
1422 Ok(read_dir) => read_dir,
1423 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1424 Err(err) => {
1425 return Err(JournalError::io(
1426 format!("journal could not list its {what}"),
1427 err,
1428 ));
1429 }
1430 };
1431 let mut positions = Vec::new();
1432 while let Some(entry) = read_dir
1433 .next_entry()
1434 .await
1435 .map_err(|err| JournalError::io(format!("journal could not list its {what}"), err))?
1436 {
1437 if let Some(position) = parse_position(&entry.file_name().to_string_lossy(), suffix) {
1438 positions.push(position);
1439 }
1440 }
1441 positions.sort_unstable();
1442 Ok(positions)
1443}
1444
1445async fn read_optional(path: &Path, what: &str) -> JournalResult<Option<String>> {
1446 match fs::read_to_string(path).await {
1447 Ok(raw) => Ok(Some(raw)),
1448 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1449 Err(err) => Err(JournalError::io(
1450 format!("journal could not read {what}"),
1451 err,
1452 )),
1453 }
1454}
1455
1456const BASE64_ALPHABET: &[u8; 64] =
1457 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1458
1459fn base64_encode(bytes: &[u8]) -> String {
1462 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
1463 for chunk in bytes.chunks(3) {
1464 let packed = (u32::from(chunk[0]) << 16)
1465 | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
1466 | u32::from(chunk.get(2).copied().unwrap_or(0));
1467 out.push(BASE64_ALPHABET[(packed >> 18) as usize & 63] as char);
1468 out.push(BASE64_ALPHABET[(packed >> 12) as usize & 63] as char);
1469 out.push(if chunk.len() > 1 {
1470 BASE64_ALPHABET[(packed >> 6) as usize & 63] as char
1471 } else {
1472 '='
1473 });
1474 out.push(if chunk.len() > 2 {
1475 BASE64_ALPHABET[packed as usize & 63] as char
1476 } else {
1477 '='
1478 });
1479 }
1480 out
1481}
1482
1483fn base64_decode(text: &str) -> Option<Vec<u8>> {
1484 let mut out = Vec::with_capacity(text.len() / 4 * 3);
1485 let mut accumulator: u32 = 0;
1486 let mut bits = 0;
1487 for byte in text.bytes() {
1488 let value = match byte {
1489 b'A'..=b'Z' => byte - b'A',
1490 b'a'..=b'z' => byte - b'a' + 26,
1491 b'0'..=b'9' => byte - b'0' + 52,
1492 b'+' => 62,
1493 b'/' => 63,
1494 b'=' => break,
1495 b'\n' | b'\r' => continue,
1496 _ => return None,
1497 };
1498 accumulator = (accumulator << 6) | u32::from(value);
1499 bits += 6;
1500 if bits >= 8 {
1501 bits -= 8;
1502 out.push(((accumulator >> bits) & 0xff) as u8);
1503 }
1504 }
1505 Some(out)
1506}
1507
1508#[cfg(test)]
1513mod tests {
1514 use super::*;
1515 use std::sync::{Arc, Barrier};
1516
1517 const OP: &str = "op-journal";
1518
1519 struct TempDir(PathBuf);
1520
1521 impl TempDir {
1522 fn new() -> Self {
1523 let dir = std::env::temp_dir().join(format!("ds-journal-{}", uuid::Uuid::new_v4()));
1524 std::fs::create_dir_all(&dir).expect("temp dir");
1525 Self(dir)
1526 }
1527
1528 fn path(&self) -> &Path {
1529 &self.0
1530 }
1531 }
1532
1533 impl Drop for TempDir {
1534 fn drop(&mut self) {
1535 let _ = std::fs::remove_dir_all(&self.0);
1536 }
1537 }
1538
1539 fn record(step_seq: u64, digest: &str) -> JournalRecordInput {
1541 payload_record(step_seq, digest, &format!("payload-{step_seq}"))
1542 }
1543
1544 fn payload_record(step_seq: u64, digest: &str, payload: &str) -> JournalRecordInput {
1545 JournalRecordInput {
1546 step_seq,
1547 record_digest: digest.to_string(),
1548 record_bytes: payload.as_bytes().to_vec(),
1549 }
1550 }
1551
1552 fn candidate(id: &str, through_step_seq: u64) -> CheckpointCandidate {
1553 CheckpointCandidate {
1554 checkpoint_id: id.to_string(),
1555 through_step_seq,
1556 state_digest: format!("state-{id}"),
1557 checkpoint_bytes: format!("checkpoint-{id}").into_bytes(),
1558 }
1559 }
1560
1561 fn head(step_seq: u64, digest: &str) -> Option<JournalHead> {
1562 Some(JournalHead {
1563 step_seq,
1564 record_digest: digest.to_string(),
1565 })
1566 }
1567
1568 fn positions(entries: &[JournalEntry]) -> Vec<u64> {
1569 entries.iter().map(|entry| entry.step_seq).collect()
1570 }
1571
1572 fn text(bytes: &[u8]) -> String {
1573 String::from_utf8(bytes.to_vec()).expect("utf8")
1574 }
1575
1576 async fn seed_chain(
1578 journal: &dyn KernelJournal,
1579 count: u64,
1580 operation_id: &str,
1581 ) -> Vec<String> {
1582 let mut digests = vec!["d0".to_string()];
1583 journal
1584 .compare_and_append(operation_id, None, record(0, "d0"))
1585 .await
1586 .expect("genesis");
1587 for step_seq in 1..=count {
1588 let digest = format!("d{step_seq}");
1589 journal
1590 .compare_and_append(
1591 operation_id,
1592 Some(&digests[(step_seq - 1) as usize]),
1593 record(step_seq, &digest),
1594 )
1595 .await
1596 .expect("append");
1597 digests.push(digest);
1598 }
1599 digests
1600 }
1601
1602 fn assert_conflict<T: std::fmt::Debug>(result: JournalResult<T>) {
1603 match result {
1604 Err(err @ JournalError::CasConflict(_)) => assert!(err.is_retryable()),
1605 other => panic!("expected a CAS conflict, got {other:?}"),
1606 }
1607 }
1608
1609 fn assert_integrity<T: std::fmt::Debug>(result: JournalResult<T>) {
1610 match result {
1611 Err(err @ JournalError::Integrity(_)) => assert!(!err.is_retryable()),
1612 other => panic!("expected an integrity fault, got {other:?}"),
1613 }
1614 }
1615
1616 async fn genesis_append_advances_the_head(journal: &dyn KernelJournal) {
1619 assert_eq!(journal.head(OP).await.unwrap(), None);
1620
1621 let receipt = journal
1622 .compare_and_append(OP, None, record(0, "d0"))
1623 .await
1624 .unwrap();
1625 assert_eq!(
1626 receipt,
1627 JournalAppendReceipt {
1628 step_seq: 0,
1629 record_digest: "d0".into()
1630 }
1631 );
1632 assert_eq!(journal.head(OP).await.unwrap(), head(0, "d0"));
1633
1634 journal
1635 .compare_and_append(OP, Some("d0"), record(1, "d1"))
1636 .await
1637 .unwrap();
1638 assert_eq!(journal.head(OP).await.unwrap(), head(1, "d1"));
1639 }
1640
1641 async fn stores_bytes_verbatim_and_links_each_record(journal: &dyn KernelJournal) {
1642 seed_chain(journal, 2, OP).await;
1643 let entries = journal.read_from(OP, 0).await.unwrap();
1644
1645 assert_eq!(positions(&entries), vec![0, 1, 2]);
1646 assert_eq!(
1647 entries
1648 .iter()
1649 .map(|entry| entry.previous_record_digest.clone())
1650 .collect::<Vec<_>>(),
1651 vec![None, Some("d0".into()), Some("d1".into())]
1652 );
1653 assert_eq!(text(&entries[2].record_bytes), "payload-2");
1654 }
1655
1656 async fn rejects_a_stale_expected_head_without_overwriting(journal: &dyn KernelJournal) {
1657 seed_chain(journal, 1, OP).await;
1658
1659 assert_conflict(
1660 journal
1661 .compare_and_append(OP, Some("d0"), record(1, "other"))
1662 .await,
1663 );
1664 assert_eq!(journal.head(OP).await.unwrap(), head(1, "d1"));
1666 assert_eq!(journal.read_from(OP, 0).await.unwrap().len(), 2);
1667 }
1668
1669 async fn rejects_a_second_genesis_on_a_non_empty_chain(journal: &dyn KernelJournal) {
1670 journal
1671 .compare_and_append(OP, None, record(0, "d0"))
1672 .await
1673 .unwrap();
1674 assert_conflict(
1675 journal
1676 .compare_and_append(OP, None, record(0, "other"))
1677 .await,
1678 );
1679 }
1680
1681 async fn separates_a_cas_conflict_from_an_integrity_violation(journal: &dyn KernelJournal) {
1682 seed_chain(journal, 1, OP).await;
1683
1684 assert_integrity(
1686 journal
1687 .compare_and_append(OP, Some("d1"), record(5, "d5"))
1688 .await,
1689 );
1690 assert_conflict(journal.compare_and_append(OP, None, record(0, "d0")).await);
1692 }
1693
1694 async fn reads_by_step_cursor_and_by_digest_cursor(journal: &dyn KernelJournal) {
1695 let digests = seed_chain(journal, 3, OP).await;
1696
1697 assert_eq!(
1698 positions(&journal.read_from(OP, 2).await.unwrap()),
1699 vec![2, 3]
1700 );
1701 assert_eq!(
1702 positions(&journal.records_after(OP, Some(&digests[1])).await.unwrap()),
1703 vec![2, 3]
1704 );
1705 assert_eq!(
1706 positions(&journal.records_after(OP, None).await.unwrap()),
1707 vec![0, 1, 2, 3]
1708 );
1709 assert_integrity(journal.records_after(OP, Some("not-a-record")).await);
1710 }
1711
1712 async fn keeps_operations_isolated(journal: &dyn KernelJournal) {
1713 seed_chain(journal, 1, "op-a").await;
1714 seed_chain(journal, 2, "op-b").await;
1715
1716 assert_eq!(journal.head("op-a").await.unwrap(), head(1, "d1"));
1717 assert_eq!(journal.head("op-b").await.unwrap(), head(2, "d2"));
1718 }
1719
1720 async fn installs_a_checkpoint_covering_a_non_current_head(journal: &dyn KernelJournal) {
1722 let digests = seed_chain(journal, 3, OP).await;
1723
1724 let installed = journal
1726 .compare_and_install_checkpoint(OP, None, &digests[1], candidate("ck-1", 1))
1727 .await
1728 .unwrap();
1729
1730 assert_eq!(installed.ordinal, 0);
1731 assert_eq!(installed.covered_head, "d1");
1732 assert!(!installed.acknowledged);
1733 assert_eq!(journal.head(OP).await.unwrap(), head(3, "d3"));
1734 assert_eq!(
1735 journal
1736 .latest_checkpoint(OP)
1737 .await
1738 .unwrap()
1739 .map(|installed| installed.checkpoint_id),
1740 Some("ck-1".to_string())
1741 );
1742 assert_eq!(
1744 positions(&journal.read_from(OP, 0).await.unwrap()),
1745 vec![0, 1, 2, 3]
1746 );
1747 }
1748
1749 async fn rejects_a_checkpoint_whose_covered_head_disagrees(journal: &dyn KernelJournal) {
1750 let digests = seed_chain(journal, 2, OP).await;
1751
1752 assert_integrity(
1753 journal
1754 .compare_and_install_checkpoint(OP, None, &digests[2], candidate("ck-1", 1))
1755 .await,
1756 );
1757 assert_integrity(
1758 journal
1759 .compare_and_install_checkpoint(OP, None, "d9", candidate("ck-1", 9))
1760 .await,
1761 );
1762 assert!(journal.latest_checkpoint(OP).await.unwrap().is_none());
1763 }
1764
1765 async fn advances_the_checkpoint_pointer_monotonically(journal: &dyn KernelJournal) {
1766 let digests = seed_chain(journal, 3, OP).await;
1767 journal
1768 .compare_and_install_checkpoint(OP, None, &digests[1], candidate("ck-1", 1))
1769 .await
1770 .unwrap();
1771
1772 assert_conflict(
1774 journal
1775 .compare_and_install_checkpoint(OP, None, &digests[2], candidate("ck-2", 2))
1776 .await,
1777 );
1778 assert_conflict(
1780 journal
1781 .compare_and_install_checkpoint(OP, Some("ck-0"), &digests[2], candidate("ck-2", 2))
1782 .await,
1783 );
1784 assert_integrity(
1786 journal
1787 .compare_and_install_checkpoint(OP, Some("ck-1"), &digests[0], candidate("ck-2", 0))
1788 .await,
1789 );
1790
1791 let second = journal
1792 .compare_and_install_checkpoint(OP, Some("ck-1"), &digests[2], candidate("ck-2", 2))
1793 .await
1794 .unwrap();
1795 assert_eq!(second.ordinal, 1);
1796 assert_eq!(second.previous_checkpoint_id.as_deref(), Some("ck-1"));
1797 assert_eq!(
1798 journal
1799 .latest_checkpoint(OP)
1800 .await
1801 .unwrap()
1802 .map(|installed| installed.checkpoint_id),
1803 Some("ck-2".to_string())
1804 );
1805 }
1806
1807 async fn gates_prefix_reclamation_on_the_acknowledgement(journal: &dyn KernelJournal) {
1808 let digests = seed_chain(journal, 3, OP).await;
1809 journal
1810 .compare_and_install_checkpoint(OP, None, &digests[2], candidate("ck-1", 2))
1811 .await
1812 .unwrap();
1813
1814 assert_eq!(
1816 journal.prune_acked_prefix(OP).await.unwrap(),
1817 JournalPruneReceipt {
1818 pruned_through_step_seq: None,
1819 pruned_count: 0
1820 }
1821 );
1822 assert_eq!(journal.read_from(OP, 0).await.unwrap().len(), 4);
1823
1824 let acked = journal.ack_checkpoint(OP, "ck-1").await.unwrap();
1825 assert!(acked.acknowledged);
1826 assert!(
1827 journal
1828 .latest_checkpoint(OP)
1829 .await
1830 .unwrap()
1831 .unwrap()
1832 .acknowledged
1833 );
1834
1835 assert_eq!(
1836 journal.prune_acked_prefix(OP).await.unwrap(),
1837 JournalPruneReceipt {
1838 pruned_through_step_seq: Some(2),
1839 pruned_count: 3
1840 }
1841 );
1842 assert_eq!(positions(&journal.read_from(OP, 0).await.unwrap()), vec![3]);
1843 assert_eq!(journal.head(OP).await.unwrap(), head(3, "d3"));
1845 assert_eq!(
1846 positions(&journal.records_after(OP, Some(&digests[2])).await.unwrap()),
1847 vec![3]
1848 );
1849 journal
1851 .compare_and_append(OP, Some("d3"), record(4, "d4"))
1852 .await
1853 .unwrap();
1854 assert_eq!(journal.head(OP).await.unwrap(), head(4, "d4"));
1855 }
1856
1857 async fn refuses_to_acknowledge_an_uninstalled_checkpoint(journal: &dyn KernelJournal) {
1858 seed_chain(journal, 1, OP).await;
1859 assert_integrity(journal.ack_checkpoint(OP, "ck-missing").await);
1860 }
1861
1862 async fn stages_reads_and_clears_an_outbound_envelope(journal: &dyn KernelJournal) {
1863 assert_eq!(journal.read_outbound_envelope(OP).await.unwrap(), None);
1864 journal
1865 .stage_outbound_envelope(OP, r#"{"input_id":"stable","kind":"configure_operation"}"#)
1866 .await
1867 .unwrap();
1868 assert_eq!(
1869 journal.read_outbound_envelope(OP).await.unwrap().as_deref(),
1870 Some(r#"{"input_id":"stable","kind":"configure_operation"}"#)
1871 );
1872 journal
1875 .stage_outbound_envelope(OP, r#"{"input_id":"replacement"}"#)
1876 .await
1877 .unwrap();
1878 assert_eq!(
1879 journal.read_outbound_envelope(OP).await.unwrap().as_deref(),
1880 Some(r#"{"input_id":"replacement"}"#)
1881 );
1882 journal.clear_outbound_envelope(OP).await.unwrap();
1883 journal.clear_outbound_envelope(OP).await.unwrap();
1884 assert_eq!(journal.read_outbound_envelope(OP).await.unwrap(), None);
1885 }
1886
1887 macro_rules! contract_suite {
1890 ($($case:ident),+ $(,)?) => {
1891 mod in_memory {
1892 use super::*;
1893 $(
1894 #[tokio::test]
1895 async fn $case() {
1896 super::$case(&InMemoryKernelJournal::new()).await;
1897 }
1898 )+
1899 }
1900
1901 mod file {
1902 use super::*;
1903 $(
1904 #[tokio::test]
1905 async fn $case() {
1906 let dir = TempDir::new();
1907 super::$case(&FileKernelJournal::new(dir.path())).await;
1908 }
1909 )+
1910 }
1911 };
1912 }
1913
1914 contract_suite!(
1915 genesis_append_advances_the_head,
1916 stores_bytes_verbatim_and_links_each_record,
1917 rejects_a_stale_expected_head_without_overwriting,
1918 rejects_a_second_genesis_on_a_non_empty_chain,
1919 separates_a_cas_conflict_from_an_integrity_violation,
1920 reads_by_step_cursor_and_by_digest_cursor,
1921 keeps_operations_isolated,
1922 installs_a_checkpoint_covering_a_non_current_head,
1923 rejects_a_checkpoint_whose_covered_head_disagrees,
1924 advances_the_checkpoint_pointer_monotonically,
1925 gates_prefix_reclamation_on_the_acknowledgement,
1926 refuses_to_acknowledge_an_uninstalled_checkpoint,
1927 stages_reads_and_clears_an_outbound_envelope,
1928 );
1929
1930 fn race<T, F>(count: usize, body: F) -> Vec<T>
1935 where
1936 T: Send + 'static,
1937 F: Fn(usize) -> T + Send + Sync + 'static,
1938 {
1939 let barrier = Arc::new(Barrier::new(count));
1940 let body = Arc::new(body);
1941 let handles: Vec<_> = (0..count)
1942 .map(|index| {
1943 let barrier = Arc::clone(&barrier);
1944 let body = Arc::clone(&body);
1945 std::thread::spawn(move || {
1946 barrier.wait();
1947 body(index)
1948 })
1949 })
1950 .collect();
1951 handles
1952 .into_iter()
1953 .map(|handle| handle.join().expect("worker thread"))
1954 .collect()
1955 }
1956
1957 fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
1958 tokio::runtime::Builder::new_current_thread()
1959 .enable_all()
1960 .build()
1961 .expect("runtime")
1962 .block_on(future)
1963 }
1964
1965 fn assert_single_winner<T: std::fmt::Debug>(results: &[JournalResult<T>]) -> &T {
1967 let winners: Vec<&T> = results
1968 .iter()
1969 .filter_map(|result| result.as_ref().ok())
1970 .collect();
1971 assert_eq!(winners.len(), 1, "expected exactly one winner: {results:?}");
1972 for result in results {
1973 if let Err(err) = result {
1974 assert!(
1975 matches!(err, JournalError::CasConflict(_)),
1976 "loser must lose with a CAS conflict, got {err:?}"
1977 );
1978 }
1979 }
1980 winners[0]
1981 }
1982
1983 #[tokio::test]
1984 async fn two_concurrent_writers_contend_for_one_chain_position() {
1985 let dir = TempDir::new();
1986 let root = dir.path().to_path_buf();
1987 FileKernelJournal::new(&root)
1988 .compare_and_append(OP, None, record(0, "d0"))
1989 .await
1990 .unwrap();
1991
1992 let raced = root.clone();
1993 let results = race(2, move |index| {
1994 let journal = FileKernelJournal::new(&raced);
1995 block_on(journal.compare_and_append(
1996 OP,
1997 Some("d0"),
1998 payload_record(1, &format!("from-{index}"), &format!("w{index}")),
1999 ))
2000 });
2001
2002 let winner = assert_single_winner(&results);
2003 let entries = FileKernelJournal::new(&root)
2005 .read_from(OP, 0)
2006 .await
2007 .unwrap();
2008 assert_eq!(positions(&entries), vec![0, 1]);
2009 assert_eq!(entries[1].record_digest, winner.record_digest);
2010 }
2011
2012 #[tokio::test]
2013 async fn a_wide_append_storm_still_has_a_single_winner_per_position() {
2014 let dir = TempDir::new();
2015 let root = dir.path().to_path_buf();
2016 FileKernelJournal::new(&root)
2017 .compare_and_append(OP, None, record(0, "d0"))
2018 .await
2019 .unwrap();
2020
2021 let raced = root.clone();
2022 let results = race(8, move |index| {
2023 let journal = FileKernelJournal::new(&raced);
2024 block_on(journal.compare_and_append(
2025 OP,
2026 Some("d0"),
2027 payload_record(1, &format!("d1-{index}"), &format!("w{index}")),
2028 ))
2029 });
2030
2031 assert_single_winner(&results);
2032 assert_eq!(
2033 FileKernelJournal::new(&root)
2034 .read_from(OP, 0)
2035 .await
2036 .unwrap()
2037 .len(),
2038 2
2039 );
2040 }
2041
2042 #[tokio::test]
2043 async fn two_concurrent_installers_contend_for_one_checkpoint_ordinal() {
2044 let dir = TempDir::new();
2045 let root = dir.path().to_path_buf();
2046 let digests = seed_chain(&FileKernelJournal::new(&root), 2, OP).await;
2047 assert_eq!(digests[2], "d2");
2048
2049 let raced = root.clone();
2050 let results = race(2, move |index| {
2051 let journal = FileKernelJournal::new(&raced);
2052 block_on(journal.compare_and_install_checkpoint(
2055 OP,
2056 None,
2057 "d2",
2058 candidate(if index == 0 { "ck-a" } else { "ck-b" }, 2),
2059 ))
2060 });
2061
2062 assert_single_winner(&results);
2063 let installed = FileKernelJournal::new(&root)
2064 .latest_checkpoint(OP)
2065 .await
2066 .unwrap()
2067 .unwrap();
2068 assert!(["ck-a", "ck-b"].contains(&installed.checkpoint_id.as_str()));
2069 assert_eq!(installed.ordinal, 0);
2070 let names: Vec<String> = std::fs::read_dir(root.join(OP).join("checkpoints"))
2071 .unwrap()
2072 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
2073 .collect();
2074 assert_eq!(names, vec!["000000000000.ckpt".to_string()]);
2075 }
2076
2077 #[tokio::test]
2078 async fn the_atomic_claim_not_the_pre_check_decides_the_append() {
2079 let dir = TempDir::new();
2085 let committed = FileKernelJournal::new(dir.path());
2086 committed
2087 .compare_and_append(OP, None, record(0, "d0"))
2088 .await
2089 .unwrap();
2090 committed
2091 .compare_and_append(OP, Some("d0"), payload_record(1, "winner", "winner"))
2092 .await
2093 .unwrap();
2094
2095 let loser = FileKernelJournal::new(dir.path());
2096 assert_conflict(
2097 loser
2098 .publish_record(OP, Some("d0"), &payload_record(1, "loser", "loser"))
2099 .await,
2100 );
2101
2102 let entries = committed.read_from(OP, 0).await.unwrap();
2103 assert_eq!(
2104 entries
2105 .iter()
2106 .map(|entry| entry.record_digest.clone())
2107 .collect::<Vec<_>>(),
2108 vec!["d0".to_string(), "winner".to_string()]
2109 );
2110 assert_eq!(text(&entries[1].record_bytes), "winner");
2111 }
2112
2113 #[tokio::test]
2114 async fn the_atomic_claim_decides_the_checkpoint_install_too() {
2115 let dir = TempDir::new();
2116 let committed = FileKernelJournal::new(dir.path());
2117 let digests = seed_chain(&committed, 2, OP).await;
2118 committed
2119 .compare_and_install_checkpoint(OP, None, &digests[2], candidate("ck-winner", 2))
2120 .await
2121 .unwrap();
2122
2123 let loser = FileKernelJournal::new(dir.path());
2124 assert_conflict(
2125 loser
2126 .publish_checkpoint(OP, 0, None, &digests[2], &candidate("ck-loser", 2))
2127 .await,
2128 );
2129
2130 assert_eq!(
2131 committed
2132 .latest_checkpoint(OP)
2133 .await
2134 .unwrap()
2135 .unwrap()
2136 .checkpoint_id,
2137 "ck-winner"
2138 );
2139 }
2140
2141 #[tokio::test]
2142 async fn reopens_and_verifies_the_chain_ignoring_crash_residue() {
2143 let dir = TempDir::new();
2144 let journal = FileKernelJournal::new(dir.path());
2145 let digests = seed_chain(&journal, 3, OP).await;
2146
2147 let operation_dir = dir.path().join(OP);
2150 std::fs::create_dir_all(operation_dir.join("tmp")).unwrap();
2151 std::fs::write(
2152 operation_dir.join("tmp").join("half-written.tmp"),
2153 r#"{"step_seq":4,"record_dig"#,
2154 )
2155 .unwrap();
2156 std::fs::write(
2157 operation_dir
2158 .join("records")
2159 .join("000000000004.rec.partial"),
2160 r#"{"step_seq":4"#,
2161 )
2162 .unwrap();
2163 std::fs::write(operation_dir.join("records").join("notes.txt"), "scratch").unwrap();
2164
2165 let reopened = FileKernelJournal::new(dir.path());
2166 let entries = reopened.read_from(OP, 0).await.unwrap();
2167 assert_eq!(positions(&entries), vec![0, 1, 2, 3]);
2168 assert_eq!(
2169 entries
2170 .iter()
2171 .map(|entry| entry.record_digest.clone())
2172 .collect::<Vec<_>>(),
2173 digests
2174 );
2175 assert_eq!(reopened.head(OP).await.unwrap(), head(3, "d3"));
2176 reopened
2178 .compare_and_append(OP, Some("d3"), record(4, "d4"))
2179 .await
2180 .unwrap();
2181 assert_eq!(reopened.head(OP).await.unwrap(), head(4, "d4"));
2182 }
2183
2184 #[tokio::test]
2185 async fn reopens_installed_and_acknowledged_checkpoints() {
2186 let dir = TempDir::new();
2187 let journal = FileKernelJournal::new(dir.path());
2188 let digests = seed_chain(&journal, 2, OP).await;
2189 journal
2190 .compare_and_install_checkpoint(OP, None, &digests[1], candidate("ck-1", 1))
2191 .await
2192 .unwrap();
2193 journal.ack_checkpoint(OP, "ck-1").await.unwrap();
2194
2195 let reopened = FileKernelJournal::new(dir.path());
2196 let latest = reopened.latest_checkpoint(OP).await.unwrap().unwrap();
2197 assert_eq!(latest.checkpoint_id, "ck-1");
2198 assert!(latest.acknowledged);
2199 assert_eq!(latest.covered_head, "d1");
2200 assert_eq!(latest.through_step_seq, 1);
2201 assert_eq!(text(&latest.checkpoint_bytes), "checkpoint-ck-1");
2202 }
2203
2204 #[tokio::test]
2205 async fn raises_an_integrity_fault_when_a_record_contradicts_its_own_name() {
2206 let dir = TempDir::new();
2207 let journal = FileKernelJournal::new(dir.path());
2208 seed_chain(&journal, 1, OP).await;
2209 let records_dir = dir.path().join(OP).join("records");
2210
2211 std::fs::write(
2213 records_dir.join("000000000002.rec"),
2214 r#"{"step_seq":7,"record_digest":"d7","record_bytes":""}"#,
2215 )
2216 .unwrap();
2217 assert_integrity(FileKernelJournal::new(dir.path()).read_from(OP, 0).await);
2218
2219 std::fs::write(records_dir.join("000000000002.rec"), r#"{"step_seq":2"#).unwrap();
2221 assert_integrity(FileKernelJournal::new(dir.path()).read_from(OP, 0).await);
2222 }
2223
2224 #[test]
2227 fn only_exactly_padded_names_are_committed_records() {
2228 assert_eq!(parse_position("000000000004.rec", RECORD_SUFFIX), Some(4));
2229 assert_eq!(
2230 parse_position("000000000004.rec.partial", RECORD_SUFFIX),
2231 None
2232 );
2233 assert_eq!(parse_position("4.rec", RECORD_SUFFIX), None);
2234 assert_eq!(parse_position("0000000000004.rec", RECORD_SUFFIX), None);
2235 assert_eq!(parse_position("notes.txt", RECORD_SUFFIX), None);
2236 assert_eq!(parse_position("000000000004.ckpt", RECORD_SUFFIX), None);
2237 assert_eq!(pad(4), "000000000004");
2238 }
2239
2240 #[test]
2241 fn an_operation_id_can_never_name_a_directory_outside_the_journal() {
2242 assert_eq!(safe_segment("op-journal"), "op-journal");
2243 assert_eq!(safe_segment("session:1/op-1"), "session~3a1~2fop-1");
2244 assert_eq!(safe_segment(".."), "~2e~2e");
2245 assert_eq!(safe_segment("."), "~2e");
2246 assert_eq!(safe_segment(""), "~~");
2247 assert_ne!(safe_segment("a/b"), safe_segment("a-b"));
2249 }
2250
2251 #[test]
2252 fn record_bytes_survive_a_base64_round_trip() {
2253 for payload in [
2254 vec![],
2255 vec![0u8],
2256 vec![0u8, 255, 128],
2257 b"payload-2".to_vec(),
2258 (0..=255u8).collect::<Vec<u8>>(),
2259 ] {
2260 assert_eq!(base64_decode(&base64_encode(&payload)), Some(payload));
2261 }
2262 assert_eq!(
2263 base64_encode(b"any carnal pleasure."),
2264 "YW55IGNhcm5hbCBwbGVhc3VyZS4="
2265 );
2266 assert_eq!(base64_decode("not base64!"), None);
2267 }
2268
2269 #[tokio::test]
2270 async fn a_chain_position_past_the_name_space_is_refused_by_both_implementations() {
2271 let dir = TempDir::new();
2272 let file = FileKernelJournal::new(dir.path());
2273 let memory = InMemoryKernelJournal::new();
2274 for journal in [&file as &dyn KernelJournal, &memory as &dyn KernelJournal] {
2275 assert_integrity(
2276 journal
2277 .compare_and_append(OP, None, record(MAX_CHAIN_POSITION, "d-overflow"))
2278 .await,
2279 );
2280 }
2281 }
2282
2283 #[test]
2284 fn the_three_error_classes_map_onto_the_sdk_error_without_collapsing() {
2285 let conflict: crate::Error = JournalError::conflict("head moved").into();
2286 assert!(
2287 matches!(conflict, crate::Error::Other(ref message) if message.contains("head moved"))
2288 );
2289
2290 let integrity: crate::Error = JournalError::integrity("chain broken").into();
2291 assert!(
2292 matches!(integrity, crate::Error::Other(ref message) if message.contains("chain broken"))
2293 );
2294
2295 let io: crate::Error = JournalError::io(
2296 "journal could not publish a durable record",
2297 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
2298 )
2299 .into();
2300 match io {
2301 crate::Error::Io(err) => {
2302 assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
2303 assert!(err.to_string().contains("could not publish"));
2304 }
2305 other => panic!("io must stay io, got {other:?}"),
2306 }
2307 }
2308}