confium_coordinator/
wal.rs1use serde::{Deserialize, Serialize};
8use std::fs::OpenOptions;
9use std::io::{BufRead, BufReader, Write};
10use std::path::{Path, PathBuf};
11use std::sync::Mutex;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct WalEntry {
16 pub seq: u64,
18 pub session_id: String,
20 pub transition: StateTransition,
22 pub timestamp: chrono::DateTime<chrono::Utc>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "snake_case")]
29pub enum StateTransition {
30 Created {
32 quorum_id: String,
33 scheme: String,
34 threshold: u32,
35 },
36 CommitmentReceived { signer_id: String },
38 ShareReceived { signer_id: String },
40 Completed,
42 Expired,
44 Aborted { reason: String },
46}
47
48pub struct SessionWal {
50 path: PathBuf,
51 next_seq: Mutex<u64>,
52}
53
54impl SessionWal {
55 pub fn open(path: impl AsRef<Path>) -> std::io::Result<Self> {
57 let path = path.as_ref().to_path_buf();
58 let next_seq = if path.exists() {
59 let max_seq = Self::read_all_from_path(&path)?
60 .into_iter()
61 .map(|e| e.seq)
62 .max()
63 .unwrap_or(0);
64 max_seq + 1
65 } else {
66 1
67 };
68 Ok(Self {
69 path,
70 next_seq: Mutex::new(next_seq),
71 })
72 }
73
74 pub fn append(&self, session_id: &str, transition: StateTransition) -> std::io::Result<u64> {
76 let seq = {
77 let mut next = self.next_seq.lock().unwrap();
78 let current = *next;
79 *next += 1;
80 current
81 };
82 let entry = WalEntry {
83 seq,
84 session_id: session_id.into(),
85 transition,
86 timestamp: chrono::Utc::now(),
87 };
88 let json = serde_json::to_string(&entry)
89 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
90 let mut file = OpenOptions::new()
91 .create(true)
92 .append(true)
93 .open(&self.path)?;
94 writeln!(file, "{json}")?;
95 file.sync_data()?;
96 Ok(seq)
97 }
98
99 pub fn read_all(&self) -> std::io::Result<Vec<WalEntry>> {
101 Self::read_all_from_path(&self.path)
102 }
103
104 fn read_all_from_path(path: &Path) -> std::io::Result<Vec<WalEntry>> {
106 if !path.exists() {
107 return Ok(Vec::new());
108 }
109 let file = OpenOptions::new().read(true).open(path)?;
110 let reader = BufReader::new(file);
111 let mut entries = Vec::new();
112 for line in reader.lines() {
113 let line = line?;
114 if line.trim().is_empty() {
115 continue;
116 }
117 match serde_json::from_str::<WalEntry>(&line) {
118 Ok(entry) => entries.push(entry),
119 Err(_) => continue,
120 }
121 }
122 Ok(entries)
123 }
124
125 pub fn truncate(&self) -> std::io::Result<()> {
127 OpenOptions::new()
128 .write(true)
129 .truncate(true)
130 .open(&self.path)?;
131 let mut next = self.next_seq.lock().unwrap();
132 *next = 1;
133 Ok(())
134 }
135
136 pub fn next_seq(&self) -> u64 {
138 *self.next_seq.lock().unwrap()
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn append_and_read() {
148 let tmp = tempfile::tempdir().unwrap();
149 let wal_path = tmp.path().join("wal.jsonl");
150 let wal = SessionWal::open(&wal_path).unwrap();
151 wal.append(
152 "s1",
153 StateTransition::Created {
154 quorum_id: "q".into(),
155 scheme: "CMP20".into(),
156 threshold: 2,
157 },
158 )
159 .unwrap();
160 wal.append(
161 "s1",
162 StateTransition::CommitmentReceived {
163 signer_id: "alice".into(),
164 },
165 )
166 .unwrap();
167 wal.append("s1", StateTransition::Completed).unwrap();
168
169 let entries = wal.read_all().unwrap();
170 assert_eq!(entries.len(), 3);
171 assert_eq!(entries[0].seq, 1);
172 assert_eq!(entries[1].seq, 2);
173 assert_eq!(entries[2].seq, 3);
174 }
175
176 #[test]
177 fn sequence_numbers_monotonic() {
178 let tmp = tempfile::tempdir().unwrap();
179 let wal = SessionWal::open(tmp.path().join("wal.jsonl")).unwrap();
180 let s1 = wal.append("s1", StateTransition::Completed).unwrap();
181 let s2 = wal.append("s1", StateTransition::Expired).unwrap();
182 let s3 = wal.append("s2", StateTransition::Completed).unwrap();
183 assert!(s1 < s2);
184 assert!(s2 < s3);
185 }
186
187 #[test]
188 fn reopen_continues_sequence() {
189 let tmp = tempfile::tempdir().unwrap();
190 let wal_path = tmp.path().join("wal.jsonl");
191 {
192 let wal = SessionWal::open(&wal_path).unwrap();
193 wal.append("s1", StateTransition::Completed).unwrap();
194 wal.append("s1", StateTransition::Expired).unwrap();
195 }
196 {
197 let wal = SessionWal::open(&wal_path).unwrap();
198 assert_eq!(wal.next_seq(), 3);
199 let seq = wal.append("s2", StateTransition::Completed).unwrap();
200 assert_eq!(seq, 3);
201 }
202 }
203
204 #[test]
205 fn truncate_clears_entries() {
206 let tmp = tempfile::tempdir().unwrap();
207 let wal = SessionWal::open(tmp.path().join("wal.jsonl")).unwrap();
208 wal.append("s1", StateTransition::Completed).unwrap();
209 wal.truncate().unwrap();
210 assert_eq!(wal.read_all().unwrap().len(), 0);
211 assert_eq!(wal.next_seq(), 1);
212 }
213
214 #[test]
215 fn empty_wal_returns_empty_vec() {
216 let tmp = tempfile::tempdir().unwrap();
217 let wal = SessionWal::open(tmp.path().join("nonexistent.jsonl")).unwrap();
218 assert!(wal.read_all().unwrap().is_empty());
219 }
220
221 #[test]
222 fn all_transition_types_serialize() {
223 let transitions = vec![
224 StateTransition::Created {
225 quorum_id: "q".into(),
226 scheme: "CMP20".into(),
227 threshold: 2,
228 },
229 StateTransition::CommitmentReceived {
230 signer_id: "a".into(),
231 },
232 StateTransition::ShareReceived {
233 signer_id: "a".into(),
234 },
235 StateTransition::Completed,
236 StateTransition::Expired,
237 StateTransition::Aborted {
238 reason: "test".into(),
239 },
240 ];
241 for t in &transitions {
242 let json = serde_json::to_string(t).unwrap();
243 let recovered: StateTransition = serde_json::from_str(&json).unwrap();
244 assert_eq!(t, &recovered);
245 }
246 }
247
248 #[test]
249 fn corrupt_lines_skipped() {
250 let tmp = tempfile::tempdir().unwrap();
251 let path = tmp.path().join("wal.jsonl");
252 std::fs::write(&path, "not json\n{\"seq\":1,\"session_id\":\"s\",\"transition\":{\"created\":{\"quorum_id\":\"q\",\"scheme\":\"CMP20\",\"threshold\":2}},\"timestamp\":\"2026-01-01T00:00:00Z\"}\n").unwrap();
253 let wal = SessionWal::open(&path).unwrap();
254 let entries = wal.read_all().unwrap();
255 assert_eq!(entries.len(), 1);
256 assert_eq!(entries[0].session_id, "s");
257 }
258}