macp_storage/storage/
compaction.rs1use crate::log_store::{EntryKind, LogEntry};
2use crate::registry::PersistedSession;
3use macp_core::session::Session;
4use std::io;
5
6use super::StorageBackend;
7
8pub async fn compact_session_log(
14 storage: &dyn StorageBackend,
15 session_id: &str,
16 session: &Session,
17 discarded_incoming_ordinals: u64,
18) -> io::Result<LogEntry> {
19 let persisted = PersistedSession::from(session);
20 let raw_payload = serde_json::to_vec(&persisted)
21 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
22
23 let now = chrono::Utc::now().timestamp_millis();
24 let checkpoint = LogEntry {
25 message_id: String::new(),
26 received_at_ms: now,
27 sender: "_runtime".into(),
28 message_type: "Checkpoint".into(),
29 raw_payload,
30 entry_kind: EntryKind::Checkpoint,
31 session_id: session_id.into(),
32 mode: session.mode.clone(),
33 macp_version: String::new(),
34 timestamp_unix_ms: now,
35 bound_mode_version: None,
38 semantics_rev: 0,
39 bound_max_suspend_ms: None,
40 compacted_incoming_ordinals: discarded_incoming_ordinals,
45 };
46
47 storage
48 .replace_log(session_id, std::slice::from_ref(&checkpoint))
49 .await?;
50 Ok(checkpoint)
51}
52
53#[cfg(test)]
54mod tests {
55 use super::*;
56 use crate::storage::FileBackend;
57
58 use std::collections::HashSet;
59
60 fn sample_session(id: &str) -> Session {
61 Session::builder(id, "macp.mode.decision.v1", "alice")
62 .ttl_expiry(61_000)
63 .ttl_ms(60_000)
64 .started_at_unix_ms(1_000)
65 .mode_state(vec![1, 2, 3])
66 .participants(vec!["alice".into(), "bob".into()])
67 .seen_message_ids(HashSet::from(["m1".into()]))
68 .intent("test intent")
69 .mode_version("1.0.0")
70 .configuration_version("cfg-1")
71 .policy_version("pol-1")
72 .context_id("test-ctx")
73 .roots(vec![macp_pb::pb::Root {
74 uri: "root://1".into(),
75 name: "r1".into(),
76 }])
77 .build()
78 }
79
80 fn sample_entry(id: &str) -> LogEntry {
81 LogEntry {
82 message_id: id.into(),
83 received_at_ms: 1_700_000_000_000,
84 sender: "alice".into(),
85 message_type: "Message".into(),
86 raw_payload: vec![],
87 entry_kind: EntryKind::Incoming,
88 session_id: String::new(),
89 mode: String::new(),
90 macp_version: String::new(),
91 timestamp_unix_ms: 1_700_000_000_000,
92 bound_mode_version: None,
93 semantics_rev: 0,
94 bound_max_suspend_ms: None,
95 compacted_incoming_ordinals: 0,
96 }
97 }
98
99 #[tokio::test]
100 async fn compaction_replaces_log_with_single_checkpoint() {
101 let dir = tempfile::tempdir().unwrap();
102 let backend = FileBackend::new(dir.path().to_path_buf()).unwrap();
103 backend.create_session_storage("s1").await.unwrap();
104
105 for i in 0..5 {
106 backend
107 .append_log_entry("s1", &sample_entry(&format!("m{i}")))
108 .await
109 .unwrap();
110 }
111 assert_eq!(backend.load_log("s1").await.unwrap().len(), 5);
112
113 let session = sample_session("s1");
114 let checkpoint = compact_session_log(&backend, "s1", &session, 5)
115 .await
116 .unwrap();
117
118 assert_eq!(checkpoint.entry_kind, EntryKind::Checkpoint);
119 assert_eq!(checkpoint.compacted_incoming_ordinals, 5);
120 assert_eq!(checkpoint.session_id, "s1");
121 assert_eq!(checkpoint.mode, "macp.mode.decision.v1");
122
123 let persisted: PersistedSession = serde_json::from_slice(&checkpoint.raw_payload).unwrap();
125 assert_eq!(persisted.session_id, "s1");
126 assert_eq!(persisted.mode, "macp.mode.decision.v1");
127 assert_eq!(persisted.participants, vec!["alice", "bob"]);
128 assert_eq!(persisted.ttl_ms, 60_000);
129
130 let log = backend.load_log("s1").await.unwrap();
132 assert_eq!(log.len(), 1);
133 assert_eq!(log[0].entry_kind, EntryKind::Checkpoint);
134 assert_eq!(log[0].compacted_incoming_ordinals, 5);
135 let reloaded: PersistedSession = serde_json::from_slice(&log[0].raw_payload).unwrap();
136 assert_eq!(reloaded.session_id, "s1");
137 }
138
139 #[tokio::test]
140 async fn append_after_compaction_keeps_checkpoint_first() {
141 let dir = tempfile::tempdir().unwrap();
142 let backend = FileBackend::new(dir.path().to_path_buf()).unwrap();
143 backend.create_session_storage("s1").await.unwrap();
144
145 for i in 0..3 {
146 backend
147 .append_log_entry("s1", &sample_entry(&format!("m{i}")))
148 .await
149 .unwrap();
150 }
151
152 let session = sample_session("s1");
153 compact_session_log(&backend, "s1", &session, 3)
154 .await
155 .unwrap();
156
157 backend
158 .append_log_entry("s1", &sample_entry("post-compaction"))
159 .await
160 .unwrap();
161
162 let log = backend.load_log("s1").await.unwrap();
163 assert_eq!(log.len(), 2);
164 assert_eq!(log[0].entry_kind, EntryKind::Checkpoint);
165 assert_eq!(log[1].entry_kind, EntryKind::Incoming);
166 assert_eq!(log[1].message_id, "post-compaction");
167 }
168}