Skip to main content

mongreldb_core/
commit_log.rs

1//! Standalone [`mongreldb_log::CommitLog`] adapter (spec section 9.4, FND-004).
2//!
3//! Implemented in the Stage 0 foundation wave: wraps the shared WAL and group
4//! commit so the transaction commit path proposes versioned command envelopes
5//! through the `CommitLog` interface, and the apply path observes only
6//! committed commands.
7//!
8//! ## Stage 0 wiring choice
9//!
10//! The commit sequencer (`Database::commit_transaction_with_external_states_inner`
11//! and the DDL commit paths) keeps the existing v4 WAL record format: a full
12//! envelope dual-write would change on-disk bytes and break the "current
13//! database format opens unchanged" Stage 0 gate. Instead this adapter **owns**
14//! the append + group-commit durability steps those paths already performed —
15//! [`Self::append_transaction`] writes the transaction command's records and
16//! its commit marker, [`Self::seal_transaction`] drives group commit and issues
17//! the [`CommitReceipt`] — and `Database` gates `publish_in_order` (reader
18//! visibility) on that receipt, so spec section 9.4's critical rule ("the
19//! storage apply path receives only committed commands") is structurally true.
20//!
21//! Generic commands proposed through [`CommitLog::propose`] are persisted as
22//! [`DdlOp::Command`] records carrying one encoded
23//! [`mongreldb_log::CommandEnvelope`]; [`CommitLog::read_committed`] replays
24//! them with the existing WAL reader.
25//!
26//! ## Dual timestamp model (ADR-0003)
27//!
28//! Stage 1B wires the node's real [`HlcClock`] (spec section 8.2) through this
29//! adapter: every commit receipt's `commit_ts` is allocated from the one core
30//! clock — the transaction commit path assigns it under the sequencer lock
31//! strictly after the transaction's read timestamp (spec section 8.2's
32//! commit-timestamp rule) — and the durable `Op::CommitTimestamp` WAL ledger
33//! records the same timestamp's physical component (byte format unchanged).
34//! During the migration, however, **`Epoch(u64)` remains the reader-visibility
35//! counter**: snapshots pin epochs, row versions carry epochs, and the WAL
36//! `TxnCommit` marker orders by epoch. HLC timestamps are the commit/identity
37//! timestamp of record (receipts, PITR's epoch↔nanos ledger); the epoch↔HLC
38//! row-version cut-over arrives with the section 8.4 migration work, never
39//! inferring format from byte length.
40
41use std::path::PathBuf;
42use std::sync::Arc;
43
44use mongreldb_log::{
45    CommandEnvelope, CommitLog, CommitReceipt, CommittedEntry, DurabilityLevel, LogError,
46    LogPosition, LogSnapshot,
47};
48use mongreldb_types::hlc::{HlcClock, HlcTimestamp};
49use mongreldb_types::ids::TransactionId;
50use parking_lot::Mutex;
51use zeroize::Zeroizing;
52
53use crate::epoch::{Epoch, EpochAuthority};
54use crate::txn::GroupCommit;
55use crate::wal::{AddedRun, DdlOp, Op, SharedWal};
56use crate::MongrelError;
57
58/// Reserved [`CommandEnvelope::command_type`] for the versioned transaction
59/// command produced by the commit sequencer (spec section 9.3, FND-003).
60pub const COMMAND_TYPE_TRANSACTION: u32 = 1;
61
62/// Converts core's rich [`crate::ExecutionControl`] into the log crate's
63/// minimal mirror (see `docs/architecture/adr/0002`).
64///
65/// The deadline maps directly. Core's cancellation hierarchy (parent/child
66/// reasons, first-event-wins ordering) is not representable as one shared
67/// atomic flag, so `cancellation` is left `None`: callers enforce cancellation
68/// through `ExecutionControl::checkpoint` before proposing, exactly as the
69/// commit sequencer already does.
70pub fn to_log_control(control: &crate::ExecutionControl) -> mongreldb_log::ExecutionControl {
71    mongreldb_log::ExecutionControl {
72        deadline: control.deadline(),
73        cancellation: None,
74    }
75}
76
77/// Maps an injected fault at a durability boundary to the closest existing
78/// engine error (spec section 9.6, FND-006). Every production failure at the
79/// WAL append, fsync, and commit-publication boundaries reaches the engine as
80/// an I/O error, so `MongrelError::Io` makes injected faults indistinguishable
81/// from real device failures: they exercise the same poison and
82/// unknown-outcome paths without a new error variant.
83pub(crate) fn fault_as_io(fault: mongreldb_fault::Fault) -> MongrelError {
84    MongrelError::Io(std::io::Error::other(fault))
85}
86
87/// Maps a per-open WAL transaction id onto a [`TransactionId`]. The mapping is
88/// injective within one open generation; the full 128-bit transaction
89/// identifier lands with the cluster id work (spec section 7).
90pub(crate) fn transaction_id_from_txn(txn_id: u64) -> TransactionId {
91    let mut bytes = [0u8; 16];
92    bytes[..8].copy_from_slice(&txn_id.to_le_bytes());
93    TransactionId::from_bytes(bytes)
94}
95
96/// The physical component of an HLC commit timestamp as WAL-ledger nanos
97/// (spec section 8.1: `physical_micros` × 1_000). The `Op::CommitTimestamp`
98/// byte format is unchanged; only the source (the node's HLC clock instead of
99/// a raw wall-clock read) changed, keeping PITR's epoch↔nanos mapping
100/// consistent with receipt timestamps.
101pub(crate) fn commit_nanos(commit_ts: HlcTimestamp) -> u64 {
102    commit_ts.physical_micros.saturating_mul(1_000)
103}
104
105/// The standalone commit log (spec section 9.4): one shared WAL + one
106/// group-commit coordinator are the single authority through which commands
107/// become committed. `term` is always 0; the log index is the commit epoch.
108pub struct StandaloneCommitLog {
109    wal: Arc<Mutex<SharedWal>>,
110    group: Arc<GroupCommit>,
111    epoch: Arc<EpochAuthority>,
112    /// Serializes [`CommitLog::propose`] against the database commit sequencer
113    /// so a proposed command cannot move the assigned-epoch counter underneath
114    /// an in-flight commit.
115    commit_lock: Arc<Mutex<()>>,
116    /// Shared per-open transaction-id allocator (same one `Database` uses).
117    txn_ids: Arc<Mutex<u64>>,
118    /// Database root, for WAL replay in [`CommitLog::read_committed`].
119    root: PathBuf,
120    /// WAL DEK for replaying encrypted segments; `None` for plaintext.
121    wal_dek: Option<Zeroizing<[u8; 32]>>,
122    /// The node's single HLC timestamp authority (spec sections 4.1, 8.2),
123    /// shared with the owning `DatabaseCore`. Receipt commit timestamps are
124    /// allocated here; the transaction commit path assigns them under the
125    /// sequencer lock (strictly after the transaction's read timestamp) and
126    /// passes them in, so the receipt and the durable WAL ledger record the
127    /// same timestamp.
128    clock: Arc<HlcClock>,
129}
130
131impl StandaloneCommitLog {
132    #[allow(clippy::too_many_arguments)]
133    pub(crate) fn new(
134        wal: Arc<Mutex<SharedWal>>,
135        group: Arc<GroupCommit>,
136        epoch: Arc<EpochAuthority>,
137        commit_lock: Arc<Mutex<()>>,
138        txn_ids: Arc<Mutex<u64>>,
139        root: PathBuf,
140        wal_dek: Option<Zeroizing<[u8; 32]>>,
141        clock: Arc<HlcClock>,
142    ) -> Self {
143        Self {
144            wal,
145            group,
146            epoch,
147            commit_lock,
148            txn_ids,
149            root,
150            wal_dek,
151            clock,
152        }
153    }
154
155    /// Owns the WAL append step of the commit sequencer (spec section 9.4):
156    /// writes the transaction command's records followed by its commit marker
157    /// and returns the commit record's WAL sequence. The caller holds the
158    /// database commit lock and the WAL lock; no fsync happens here.
159    ///
160    /// `commit_ts` is the timestamp the sequencer assigned (S1B-004 step 5);
161    /// its physical component is recorded in the durable
162    /// `Op::CommitTimestamp` ledger record ahead of the commit marker.
163    pub(crate) fn append_transaction(
164        &self,
165        wal: &mut SharedWal,
166        txn_id: u64,
167        epoch: Epoch,
168        commit_ts: HlcTimestamp,
169        records: Vec<(u64, Op)>,
170        added_runs: &[AddedRun],
171    ) -> Result<u64, LogError> {
172        for (table_id, op) in records {
173            wal.append(txn_id, table_id, op)
174                .map_err(|error| LogError::Internal(error.to_string()))?;
175        }
176        wal.append_commit_at(txn_id, epoch, added_runs, commit_nanos(commit_ts))
177            .map_err(|error| LogError::Internal(error.to_string()))
178    }
179
180    /// Owns the group-commit durability step of the commit sequencer: blocks
181    /// until `commit_seq` is durable (one leader fsync serves the batch) and
182    /// issues the irrevocable receipt that gates visibility publication.
183    ///
184    /// `commit_ts` is `Some` when the caller already assigned the timestamp
185    /// (the transaction sequencer, S1B-004 step 5); `None` asks the clock
186    /// for a fresh one (DDL/maintenance commits and [`CommitLog::propose`]).
187    pub(crate) fn seal_transaction(
188        &self,
189        txn_id: u64,
190        epoch: Epoch,
191        commit_seq: u64,
192        commit_ts: Option<HlcTimestamp>,
193    ) -> Result<CommitReceipt, LogError> {
194        self.group
195            .await_durable(&self.wal, commit_seq)
196            .map_err(|error| LogError::Internal(error.to_string()))?;
197        let commit_ts =
198            commit_ts.unwrap_or_else(|| self.clock.commit_timestamp(std::iter::empty()));
199        Ok(CommitReceipt {
200            transaction_id: transaction_id_from_txn(txn_id),
201            commit_ts,
202            log_position: LogPosition {
203                term: 0,
204                index: epoch.0,
205            },
206            durability: DurabilityLevel::GroupCommit,
207        })
208    }
209}
210
211impl CommitLog for StandaloneCommitLog {
212    /// Persists one command envelope as a committed WAL transaction
213    /// ([`DdlOp::Command`] record + commit marker) and waits for group-commit
214    /// durability. The assigned epoch is published once durable; on any error
215    /// the ticket is abandoned so the visibility watermark never stalls behind
216    /// an epoch hole.
217    fn propose(
218        &self,
219        command: CommandEnvelope,
220        control: &mongreldb_log::ExecutionControl,
221    ) -> Result<CommitReceipt, LogError> {
222        command.verify()?;
223        control.check()?;
224        let _commit = self.commit_lock.lock();
225        let epoch = self.epoch.bump_assigned();
226        let result = (|| {
227            let txn_id = crate::txn::allocate_txn_id(&self.txn_ids)
228                .map_err(|error| LogError::Internal(error.to_string()))?;
229            // Assign the commit timestamp before the append (S1B-004 step 5)
230            // so the receipt and the durable WAL ledger record agree.
231            let commit_ts = self.clock.commit_timestamp(std::iter::empty());
232            let record = Op::Ddl(DdlOp::Command {
233                payload: command.encode(),
234            });
235            let commit_seq = {
236                let mut wal = self.wal.lock();
237                wal.append(txn_id, crate::database::WAL_TABLE_ID, record)
238                    .and_then(|_| wal.append_commit_at(txn_id, epoch, &[], commit_nanos(commit_ts)))
239                    .map_err(|error| LogError::Internal(error.to_string()))?
240            };
241            let receipt = self.seal_transaction(txn_id, epoch, commit_seq, Some(commit_ts))?;
242            mongreldb_fault::inject("commit.publish.before")
243                .map_err(|fault| LogError::Internal(fault.to_string()))?;
244            self.epoch.publish_in_order(epoch);
245            mongreldb_fault::inject("commit.publish.after")
246                .map_err(|fault| LogError::Internal(fault.to_string()))?;
247            Ok(receipt)
248        })();
249        if result.is_err() {
250            // A failed proposal commits no data: abandon the assigned ticket so
251            // later publishes are not gated on an epoch hole.
252            self.epoch.abandon(epoch);
253        }
254        result
255    }
256
257    /// Replays committed command envelopes with `position.index > after.index`
258    /// in commit order, using the existing WAL reader. Only transactions sealed
259    /// by a durable `TxnCommit` marker are returned, and replay is constrained
260    /// to the authenticated durable WAL head, so unacknowledged appends never
261    /// surface. `after.term` is ignored: the standalone log has a single term.
262    ///
263    /// Reconstructed `commit_ts` values carry only the physical component: the
264    /// WAL ledger stores `physical_micros` as nanos, not the HLC logical
265    /// counter or node tiebreaker (byte format unchanged from the Stage 0
266    /// gate). Within one open, receipts issued by [`Self::seal_transaction`]
267    /// remain the exact commit timestamps; replayed values order identically
268    /// at microsecond granularity.
269    fn read_committed(
270        &self,
271        after: LogPosition,
272        limit: usize,
273    ) -> Result<Vec<CommittedEntry>, LogError> {
274        // Serialize against segment rotation, GC, and WAL-head rewrites.
275        let _wal = self.wal.lock();
276        let records = SharedWal::replay_with_dek(&self.root, self.wal_dek.as_ref())
277            .map_err(|error| LogError::Internal(error.to_string()))?;
278        let mut commits = std::collections::HashMap::new();
279        let mut timestamps = std::collections::HashMap::new();
280        for record in &records {
281            match record.op {
282                Op::TxnCommit { epoch, .. } => {
283                    commits.insert(record.txn_id, epoch);
284                }
285                Op::CommitTimestamp { unix_nanos } => {
286                    timestamps.insert(record.txn_id, unix_nanos);
287                }
288                _ => {}
289            }
290        }
291        let mut entries = Vec::new();
292        for record in &records {
293            if entries.len() >= limit {
294                break;
295            }
296            let Op::Ddl(DdlOp::Command { payload }) = &record.op else {
297                continue;
298            };
299            let Some(&epoch) = commits.get(&record.txn_id) else {
300                continue;
301            };
302            if epoch <= after.index {
303                continue;
304            }
305            let envelope = CommandEnvelope::decode(payload)?;
306            let physical_micros = timestamps.get(&record.txn_id).copied().unwrap_or(0) / 1_000;
307            entries.push(CommittedEntry {
308                position: LogPosition {
309                    term: 0,
310                    index: epoch,
311                },
312                commit_ts: HlcTimestamp {
313                    physical_micros,
314                    logical: 0,
315                    node_tiebreaker: 0,
316                },
317                envelope,
318            });
319        }
320        Ok(entries)
321    }
322
323    /// The highest WAL sequence made durable by group commit. In standalone
324    /// mode the local state machine applies everything the WAL makes durable.
325    ///
326    /// Note the Stage 0 units: receipt positions use the commit epoch while
327    /// this watermark uses the WAL record sequence (`durable_seq`), which
328    /// advances by several records per commit. Stage 2 unifies both behind one
329    /// replicated log index.
330    fn applied_position(&self) -> LogPosition {
331        LogPosition {
332            term: 0,
333            index: self.wal.lock().durable_seq(),
334        }
335    }
336
337    /// Unsupported in Stage 0: replicated log-snapshot boundaries arrive with
338    /// the consensus adapter in Stage 2 (spec section 9.4). The standalone
339    /// database's checkpoint/backup machinery already covers local images.
340    fn create_snapshot(&self) -> Result<LogSnapshot, LogError> {
341        Err(LogError::Unsupported(
342            "standalone commit log does not create log snapshots; replicated snapshot boundaries arrive in Stage 2",
343        ))
344    }
345
346    /// Unsupported in Stage 0: see [`CommitLog::create_snapshot`].
347    fn install_snapshot(&self, _snapshot: LogSnapshot) -> Result<(), LogError> {
348        Err(LogError::Unsupported(
349            "standalone commit log does not install log snapshots; replicated snapshot boundaries arrive in Stage 2",
350        ))
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use std::time::{Duration, Instant};
358
359    #[test]
360    fn core_hlc_clock_is_monotonic_across_commit_allocations() {
361        let clock = HlcClock::new(0, Duration::from_secs(60));
362        let mut previous = clock.commit_timestamp(std::iter::empty());
363        for _ in 0..1_000 {
364            let next = clock.commit_timestamp(std::iter::empty());
365            assert!(next > previous, "{next} must exceed {previous}");
366            previous = next;
367        }
368    }
369
370    #[test]
371    fn commit_timestamp_exceeds_every_participant_timestamp() {
372        let clock = HlcClock::new(0, Duration::from_secs(60));
373        let read_ts = clock.now().unwrap();
374        let commit_ts = clock.commit_timestamp([read_ts]);
375        assert!(
376            commit_ts > read_ts,
377            "§8.2: commit ts {commit_ts} must exceed read ts {read_ts}"
378        );
379        let later = clock.commit_timestamp([read_ts, commit_ts]);
380        assert!(later > commit_ts);
381    }
382
383    #[test]
384    fn commit_nanos_carries_the_physical_component() {
385        let ts = HlcTimestamp {
386            physical_micros: 1_700_000_000_123_456,
387            logical: 7,
388            node_tiebreaker: 2,
389        };
390        assert_eq!(commit_nanos(ts), 1_700_000_000_123_456_000);
391        let saturated = HlcTimestamp {
392            physical_micros: u64::MAX,
393            logical: 0,
394            node_tiebreaker: 0,
395        };
396        assert_eq!(commit_nanos(saturated), u64::MAX);
397    }
398
399    #[test]
400    fn to_log_control_maps_deadline_and_leaves_cancellation_to_checkpoint() {
401        let control = crate::ExecutionControl::new(None);
402        let converted = to_log_control(&control);
403        assert!(converted.deadline.is_none());
404        assert!(converted.cancellation.is_none());
405        assert!(converted.check().is_ok());
406
407        let deadline = Instant::now() + Duration::from_secs(60);
408        let control = crate::ExecutionControl::new(Some(deadline));
409        let converted = to_log_control(&control);
410        assert_eq!(converted.deadline, Some(deadline));
411
412        let expired = crate::ExecutionControl::new(Some(Instant::now()));
413        let converted = to_log_control(&expired);
414        assert!(matches!(converted.check(), Err(LogError::DeadlineExceeded)));
415    }
416
417    #[test]
418    fn transaction_id_mapping_is_injective() {
419        assert_ne!(transaction_id_from_txn(1), transaction_id_from_txn(2));
420        assert_eq!(
421            transaction_id_from_txn(7),
422            transaction_id_from_txn(7),
423            "mapping must be deterministic"
424        );
425    }
426}