Skip to main content

mongreldb_log/
mem.rs

1//! In-memory [`CommitLog`] implementation (spec section 9.4, FND-004).
2//!
3//! [`InMemoryCommitLog`] is a thread-safe, fully in-memory commit log for
4//! tests, embedded single-process use, and deterministic simulation. It keeps
5//! every committed entry in memory and assigns strictly increasing positions:
6//! `term` is always zero and `index` starts at one, so [`LogPosition::ZERO`]
7//! precedes every entry. The log applies each command the moment it commits,
8//! so [`CommitLog::applied_position`] advances as entries are proposed.
9//! Receipts report [`DurabilityLevel::GroupCommit`] only nominally — nothing
10//! is ever fsynced.
11//!
12//! [`CommitLog::create_snapshot`] captures the committed entries through the
13//! applied position in a versioned, self-describing byte format, and
14//! [`CommitLog::install_snapshot`] replaces the log state with them, so a
15//! snapshot taken on one log installs cleanly into another. The in-memory
16//! snapshot retains full entries (no compaction) to keep behavior observable
17//! in tests.
18
19use core::fmt;
20use std::sync::Mutex;
21use std::time::{SystemTime, UNIX_EPOCH};
22
23use mongreldb_types::hlc::HlcTimestamp;
24use mongreldb_types::ids::TransactionId;
25
26use crate::commit_log::{
27    CommitLog, CommitReceipt, CommittedEntry, DurabilityLevel, ExecutionControl, LogError,
28    LogPosition, LogSnapshot,
29};
30use crate::envelope::CommandEnvelope;
31
32/// An injectable source of commit timestamps for [`InMemoryCommitLog`].
33///
34/// The source is called once per accepted proposal, under the log lock, and
35/// should be non-decreasing so commit timestamps order with log positions.
36pub type TimestampSource = Box<dyn FnMut() -> HlcTimestamp + Send>;
37
38const SNAPSHOT_MAGIC: [u8; 8] = *b"MLOGSNAP";
39const SNAPSHOT_FORMAT_VERSION: u32 = 1;
40
41struct State {
42    entries: Vec<CommittedEntry>,
43    next_index: u64,
44    timestamp_source: TimestampSource,
45}
46
47/// Thread-safe in-memory [`CommitLog`]; see the module documentation.
48pub struct InMemoryCommitLog {
49    state: Mutex<State>,
50}
51
52impl InMemoryCommitLog {
53    /// Creates an empty log whose commit timestamps come from the system
54    /// clock in microseconds (`logical` and `node_tiebreaker` are zero).
55    pub fn new() -> Self {
56        Self::with_timestamp_source(Box::new(system_time_micros))
57    }
58
59    /// Creates an empty log with an injectable commit-timestamp source.
60    pub fn with_timestamp_source(timestamp_source: TimestampSource) -> Self {
61        Self {
62            state: Mutex::new(State {
63                entries: Vec::new(),
64                next_index: 1,
65                timestamp_source,
66            }),
67        }
68    }
69}
70
71impl Default for InMemoryCommitLog {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl fmt::Debug for InMemoryCommitLog {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        let mut d = f.debug_struct("InMemoryCommitLog");
80        match self.state.lock() {
81            Ok(state) => d
82                .field("entries", &state.entries.len())
83                .field("next_index", &state.next_index)
84                .finish(),
85            Err(_) => d.finish_non_exhaustive(),
86        }
87    }
88}
89
90impl CommitLog for InMemoryCommitLog {
91    /// Verifies the envelope, honors cancellation/deadline, then commits and
92    /// applies the command in one step. The receipt's transaction id is
93    /// derived from the envelope's command id (the idempotent-apply
94    /// identifier).
95    fn propose(
96        &self,
97        command: CommandEnvelope,
98        control: &ExecutionControl,
99    ) -> Result<CommitReceipt, LogError> {
100        control.check()?;
101        command.verify()?;
102        let mut state = self
103            .state
104            .lock()
105            .map_err(|_| LogError::Internal("in-memory commit log lock poisoned".to_owned()))?;
106        let position = LogPosition {
107            term: 0,
108            index: state.next_index,
109        };
110        state.next_index += 1;
111        let commit_ts = (state.timestamp_source)();
112        let command_id = command.command_id;
113        state.entries.push(CommittedEntry {
114            position,
115            commit_ts,
116            envelope: command,
117        });
118        Ok(CommitReceipt {
119            transaction_id: TransactionId::from_bytes(command_id),
120            commit_ts,
121            log_position: position,
122            durability: DurabilityLevel::GroupCommit,
123        })
124    }
125
126    fn read_committed(
127        &self,
128        after: LogPosition,
129        limit: usize,
130    ) -> Result<Vec<CommittedEntry>, LogError> {
131        let state = self
132            .state
133            .lock()
134            .map_err(|_| LogError::Internal("in-memory commit log lock poisoned".to_owned()))?;
135        Ok(state
136            .entries
137            .iter()
138            .filter(|entry| entry.position > after)
139            .take(limit)
140            .cloned()
141            .collect())
142    }
143
144    fn applied_position(&self) -> LogPosition {
145        self.state
146            .lock()
147            .expect("in-memory commit log lock poisoned")
148            .entries
149            .last()
150            .map_or(LogPosition::ZERO, |entry| entry.position)
151    }
152
153    fn create_snapshot(&self) -> Result<LogSnapshot, LogError> {
154        let state = self
155            .state
156            .lock()
157            .map_err(|_| LogError::Internal("in-memory commit log lock poisoned".to_owned()))?;
158        let (position, commit_ts) = state
159            .entries
160            .last()
161            .map_or((LogPosition::ZERO, HlcTimestamp::ZERO), |entry| {
162                (entry.position, entry.commit_ts)
163            });
164        Ok(LogSnapshot {
165            position,
166            commit_ts,
167            data: encode_snapshot(&state.entries),
168        })
169    }
170
171    fn install_snapshot(&self, snapshot: LogSnapshot) -> Result<(), LogError> {
172        let entries = decode_snapshot(&snapshot.data)?;
173        match entries.last() {
174            Some(last) if last.position != snapshot.position => {
175                return Err(LogError::Internal(
176                    "in-memory snapshot position does not match its last entry".to_owned(),
177                ));
178            }
179            None if snapshot.position != LogPosition::ZERO => {
180                return Err(LogError::Internal(
181                    "in-memory snapshot carries no entries but a nonzero position".to_owned(),
182                ));
183            }
184            _ => {}
185        }
186        let mut state = self
187            .state
188            .lock()
189            .map_err(|_| LogError::Internal("in-memory commit log lock poisoned".to_owned()))?;
190        state.entries = entries;
191        state.next_index = snapshot.position.index + 1;
192        Ok(())
193    }
194}
195
196/// Commit timestamp from the system clock in microseconds since the Unix
197/// epoch; the default [`TimestampSource`].
198fn system_time_micros() -> HlcTimestamp {
199    let physical_micros = SystemTime::now()
200        .duration_since(UNIX_EPOCH)
201        .map_or(0, |d| d.as_micros() as u64);
202    HlcTimestamp {
203        physical_micros,
204        logical: 0,
205        node_tiebreaker: 0,
206    }
207}
208
209/// Serializes committed entries into the versioned, deterministic snapshot
210/// byte format. Every envelope uses its canonical encoding, so the checksum
211/// is re-verified on decode.
212fn encode_snapshot(entries: &[CommittedEntry]) -> Vec<u8> {
213    let mut out = Vec::new();
214    out.extend_from_slice(&SNAPSHOT_MAGIC);
215    out.extend_from_slice(&SNAPSHOT_FORMAT_VERSION.to_le_bytes());
216    out.extend_from_slice(&(entries.len() as u64).to_le_bytes());
217    for entry in entries {
218        out.extend_from_slice(&entry.position.term.to_le_bytes());
219        out.extend_from_slice(&entry.position.index.to_le_bytes());
220        out.extend_from_slice(&entry.commit_ts.physical_micros.to_le_bytes());
221        out.extend_from_slice(&entry.commit_ts.logical.to_le_bytes());
222        out.extend_from_slice(&entry.commit_ts.node_tiebreaker.to_le_bytes());
223        let envelope = entry.envelope.encode();
224        out.extend_from_slice(&(envelope.len() as u32).to_le_bytes());
225        out.extend_from_slice(&envelope);
226    }
227    out
228}
229
230fn malformed(reason: &str) -> LogError {
231    LogError::Internal(format!("malformed in-memory snapshot: {reason}"))
232}
233
234/// Byte cursor over snapshot data that fails closed on truncation.
235struct Cursor<'a> {
236    bytes: &'a [u8],
237    offset: usize,
238}
239
240impl<'a> Cursor<'a> {
241    fn take(&mut self, len: usize) -> Result<&'a [u8], LogError> {
242        let end = self
243            .offset
244            .checked_add(len)
245            .filter(|end| *end <= self.bytes.len())
246            .ok_or_else(|| malformed("truncated"))?;
247        let slice = &self.bytes[self.offset..end];
248        self.offset = end;
249        Ok(slice)
250    }
251
252    fn read_u32(&mut self) -> Result<u32, LogError> {
253        Ok(u32::from_le_bytes(
254            self.take(4)?.try_into().expect("slice len"),
255        ))
256    }
257
258    fn read_u64(&mut self) -> Result<u64, LogError> {
259        Ok(u64::from_le_bytes(
260            self.take(8)?.try_into().expect("slice len"),
261        ))
262    }
263
264    fn remaining(&self) -> usize {
265        self.bytes.len() - self.offset
266    }
267}
268
269/// Parses snapshot data produced by [`encode_snapshot`], verifying every
270/// envelope and rejecting truncated or trailing bytes.
271fn decode_snapshot(data: &[u8]) -> Result<Vec<CommittedEntry>, LogError> {
272    let mut cursor = Cursor {
273        bytes: data,
274        offset: 0,
275    };
276    if cursor.take(SNAPSHOT_MAGIC.len())? != SNAPSHOT_MAGIC.as_slice() {
277        return Err(malformed("bad magic"));
278    }
279    let format_version = cursor.read_u32()?;
280    if format_version != SNAPSHOT_FORMAT_VERSION {
281        return Err(malformed("unsupported format version"));
282    }
283    let entry_count = cursor.read_u64()?;
284    let mut entries = Vec::with_capacity(entry_count.min(1024) as usize);
285    for _ in 0..entry_count {
286        let term = cursor.read_u64()?;
287        let index = cursor.read_u64()?;
288        let physical_micros = cursor.read_u64()?;
289        let logical = cursor.read_u32()?;
290        let node_tiebreaker = cursor.read_u32()?;
291        let envelope_len = cursor.read_u32()? as usize;
292        let envelope = CommandEnvelope::decode(cursor.take(envelope_len)?)?;
293        entries.push(CommittedEntry {
294            position: LogPosition { term, index },
295            commit_ts: HlcTimestamp {
296                physical_micros,
297                logical,
298                node_tiebreaker,
299            },
300            envelope,
301        });
302    }
303    if cursor.remaining() != 0 {
304        return Err(malformed("trailing bytes"));
305    }
306    Ok(entries)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn envelope(id: u8) -> CommandEnvelope {
314        CommandEnvelope::new(1, [id; 16], vec![id; 8])
315    }
316
317    #[test]
318    fn empty_log_starts_at_zero() {
319        let log = InMemoryCommitLog::new();
320        assert_eq!(log.applied_position(), LogPosition::ZERO);
321        assert!(log
322            .read_committed(LogPosition::ZERO, 10)
323            .unwrap()
324            .is_empty());
325        let snapshot = log.create_snapshot().unwrap();
326        assert_eq!(snapshot.position, LogPosition::ZERO);
327        assert_eq!(snapshot.commit_ts, HlcTimestamp::ZERO);
328    }
329
330    #[test]
331    fn malformed_snapshot_data_is_rejected() {
332        let log = InMemoryCommitLog::new();
333        let snapshot = LogSnapshot {
334            position: LogPosition::ZERO,
335            commit_ts: HlcTimestamp::ZERO,
336            data: b"garbage".to_vec(),
337        };
338        assert!(matches!(
339            log.install_snapshot(snapshot),
340            Err(LogError::Internal(_))
341        ));
342    }
343
344    #[test]
345    fn snapshot_position_mismatch_is_rejected() {
346        let source = InMemoryCommitLog::new();
347        source
348            .propose(envelope(1), &ExecutionControl::default())
349            .unwrap();
350        let mut snapshot = source.create_snapshot().unwrap();
351        snapshot.position = LogPosition { term: 0, index: 99 };
352        let log = InMemoryCommitLog::new();
353        assert!(matches!(
354            log.install_snapshot(snapshot),
355            Err(LogError::Internal(_))
356        ));
357        assert_eq!(log.applied_position(), LogPosition::ZERO);
358    }
359}