1use std::collections::HashMap;
2use tokio::sync::RwLock;
3
4#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
5pub enum EntryKind {
6 Incoming,
7 Internal,
8 Checkpoint,
9}
10
11#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
12pub struct LogEntry {
13 pub message_id: String,
14 pub received_at_ms: i64,
15 pub sender: String,
16 pub message_type: String,
17 pub raw_payload: Vec<u8>,
18 pub entry_kind: EntryKind,
19 #[serde(default)]
20 pub session_id: String,
21 #[serde(default)]
22 pub mode: String,
23 #[serde(default)]
24 pub macp_version: String,
25 #[serde(default)]
27 pub timestamp_unix_ms: i64,
28 #[serde(default)]
35 pub bound_mode_version: Option<String>,
36 #[serde(default)]
41 pub semantics_rev: u32,
42 #[serde(default)]
48 pub bound_max_suspend_ms: Option<i64>,
49 #[serde(default)]
55 pub compacted_incoming_ordinals: u64,
56}
57
58pub struct LogStore {
59 logs: RwLock<HashMap<String, Vec<LogEntry>>>,
60}
61
62impl Default for LogStore {
63 fn default() -> Self {
64 Self::new()
65 }
66}
67
68impl LogStore {
69 pub fn new() -> Self {
70 Self {
71 logs: RwLock::new(HashMap::new()),
72 }
73 }
74
75 pub async fn create_session_log(&self, session_id: &str) {
76 let mut guard = self.logs.write().await;
77 guard.entry(session_id.to_string()).or_default();
78 }
79
80 pub async fn append(&self, session_id: &str, entry: LogEntry) {
81 let mut guard = self.logs.write().await;
82 guard.entry(session_id.to_string()).or_default().push(entry);
83 }
84
85 pub async fn get_log(&self, session_id: &str) -> Option<Vec<LogEntry>> {
86 let guard = self.logs.read().await;
87 guard.get(session_id).cloned()
88 }
89
90 pub async fn get_incoming_after(
104 &self,
105 session_id: &str,
106 after_sequence: u64,
107 ) -> Result<Vec<(u64, LogEntry)>, u64> {
108 let guard = self.logs.read().await;
109 let Some(entries) = guard.get(session_id) else {
110 return Ok(Vec::new());
111 };
112 let base: u64 = entries
118 .iter()
119 .filter(|e| e.entry_kind == EntryKind::Checkpoint)
120 .map(|e| e.compacted_incoming_ordinals)
121 .max()
122 .unwrap_or(0);
123 if after_sequence < base {
124 return Err(base);
125 }
126 Ok(entries
127 .iter()
128 .filter(|e| e.entry_kind == EntryKind::Incoming)
129 .enumerate()
130 .map(|(i, e)| (base + (i + 1) as u64, e))
131 .filter(|(ordinal, _)| *ordinal > after_sequence)
132 .map(|(ordinal, e)| (ordinal, e.clone()))
133 .collect())
134 }
135
136 pub async fn remove_session_log(&self, session_id: &str) {
139 let mut guard = self.logs.write().await;
140 guard.remove(session_id);
141 }
142
143 pub async fn replace_session_log(&self, session_id: &str, entries: Vec<LogEntry>) {
147 let mut guard = self.logs.write().await;
148 guard.insert(session_id.to_string(), entries);
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 fn entry(id: &str, kind: EntryKind) -> LogEntry {
157 LogEntry {
158 message_id: id.into(),
159 received_at_ms: 1_700_000_000_000,
160 sender: "test".into(),
161 message_type: "Message".into(),
162 raw_payload: vec![],
163 entry_kind: kind,
164 session_id: String::new(),
165 mode: String::new(),
166 macp_version: String::new(),
167 timestamp_unix_ms: 1_700_000_000_000,
168 bound_mode_version: None,
169 semantics_rev: 0,
170 bound_max_suspend_ms: None,
171 compacted_incoming_ordinals: 0,
172 }
173 }
174
175 #[tokio::test]
176 async fn create_append_get_round_trip() {
177 let store = LogStore::new();
178 store.create_session_log("s1").await;
179 store.append("s1", entry("m1", EntryKind::Incoming)).await;
180 store.append("s1", entry("m2", EntryKind::Incoming)).await;
181
182 let log = store.get_log("s1").await.unwrap();
183 assert_eq!(log.len(), 2);
184 assert_eq!(log[0].message_id, "m1");
185 assert_eq!(log[1].message_id, "m2");
186 }
187
188 #[tokio::test]
189 async fn get_incoming_after_uses_accepted_ordinals_exclusive() {
190 let store = LogStore::new();
191 store.create_session_log("s1").await;
192 store.append("s1", entry("m0", EntryKind::Incoming)).await; store.append("s1", entry("m1", EntryKind::Internal)).await; store.append("s1", entry("m2", EntryKind::Incoming)).await; store.append("s1", entry("m3", EntryKind::Incoming)).await; store.append("s1", entry("m4", EntryKind::Checkpoint)).await; let all = store.get_incoming_after("s1", 0).await.unwrap();
202 assert_eq!(all.len(), 3);
203 assert_eq!((all[0].0, all[0].1.message_id.as_str()), (1, "m0"));
204 assert_eq!((all[1].0, all[1].1.message_id.as_str()), (2, "m2"));
205 assert_eq!((all[2].0, all[2].1.message_id.as_str()), (3, "m3"));
206
207 let after2 = store.get_incoming_after("s1", 2).await.unwrap();
210 assert_eq!(after2.len(), 1);
211 assert_eq!(after2[0].0, 3);
212 assert_eq!(after2[0].1.message_id, "m3");
213
214 let empty = store.get_incoming_after("nope", 0).await.unwrap();
216 assert!(empty.is_empty());
217 }
218
219 #[tokio::test]
220 async fn get_incoming_after_ordinals_survive_compaction() {
221 let store = LogStore::new();
222 store.create_session_log("s1").await;
223 let mut cp = entry("cp", EntryKind::Checkpoint);
226 cp.compacted_incoming_ordinals = 5;
227 store.append("s1", cp).await;
228 store.append("s1", entry("m6", EntryKind::Incoming)).await;
229 store.append("s1", entry("m7", EntryKind::Incoming)).await;
230
231 let after5 = store.get_incoming_after("s1", 5).await.unwrap();
232 assert_eq!(after5.len(), 2);
233 assert_eq!(after5[0].0, 6);
234 assert_eq!(after5[1].0, 7);
235
236 let after6 = store.get_incoming_after("s1", 6).await.unwrap();
237 assert_eq!(after6.len(), 1);
238 assert_eq!(after6[0].1.message_id, "m7");
239
240 assert!(matches!(store.get_incoming_after("s1", 3).await, Err(5)));
243 }
244}