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}