Skip to main content

nodedb_wal/
segmented.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Segmented WAL: manages a directory of segment files with automatic rollover
4//! and truncation.
5//!
6//! This is the primary WAL interface for production use. It wraps `WalWriter`
7//! and `WalReader` to provide:
8//!
9//! - Automatic segment rollover when the active segment exceeds a target size.
10//! - Truncation of old segments after checkpoint confirmation.
11//! - Transparent multi-segment replay for crash recovery.
12//! - Legacy single-file WAL migration.
13//!
14//! ## Thread safety
15//!
16//! `SegmentedWal` is NOT `Send + Sync` by itself. The `WalManager` in the main
17//! crate wraps it in a `Mutex` for thread-safe access from the Control Plane.
18
19use std::fs;
20use std::path::{Path, PathBuf};
21
22use tracing::info;
23
24use crate::crypto::KeyRing;
25use crate::error::{Result, WalError};
26use crate::record::{RecordTarget, WalRecord};
27use crate::segment::orphan::discard_unused_segment;
28use crate::segment::{
29    DEFAULT_SEGMENT_TARGET_SIZE, SegmentContinuity, SegmentMeta, TruncateResult,
30    check_resume_above_previous, check_retained_floor, discover_segments, segment_path,
31    truncate_segments,
32};
33use crate::writer::{WalWriter, WalWriterConfig};
34
35/// Configuration for the segmented WAL.
36#[derive(Debug, Clone)]
37pub struct SegmentedWalConfig {
38    /// WAL directory path.
39    pub wal_dir: PathBuf,
40
41    /// Target segment size in bytes. When the active segment exceeds this,
42    /// a new segment is created. This is a soft limit — the current record
43    /// is always completed before rolling.
44    pub segment_target_size: u64,
45
46    /// Writer configuration (alignment, buffer size, O_DIRECT).
47    pub writer_config: WalWriterConfig,
48}
49
50impl SegmentedWalConfig {
51    /// Create a config with defaults for the given directory.
52    pub fn new(wal_dir: PathBuf) -> Self {
53        Self {
54            wal_dir,
55            segment_target_size: DEFAULT_SEGMENT_TARGET_SIZE,
56            writer_config: WalWriterConfig::default(),
57        }
58    }
59
60    /// Create a config for testing (no O_DIRECT).
61    pub fn for_testing(wal_dir: PathBuf) -> Self {
62        Self {
63            wal_dir,
64            segment_target_size: DEFAULT_SEGMENT_TARGET_SIZE,
65            writer_config: WalWriterConfig {
66                use_direct_io: false,
67                ..Default::default()
68            },
69        }
70    }
71}
72
73/// A segmented write-ahead log.
74///
75/// Manages a directory of WAL segment files. Records are appended to the active
76/// segment. When the active segment exceeds `segment_target_size`, a new segment
77/// is created automatically on the next append.
78pub struct SegmentedWal {
79    /// WAL directory.
80    wal_dir: PathBuf,
81
82    /// The currently active writer (appending to the latest segment).
83    writer: WalWriter,
84
85    /// First LSN of the active segment (used for truncation safety).
86    active_first_lsn: u64,
87
88    /// Target size for segment rollover.
89    segment_target_size: u64,
90
91    /// Writer config (for creating new segments).
92    writer_config: WalWriterConfig,
93
94    /// Optional encryption key ring.
95    encryption_ring: Option<crate::crypto::KeyRing>,
96}
97
98impl SegmentedWal {
99    /// Open or create a segmented WAL in the given directory.
100    ///
101    /// On first startup, creates the directory and the first segment.
102    /// On subsequent startups, discovers existing segments and opens the
103    /// last one for continued appending.
104    pub fn open(config: SegmentedWalConfig) -> Result<Self> {
105        fs::create_dir_all(&config.wal_dir).map_err(WalError::Io)?;
106
107        let segments = discover_segments(&config.wal_dir)?;
108
109        let (writer, active_first_lsn) = if segments.is_empty() {
110            // Fresh WAL — create the first segment starting at LSN 1.
111            let path = segment_path(&config.wal_dir, 1);
112            let writer = WalWriter::open(&path, config.writer_config.clone())?;
113            // Creating the file makes its data durable on the next fsync, but
114            // POSIX promises nothing about the name pointing at that inode.
115            // Without this, a crash can leave the very first segment
116            // unreachable and every record acknowledged into it lost.
117            crate::segment::fsync_directory(&config.wal_dir)?;
118            (writer, 1u64)
119        } else {
120            // Resume from the last segment. The filename's first LSN is the
121            // floor for the resumed sequence — a segment that was rolled but
122            // never written to (or whose records were all torn away) holds no
123            // LSN to recover from, and restarting at 1 would re-issue LSNs the
124            // earlier segments already own.
125            let last = &segments[segments.len() - 1];
126            let writer =
127                WalWriter::open_resuming(&last.path, config.writer_config.clone(), last.first_lsn)?;
128            check_resume_above_previous(&segments, writer.next_lsn())?;
129            (writer, last.first_lsn)
130        };
131
132        info!(
133            wal_dir = %config.wal_dir.display(),
134            segments = segments.len().max(1),
135            active_first_lsn,
136            next_lsn = writer.next_lsn(),
137            "segmented WAL opened"
138        );
139
140        Ok(Self {
141            wal_dir: config.wal_dir,
142            writer,
143            active_first_lsn,
144            segment_target_size: config.segment_target_size,
145            writer_config: config.writer_config,
146            encryption_ring: None,
147        })
148    }
149
150    /// Set the encryption key ring. All subsequent records will be encrypted.
151    ///
152    /// Must be called before the first `append`. Returns an error if records
153    /// have already been written to the active segment.
154    pub fn set_encryption_ring(&mut self, ring: crate::crypto::KeyRing) -> Result<()> {
155        self.writer.set_encryption_ring(ring.clone())?;
156        self.encryption_ring = Some(ring);
157        Ok(())
158    }
159
160    /// Configure encryption for a newly created WAL or roll an already-open
161    /// active segment before switching keys on restart.
162    pub fn configure_encryption_ring(&mut self, ring: crate::crypto::KeyRing) -> Result<()> {
163        if self.writer.can_set_encryption_ring() {
164            self.set_encryption_ring(ring)
165        } else {
166            self.rotate_encryption_ring(ring)
167        }
168    }
169
170    /// Get the encryption key ring (for replay decryption).
171    pub fn encryption_ring(&self) -> Option<&crate::crypto::KeyRing> {
172        self.encryption_ring.as_ref()
173    }
174
175    /// Seal the active segment and start a new segment under `ring`.
176    /// This is the only safe runtime key-rotation boundary because a segment's
177    /// encryption epoch and key are fixed by its preamble.
178    pub fn rotate_encryption_ring(&mut self, ring: crate::crypto::KeyRing) -> Result<()> {
179        self.roll_segment_with_ring(Some(ring))
180    }
181
182    /// Append a record to the WAL. Returns the assigned LSN.
183    ///
184    /// If the active segment has exceeded the target size, a new segment is
185    /// created before appending. The rollover is transparent to the caller.
186    ///
187    /// `database_id` is stored in header bytes 34-41. Pass `0` for the
188    /// default database (backward-compatible with pre-existing records).
189    pub fn append(
190        &mut self,
191        record_type: u32,
192        tenant_id: u64,
193        vshard_id: u32,
194        database_id: u64,
195        payload: &[u8],
196    ) -> Result<u64> {
197        self.append_keyed(
198            RecordTarget {
199                record_type,
200                tenant_id,
201                vshard_id,
202                database_id,
203                event_source: crate::record::NO_EVENT_SOURCE,
204                commit_hlc: 0,
205            },
206            payload,
207            0,
208        )
209    }
210
211    /// [`Self::append`] for a record appended by the apply of the replicated
212    /// proposal `apply_key`, carrying the event source in `target` (see
213    /// [`crate::WalRecord::new_stamped`]).
214    pub fn append_keyed(
215        &mut self,
216        target: RecordTarget,
217        payload: &[u8],
218        apply_key: u64,
219    ) -> Result<u64> {
220        // Check if we need to roll to a new segment.
221        if self.writer.file_offset() >= self.segment_target_size {
222            self.roll_segment()?;
223        }
224
225        self.writer.append_keyed(target, payload, apply_key)
226    }
227
228    /// Flush all buffered records and fsync the active segment.
229    pub fn sync(&mut self) -> Result<()> {
230        self.writer.sync()
231    }
232
233    /// Seal the active segment and start the next one, so every record
234    /// appended so far sits in a sealed segment. A no-op when the active
235    /// segment holds no record.
236    pub fn seal_active_segment(&mut self) -> Result<()> {
237        if self.writer.next_lsn() == self.active_first_lsn {
238            return Ok(());
239        }
240        self.roll_segment()
241    }
242
243    /// The next LSN that will be assigned.
244    pub fn next_lsn(&self) -> u64 {
245        self.writer.next_lsn()
246    }
247
248    /// The largest payload one record takes (see [`WalWriter::max_payload`]).
249    pub fn max_payload(&self) -> usize {
250        self.writer.max_payload()
251    }
252
253    /// First LSN of the active (currently written) segment.
254    pub fn active_segment_first_lsn(&self) -> u64 {
255        self.active_first_lsn
256    }
257
258    /// The WAL directory path.
259    pub fn wal_dir(&self) -> &Path {
260        &self.wal_dir
261    }
262
263    /// Truncate old WAL segments that are fully below the checkpoint LSN.
264    ///
265    /// Only sealed (non-active) segments are eligible for deletion.
266    /// The active segment is never deleted.
267    ///
268    /// Returns the number of segments deleted and bytes reclaimed.
269    pub fn truncate_before(&self, checkpoint_lsn: u64) -> Result<TruncateResult> {
270        truncate_segments(&self.wal_dir, checkpoint_lsn, self.active_first_lsn)
271    }
272
273    /// Replay all committed records across all segments, in LSN order.
274    ///
275    /// Used for crash recovery on startup. Payloads are returned as plaintext:
276    /// this WAL's own key ring is applied to every encrypted record.
277    pub fn replay(&self) -> Result<Vec<WalRecord>> {
278        replay_all_segments(&self.wal_dir, self.encryption_ring.as_ref())
279    }
280
281    /// Replay only records with LSN >= `from_lsn`.
282    ///
283    /// Used when recovering from a checkpoint — records before the checkpoint
284    /// LSN have already been applied from the snapshot.
285    ///
286    /// Fails with [`WalError::ReplayBelowRetainedFloor`] when `from_lsn` names
287    /// an LSN truncation has already deleted, rather than returning the shorter
288    /// suffix that survives. Delegating to `replay_from_limit_dir` keeps that
289    /// check — and the per-segment continuity and torn-tail checks — in one
290    /// place instead of replaying everything and filtering afterwards.
291    pub fn replay_from(&self, from_lsn: u64) -> Result<Vec<WalRecord>> {
292        Ok(replay_from_limit_dir(
293            &self.wal_dir,
294            from_lsn,
295            usize::MAX,
296            self.encryption_ring.as_ref(),
297        )?
298        .0)
299    }
300
301    /// Paginated replay (delegates to standalone `replay_from_limit_dir`).
302    pub fn replay_from_limit(
303        &self,
304        from_lsn: u64,
305        max_records: usize,
306    ) -> Result<(Vec<WalRecord>, bool)> {
307        replay_from_limit_dir(
308            &self.wal_dir,
309            from_lsn,
310            max_records,
311            self.encryption_ring.as_ref(),
312        )
313    }
314
315    /// List all segment metadata (for monitoring / operational tooling).
316    pub fn list_segments(&self) -> Result<Vec<SegmentMeta>> {
317        discover_segments(&self.wal_dir)
318    }
319
320    /// Total WAL size on disk across all segments.
321    pub fn total_size_bytes(&self) -> Result<u64> {
322        let segments = discover_segments(&self.wal_dir)?;
323        Ok(segments.iter().map(|s| s.file_size).sum())
324    }
325
326    /// Roll to a new segment: seal the current writer and create a new one.
327    fn roll_segment(&mut self) -> Result<()> {
328        let next_ring = self
329            .encryption_ring
330            .as_ref()
331            .map(|ring| {
332                ring.current()
333                    .with_fresh_epoch()
334                    .map(crate::crypto::KeyRing::new)
335            })
336            .transpose()?;
337        self.roll_segment_with_ring(next_ring)
338    }
339
340    /// The new segment is built and validated first, and the old writer is
341    /// sealed only once nothing fallible remains. Sealing earlier would leave
342    /// a writer that rejects every append still installed whenever a later
343    /// step failed — a roll that fails once would shut the WAL down for the
344    /// life of the process. Here a failure returns with the old writer intact
345    /// and still accepting appends; the next append simply retries the roll.
346    ///
347    /// The two ordering constraints the log depends on still hold: the seal
348    /// precedes the install, so no record can land in the old segment past the
349    /// new segment's declared `first_lsn`; and the directory fsync precedes
350    /// the install, so no record is acknowledged into a segment whose name
351    /// might not survive a crash.
352    fn roll_segment_with_ring(&mut self, next_ring: Option<crate::crypto::KeyRing>) -> Result<()> {
353        // A sync can append the batch's time anchor, so it runs before the
354        // new segment's first LSN is read. The seal below then has nothing
355        // left to append, and the LSN read here is the one it leaves behind.
356        self.writer.sync()?;
357        let new_first_lsn = self.writer.next_lsn();
358        let new_path = segment_path(&self.wal_dir, new_first_lsn);
359        // Every failure from here on removes the new file before returning.
360        // Left on disk, it names LSNs the still-active old segment goes on to
361        // write, and a restart would resume from it and reissue them.
362        let new_writer = match self.open_and_seal(&new_path, new_first_lsn, next_ring.as_ref()) {
363            Ok(new_writer) => new_writer,
364            Err(roll_err) => {
365                return Err(discard_unused_segment(&self.wal_dir, &new_path, roll_err));
366            }
367        };
368
369        // Everything past here is infallible, so no failure can strand a
370        // sealed writer that was never replaced.
371        self.encryption_ring = next_ring;
372        self.writer = new_writer;
373        self.active_first_lsn = new_first_lsn;
374
375        info!(
376            segment = %new_path.display(),
377            first_lsn = new_first_lsn,
378            "rolled to new WAL segment"
379        );
380        Ok(())
381    }
382
383    /// Create the next segment at `new_path`, then seal the old writer.
384    fn open_and_seal(
385        &mut self,
386        new_path: &Path,
387        new_first_lsn: u64,
388        next_ring: Option<&KeyRing>,
389    ) -> Result<WalWriter> {
390        let mut new_writer =
391            WalWriter::open_with_start_lsn(new_path, self.writer_config.clone(), new_first_lsn)?;
392
393        crate::segment::fsync_directory(&self.wal_dir)?;
394
395        if let Some(ring) = next_ring {
396            new_writer.set_encryption_ring(ring.clone())?;
397        }
398
399        nodedb_types::fail_point_err!("wal::roll_before_seal", |detail: String| WalError::Io(
400            std::io::Error::other(format!("failpoint wal::roll_before_seal: {detail}"))
401        ));
402
403        // Last fallible step. A seal is a durability barrier over the old
404        // segment's buffered records; if it fails those records were never
405        // made durable, and the writer reports that on every later call — a
406        // real data-loss error, not a bookkeeping state this roll created.
407        self.writer.seal()?;
408        Ok(new_writer)
409    }
410}
411
412/// Replay all records from all segments in a WAL directory, in LSN order.
413///
414/// Segments are read in order of their first_lsn. Within each segment,
415/// records are read sequentially. This produces a globally ordered stream.
416///
417/// This is `replay_from_limit_dir` with no LSN floor and no record cap, so the
418/// per-segment continuity/torn-tail checks live in exactly one place.
419///
420/// `keys` must be the key ring the directory was written under, or `None` for a
421/// WAL that was never encrypted. It is a required argument rather than an
422/// opt-in so that no caller can reach ciphertext by omission: an encrypted
423/// record met with `keys == None` fails with
424/// [`WalError::EncryptedRecordWithoutKey`].
425pub fn replay_all_segments(wal_dir: &Path, keys: Option<&KeyRing>) -> Result<Vec<WalRecord>> {
426    Ok(replay_from_limit_dir(wal_dir, 0, usize::MAX, keys)?.0)
427}
428
429/// Paginated replay from a WAL directory: reads at most `max_records` from `from_lsn`.
430///
431/// Uses sequential I/O (not mmap) and does NOT require the WAL mutex — safe
432/// to call concurrently with writes. Sealed segments are immutable; the active
433/// segment is read via buffered I/O which sees data after the writer's fsync.
434///
435/// Returns `(records, has_more)` where `has_more` is `true` if the limit was hit.
436///
437/// Records are returned as plaintext. See [`replay_all_segments`] for why
438/// `keys` is mandatory.
439///
440/// A `from_lsn` below the earliest LSN the directory still retains fails with
441/// [`WalError::ReplayBelowRetainedFloor`] instead of returning the surviving
442/// (shorter) suffix.
443pub fn replay_from_limit_dir(
444    wal_dir: &Path,
445    from_lsn: u64,
446    max_records: usize,
447    keys: Option<&KeyRing>,
448) -> Result<(Vec<WalRecord>, bool)> {
449    let segments = discover_segments(wal_dir)?;
450    // Before any record is read: the answer to "is the requested suffix still
451    // here?" is in the segment set, and once records start flowing back a short
452    // result is indistinguishable from a complete one.
453    check_retained_floor(&segments, from_lsn)?;
454    let mut records = Vec::with_capacity(max_records.min(4096));
455    let mut continuity = SegmentContinuity::new();
456
457    for seg in &segments {
458        continuity.check(seg)?;
459        // The reader decrypts: records reach the caller as plaintext or not at
460        // all.
461        let mut reader = crate::reader::WalReader::open(&seg.path, keys)?;
462        // Tracked over everything the segment contains, not over what survives
463        // the `from_lsn` filter — continuity is a property of the log, not of
464        // the caller's window into it.
465        let mut last_lsn = 0u64;
466        while let Some(record) = reader
467            .next_record()
468            .map_err(|error| error.with_segment_path(&seg.path))?
469        {
470            last_lsn = record.header.lsn;
471            if record.header.lsn >= from_lsn {
472                records.push(record);
473                if records.len() >= max_records {
474                    // Stopping short leaves the rest of this segment unread, so
475                    // its end LSN is unknown and no boundary can be judged.
476                    return Ok((records, true));
477                }
478            }
479        }
480        // A segment that stops early may be an interrupted final write, or it
481        // may be a hole with committed records behind it. Only the first is a
482        // legal end of the log.
483        crate::torn_tail::verify_committed_prefix(&seg.path, reader.stop_reason(), last_lsn)?;
484        continuity.completed(seg, last_lsn);
485    }
486
487    Ok((records, false))
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use crate::record::RecordType;
494
495    fn test_config(dir: &Path) -> SegmentedWalConfig {
496        SegmentedWalConfig::for_testing(dir.to_path_buf())
497    }
498
499    #[test]
500    fn create_and_append() {
501        let dir = tempfile::tempdir().unwrap();
502        let wal_dir = dir.path().join("wal");
503
504        let mut wal = SegmentedWal::open(test_config(&wal_dir)).unwrap();
505        let lsn1 = wal
506            .append(RecordType::Put as u32, 1, 0, 0, b"hello")
507            .unwrap();
508        let lsn2 = wal
509            .append(RecordType::Put as u32, 1, 0, 0, b"world")
510            .unwrap();
511        wal.sync().unwrap();
512
513        assert_eq!(lsn1, 1);
514        assert_eq!(lsn2, 2);
515        assert_eq!(wal.next_lsn(), 3);
516    }
517
518    #[test]
519    fn replay_after_close() {
520        let dir = tempfile::tempdir().unwrap();
521        let wal_dir = dir.path().join("wal");
522
523        // Write records.
524        {
525            let mut wal = SegmentedWal::open(test_config(&wal_dir)).unwrap();
526            wal.append(RecordType::Put as u32, 1, 0, 0, b"first")
527                .unwrap();
528            wal.append(RecordType::Delete as u32, 2, 1, 0, b"second")
529                .unwrap();
530            wal.append(RecordType::Put as u32, 1, 0, 0, b"third")
531                .unwrap();
532            wal.sync().unwrap();
533        }
534
535        // Reopen and replay.
536        let wal = SegmentedWal::open(test_config(&wal_dir)).unwrap();
537        let records = wal.replay().unwrap();
538        assert_eq!(records.len(), 3);
539        assert_eq!(records[0].payload, b"first");
540        assert_eq!(records[1].payload, b"second");
541        assert_eq!(records[2].payload, b"third");
542        assert_eq!(wal.next_lsn(), 4);
543    }
544
545    #[test]
546    fn automatic_segment_rollover() {
547        let dir = tempfile::tempdir().unwrap();
548        let wal_dir = dir.path().join("wal");
549
550        // Use a tiny segment target to force rollover.
551        let config = SegmentedWalConfig {
552            wal_dir: wal_dir.clone(),
553            segment_target_size: 100, // 100 bytes — will roll after ~2 records.
554            writer_config: WalWriterConfig {
555                use_direct_io: false,
556                ..Default::default()
557            },
558        };
559
560        let mut wal = SegmentedWal::open(config).unwrap();
561
562        // Write enough records to trigger multiple rollovers.
563        for i in 0..20u32 {
564            let payload = format!("record-{i:04}");
565            wal.append(RecordType::Put as u32, 1, 0, 0, payload.as_bytes())
566                .unwrap();
567            wal.sync().unwrap();
568        }
569
570        // Should have created multiple segments.
571        let segments = wal.list_segments().unwrap();
572        assert!(
573            segments.len() > 1,
574            "expected multiple segments, got {}",
575            segments.len()
576        );
577
578        // Replay should return all 20 records in order.
579        let records = wal.replay().unwrap();
580        assert_eq!(records.len(), 20);
581        for (i, record) in records.iter().enumerate() {
582            assert_eq!(record.header.lsn, (i + 1) as u64);
583            let expected = format!("record-{i:04}");
584            assert_eq!(record.payload, expected.as_bytes());
585        }
586    }
587
588    #[test]
589    fn truncation_removes_old_segments() {
590        let dir = tempfile::tempdir().unwrap();
591        let wal_dir = dir.path().join("wal");
592
593        let config = SegmentedWalConfig {
594            wal_dir: wal_dir.clone(),
595            segment_target_size: 100,
596            writer_config: WalWriterConfig {
597                use_direct_io: false,
598                ..Default::default()
599            },
600        };
601
602        let mut wal = SegmentedWal::open(config).unwrap();
603
604        // Write records to create multiple segments.
605        for i in 0..20u32 {
606            let payload = format!("record-{i:04}");
607            wal.append(RecordType::Put as u32, 1, 0, 0, payload.as_bytes())
608                .unwrap();
609            wal.sync().unwrap();
610        }
611
612        let segments_before = wal.list_segments().unwrap();
613        assert!(segments_before.len() > 1);
614
615        // Truncate with a checkpoint at LSN 15.
616        let result = wal.truncate_before(15).unwrap();
617        assert!(result.segments_deleted > 0);
618        assert!(result.bytes_reclaimed > 0);
619
620        let segments_after = wal.list_segments().unwrap();
621        assert!(segments_after.len() < segments_before.len());
622
623        // Remaining records should still be replayable.
624        let records = wal.replay().unwrap();
625        // At minimum, records from LSN 15+ should be present.
626        assert!(records.iter().any(|r| r.header.lsn >= 15));
627    }
628
629    #[test]
630    fn replay_from_checkpoint_lsn() {
631        let dir = tempfile::tempdir().unwrap();
632        let wal_dir = dir.path().join("wal");
633
634        let mut wal = SegmentedWal::open(test_config(&wal_dir)).unwrap();
635        for i in 0..10u32 {
636            wal.append(RecordType::Put as u32, 1, 0, 0, format!("r{i}").as_bytes())
637                .unwrap();
638        }
639        wal.sync().unwrap();
640
641        // Replay from LSN 6 — should return LSNs 6..=10.
642        let records = wal.replay_from(6).unwrap();
643        assert_eq!(records.len(), 5);
644        assert_eq!(records[0].header.lsn, 6);
645        assert_eq!(records[4].header.lsn, 10);
646    }
647
648    /// Build a WAL with several segments, truncate its prefix away, and report
649    /// the earliest LSN that survives.
650    fn truncated_wal(wal_dir: &Path) -> (SegmentedWal, u64) {
651        let config = SegmentedWalConfig {
652            wal_dir: wal_dir.to_path_buf(),
653            segment_target_size: 100,
654            writer_config: WalWriterConfig {
655                use_direct_io: false,
656                ..Default::default()
657            },
658        };
659
660        let mut wal = SegmentedWal::open(config).expect("open");
661        for i in 0..40u32 {
662            wal.append(
663                RecordType::Put as u32,
664                1,
665                0,
666                0,
667                format!("record-{i:04}").as_bytes(),
668            )
669            .expect("append");
670            wal.sync().expect("sync");
671        }
672
673        let result = wal.truncate_before(20).expect("truncate");
674        assert!(
675            result.segments_deleted > 0,
676            "the test needs a truncated prefix to have a floor above LSN 1"
677        );
678
679        let floor = wal.list_segments().expect("list")[0].first_lsn;
680        assert!(floor > 1, "expected the retained floor to have moved up");
681        (wal, floor)
682    }
683
684    #[test]
685    fn replay_from_the_retained_floor_returns_the_whole_suffix() {
686        let dir = tempfile::tempdir().unwrap();
687        let (wal, floor) = truncated_wal(&dir.path().join("wal"));
688
689        let records = wal.replay_from(floor).expect("floor is fully retained");
690        assert_eq!(records[0].header.lsn, floor);
691        assert_eq!(records.last().unwrap().header.lsn, 40);
692        assert_eq!(records.len() as u64, 40 - floor + 1);
693    }
694
695    /// The off-by-one guard: the floor itself is the first retained LSN, so a
696    /// request starting exactly there is complete, not short.
697    #[test]
698    fn replay_from_exactly_the_floor_is_not_an_error() {
699        let dir = tempfile::tempdir().unwrap();
700        let (wal, floor) = truncated_wal(&dir.path().join("wal"));
701
702        assert!(wal.replay_from(floor).is_ok());
703        // One below is the first request that cannot be served.
704        assert!(wal.replay_from(floor - 1).is_err());
705    }
706
707    #[test]
708    fn replay_below_the_retained_floor_is_a_typed_error_naming_both_lsns() {
709        let dir = tempfile::tempdir().unwrap();
710        let (wal, floor) = truncated_wal(&dir.path().join("wal"));
711
712        match wal.replay_from(1) {
713            Err(WalError::ReplayBelowRetainedFloor {
714                from_lsn,
715                retained_floor_lsn,
716                ..
717            }) => {
718                assert_eq!(from_lsn, 1);
719                assert_eq!(retained_floor_lsn, floor);
720            }
721            other => panic!("expected a retained-floor violation, got {other:?}"),
722        }
723
724        // The paginated variant answers the same way: a bounded read must not
725        // be the way a caller gets a silently short suffix either.
726        assert!(matches!(
727            wal.replay_from_limit(1, 5),
728            Err(WalError::ReplayBelowRetainedFloor { .. })
729        ));
730
731        // Full replay names no particular record and is unaffected.
732        assert!(wal.replay().is_ok());
733    }
734
735    #[test]
736    fn total_size_bytes() {
737        let dir = tempfile::tempdir().unwrap();
738        let wal_dir = dir.path().join("wal");
739
740        let mut wal = SegmentedWal::open(test_config(&wal_dir)).unwrap();
741        wal.append(RecordType::Put as u32, 1, 0, 0, b"data")
742            .unwrap();
743        wal.sync().unwrap();
744
745        let size = wal.total_size_bytes().unwrap();
746        assert!(size > 0);
747    }
748
749    #[test]
750    fn reopen_continues_lsn() {
751        let dir = tempfile::tempdir().unwrap();
752        let wal_dir = dir.path().join("wal");
753
754        {
755            let mut wal = SegmentedWal::open(test_config(&wal_dir)).unwrap();
756            wal.append(RecordType::Put as u32, 1, 0, 0, b"a").unwrap();
757            wal.append(RecordType::Put as u32, 1, 0, 0, b"b").unwrap();
758            wal.sync().unwrap();
759        }
760
761        {
762            let mut wal = SegmentedWal::open(test_config(&wal_dir)).unwrap();
763            assert_eq!(wal.next_lsn(), 3);
764            let lsn = wal.append(RecordType::Put as u32, 1, 0, 0, b"c").unwrap();
765            assert_eq!(lsn, 3);
766            wal.sync().unwrap();
767        }
768
769        let wal = SegmentedWal::open(test_config(&wal_dir)).unwrap();
770        let records = wal.replay().unwrap();
771        assert_eq!(records.len(), 3);
772    }
773
774    #[test]
775    fn encryption_persists_across_segments() {
776        // Rewritten: actually decrypts across a simulated restart.
777        //
778        // Lifecycle:
779        //  1. Write 10 records with encryption, forcing segment rollover.
780        //  2. Drop the WAL (simulating process exit).
781        //  3. Reopen a new reader and replay — records are marked encrypted.
782        //  4. For each segment, open the reader, read its preamble epoch, and
783        //     decrypt each record using that epoch. Verify payloads match.
784        let dir = tempfile::tempdir().unwrap();
785        let wal_dir = dir.path().join("wal");
786        let key_bytes = [42u8; 32];
787
788        let config = SegmentedWalConfig {
789            wal_dir: wal_dir.clone(),
790            segment_target_size: 100,
791            writer_config: WalWriterConfig {
792                use_direct_io: false,
793                ..Default::default()
794            },
795        };
796
797        // Step 1 & 2: write and drop.
798        {
799            let key = crate::crypto::WalEncryptionKey::from_bytes(&key_bytes).unwrap();
800            let ring = crate::crypto::KeyRing::new(key);
801            let mut wal = SegmentedWal::open(config.clone()).unwrap();
802            wal.set_encryption_ring(ring).unwrap();
803
804            for i in 0..10u32 {
805                wal.append(
806                    RecordType::Put as u32,
807                    1,
808                    0,
809                    0,
810                    format!("enc-{i}").as_bytes(),
811                )
812                .unwrap();
813                wal.sync().unwrap();
814            }
815            assert!(wal.list_segments().unwrap().len() > 1);
816        }
817
818        // Step 3: reopen WAL (new in-memory epoch, simulates restart).
819        let segments = crate::segment::discover_segments(&wal_dir).unwrap();
820        assert!(
821            segments.len() > 1,
822            "expected multiple segments after rollover"
823        );
824
825        let key_for_read = crate::crypto::WalEncryptionKey::from_bytes(&key_bytes).unwrap();
826        let ring_for_read = crate::crypto::KeyRing::new(key_for_read);
827
828        let mut all_payloads = Vec::new();
829
830        // Step 4: per-segment read + decrypt. Raw mode on purpose — this test
831        // asserts what is actually on disk, then decrypts by hand.
832        for seg in &segments {
833            let reader = crate::reader::WalReader::open_raw(&seg.path).unwrap();
834            // Read the epoch from the preamble written at segment open time.
835            let epoch = *reader
836                .segment_preamble()
837                .expect("encrypted segment must have a preamble")
838                .epoch();
839            let preamble_bytes = reader.segment_preamble().unwrap().to_bytes();
840
841            for record_result in reader.records() {
842                let record = record_result.unwrap();
843                assert!(record.is_encrypted(), "all records should be encrypted");
844                let plaintext = record
845                    .decrypt_payload_ring(&epoch, Some(&preamble_bytes), Some(&ring_for_read))
846                    .unwrap();
847                all_payloads.push(plaintext);
848            }
849        }
850
851        assert_eq!(all_payloads.len(), 10);
852        for (i, payload) in all_payloads.iter().enumerate() {
853            assert_eq!(payload, format!("enc-{i}").as_bytes());
854        }
855    }
856
857    /// WAL restart roundtrip with encryption.
858    ///
859    /// Writes encrypted records, simulates a process restart (different in-memory
860    /// epoch), replays, and verifies that decryption succeeds because the epoch
861    /// is read from the on-disk preamble rather than the current key's epoch.
862    #[test]
863    fn wal_encrypted_restart_roundtrip() {
864        let dir = tempfile::tempdir().unwrap();
865        let wal_dir = dir.path().join("wal");
866        let key_bytes = [0xABu8; 32];
867
868        let config = SegmentedWalConfig::for_testing(wal_dir.clone());
869
870        // Write with key lifetime 1.
871        {
872            let key = crate::crypto::WalEncryptionKey::from_bytes(&key_bytes).unwrap();
873            let ring = crate::crypto::KeyRing::new(key);
874            let mut wal = SegmentedWal::open(config.clone()).unwrap();
875            wal.set_encryption_ring(ring).unwrap();
876
877            for i in 0..5u32 {
878                wal.append(
879                    RecordType::Put as u32,
880                    1,
881                    0,
882                    0,
883                    format!("restart-{i}").as_bytes(),
884                )
885                .unwrap();
886            }
887            wal.sync().unwrap();
888        }
889
890        // Simulate restart: new WalEncryptionKey with same bytes (fresh random epoch).
891        let key_restart = crate::crypto::WalEncryptionKey::from_bytes(&key_bytes).unwrap();
892        let ring_restart = crate::crypto::KeyRing::new(key_restart);
893
894        // Replay all segments and decrypt.
895        let segments = crate::segment::discover_segments(&wal_dir).unwrap();
896        let mut payloads = Vec::new();
897
898        for seg in &segments {
899            let reader = crate::reader::WalReader::open_raw(&seg.path).unwrap();
900            let epoch = *reader
901                .segment_preamble()
902                .expect("segment must have preamble after encrypted write")
903                .epoch();
904            let preamble_bytes = reader.segment_preamble().unwrap().to_bytes();
905
906            for record_result in reader.records() {
907                let record = record_result.unwrap();
908                let pt = record
909                    .decrypt_payload_ring(&epoch, Some(&preamble_bytes), Some(&ring_restart))
910                    .unwrap();
911                payloads.push(pt);
912            }
913        }
914
915        assert_eq!(payloads.len(), 5);
916        for (i, pt) in payloads.iter().enumerate() {
917            assert_eq!(pt, format!("restart-{i}").as_bytes());
918        }
919    }
920
921    /// Epoch tamper rejection.
922    ///
923    /// After writing an encrypted segment, corrupt the preamble bytes on disk.
924    /// Decryption must fail because the preamble is part of the AAD.
925    #[test]
926    fn epoch_tamper_rejected() {
927        let dir = tempfile::tempdir().unwrap();
928        let wal_dir = dir.path().join("wal");
929        let key_bytes = [0x55u8; 32];
930
931        let config = SegmentedWalConfig::for_testing(wal_dir.clone());
932
933        {
934            let key = crate::crypto::WalEncryptionKey::from_bytes(&key_bytes).unwrap();
935            let ring = crate::crypto::KeyRing::new(key);
936            let mut wal = SegmentedWal::open(config).unwrap();
937            wal.set_encryption_ring(ring).unwrap();
938            wal.append(RecordType::Put as u32, 1, 0, 0, b"sensitive payload")
939                .unwrap();
940            wal.sync().unwrap();
941        }
942
943        // Find the segment file and corrupt byte 9 of the preamble (epoch field).
944        let segments = crate::segment::discover_segments(&wal_dir).unwrap();
945        assert_eq!(segments.len(), 1);
946        let seg_path = &segments[0].path;
947
948        let mut raw = std::fs::read(seg_path).unwrap();
949        // Preamble is at offset 0; epoch is bytes 8..12. Flip byte 9.
950        raw[9] ^= 0xFF;
951        std::fs::write(seg_path, &raw).unwrap();
952
953        // Reading the preamble will succeed (bytes are valid), but the epoch
954        // in the preamble will be wrong, so the AAD during decryption won't
955        // match what was used at encryption time — decryption must fail.
956        let key_read = crate::crypto::WalEncryptionKey::from_bytes(&key_bytes).unwrap();
957        let ring_read = crate::crypto::KeyRing::new(key_read);
958
959        let reader = crate::reader::WalReader::open_raw(seg_path).unwrap();
960        let epoch = *reader.segment_preamble().unwrap().epoch();
961        let preamble_bytes = reader.segment_preamble().unwrap().to_bytes();
962
963        let record = reader.records().next().unwrap().unwrap();
964        let result = record.decrypt_payload_ring(&epoch, Some(&preamble_bytes), Some(&ring_read));
965        assert!(
966            result.is_err(),
967            "decryption with tampered preamble epoch must fail"
968        );
969    }
970
971    #[test]
972    fn replay_from_limit_basic() {
973        let dir = tempfile::tempdir().unwrap();
974        let config = test_config(dir.path());
975        let mut wal = SegmentedWal::open(config).unwrap();
976
977        // Append 10 records.
978        for i in 0..10u8 {
979            wal.append(RecordType::Put as u32, 1, 0, 0, &[i]).unwrap();
980        }
981        wal.sync().unwrap();
982
983        // Read all with limit > count.
984        let (records, has_more) = wal.replay_from_limit(1, 100).unwrap();
985        assert_eq!(records.len(), 10);
986        assert!(!has_more);
987
988        // Read with limit = 3.
989        let (records, has_more) = wal.replay_from_limit(1, 3).unwrap();
990        assert_eq!(records.len(), 3);
991        assert!(has_more);
992        assert_eq!(records[0].header.lsn, 1);
993        assert_eq!(records[2].header.lsn, 3);
994    }
995
996    #[test]
997    fn replay_from_limit_with_lsn_filter() {
998        let dir = tempfile::tempdir().unwrap();
999        let config = test_config(dir.path());
1000        let mut wal = SegmentedWal::open(config).unwrap();
1001
1002        for i in 0..10u8 {
1003            wal.append(RecordType::Put as u32, 1, 0, 0, &[i]).unwrap();
1004        }
1005        wal.sync().unwrap();
1006
1007        // Start from LSN 6 with limit 100 — should get 5 records (LSNs 6-10).
1008        let (records, has_more) = wal.replay_from_limit(6, 100).unwrap();
1009        assert_eq!(records.len(), 5);
1010        assert!(!has_more);
1011        assert_eq!(records[0].header.lsn, 6);
1012
1013        // Start from LSN 6 with limit 2 — should get 2 records.
1014        let (records, has_more) = wal.replay_from_limit(6, 2).unwrap();
1015        assert_eq!(records.len(), 2);
1016        assert!(has_more);
1017    }
1018
1019    #[test]
1020    fn replay_from_limit_empty() {
1021        let dir = tempfile::tempdir().unwrap();
1022        let config = test_config(dir.path());
1023        let mut wal = SegmentedWal::open(config).unwrap();
1024
1025        wal.append(RecordType::Put as u32, 1, 0, 0, b"a").unwrap();
1026        wal.sync().unwrap();
1027
1028        // Start from beyond all records.
1029        let (records, has_more) = wal.replay_from_limit(999, 100).unwrap();
1030        assert!(records.is_empty());
1031        assert!(!has_more);
1032    }
1033
1034    #[test]
1035    fn replay_from_limit_across_segments() {
1036        let dir = tempfile::tempdir().unwrap();
1037        let config = test_config(dir.path());
1038        let mut wal = SegmentedWal::open(config).unwrap();
1039
1040        // Write 10 records to first segment.
1041        for i in 0..10u8 {
1042            wal.append(RecordType::Put as u32, 1, 0, 0, &[i]).unwrap();
1043        }
1044        wal.sync().unwrap();
1045        // Force a segment rollover.
1046        wal.roll_segment().unwrap();
1047
1048        // Write 10 more records to second segment.
1049        for i in 10..20u8 {
1050            wal.append(RecordType::Put as u32, 1, 0, 0, &[i]).unwrap();
1051        }
1052        wal.sync().unwrap();
1053
1054        let seg_count = wal.list_segments().unwrap().len();
1055        assert!(
1056            seg_count >= 2,
1057            "expected multiple segments, got {seg_count}"
1058        );
1059
1060        // Paginated read should span segments correctly.
1061        let (records, has_more) = wal.replay_from_limit(1, 5).unwrap();
1062        assert_eq!(records.len(), 5);
1063        assert!(has_more);
1064
1065        // Continue from where we left off.
1066        let next_lsn = records.last().unwrap().header.lsn + 1;
1067        let (records2, _) = wal.replay_from_limit(next_lsn, 200).unwrap();
1068        assert_eq!(records2.len(), 15); // 20 - 5 = 15 remaining
1069    }
1070
1071    /// Anchors ride every batch, keep LSNs unique across a rollover, and come
1072    /// back from disk after a restart.
1073    #[test]
1074    fn time_anchors_survive_rollover_and_restart() {
1075        use std::sync::Arc;
1076
1077        use crate::time_anchors::TimeAnchors;
1078
1079        let dir = tempfile::tempdir().unwrap();
1080        let wal_dir = dir.path().join("wal");
1081        let config = |anchors: &Arc<TimeAnchors>| SegmentedWalConfig {
1082            wal_dir: wal_dir.clone(),
1083            segment_target_size: 200,
1084            writer_config: WalWriterConfig {
1085                use_direct_io: false,
1086                time_anchors: Some(Arc::clone(anchors)),
1087                ..Default::default()
1088            },
1089        };
1090
1091        let live = Arc::new(TimeAnchors::new(Arc::new(nodedb_types::HlcClock::new())));
1092        {
1093            let mut wal = SegmentedWal::open(config(&live)).unwrap();
1094            for i in 0..12u32 {
1095                wal.append(RecordType::Put as u32, 1, 0, 0, format!("r{i}").as_bytes())
1096                    .unwrap();
1097                if i % 3 == 2 {
1098                    wal.sync().unwrap();
1099                }
1100            }
1101            wal.sync().unwrap();
1102            assert!(wal.list_segments().unwrap().len() > 1);
1103        }
1104        let live_anchors = live.anchors();
1105        assert!(live_anchors.len() >= 4);
1106
1107        let records = replay_all_segments(&wal_dir, None).unwrap();
1108        let lsns: Vec<u64> = records.iter().map(|r| r.header.lsn).collect();
1109        assert!(lsns.windows(2).all(|w| w[1] == w[0] + 1), "{lsns:?}");
1110        let puts = records
1111            .iter()
1112            .filter(|r| RecordType::from_raw(r.logical_record_type()) == Some(RecordType::Put))
1113            .count();
1114        assert_eq!(puts, 12);
1115
1116        let restarted = TimeAnchors::new(Arc::new(nodedb_types::HlcClock::new()));
1117        restarted.absorb_replayed(&records).unwrap();
1118        assert_eq!(restarted.anchors(), live_anchors);
1119    }
1120
1121    /// A roll that fails after creating the next segment removes it. The old
1122    /// writer stays active, and a retried roll succeeds.
1123    #[cfg(feature = "failpoints")]
1124    #[test]
1125    fn an_injected_seal_failure_leaves_no_new_segment_file() {
1126        use nodedb_types::fail_point::FailGuard;
1127
1128        let dir = tempfile::tempdir().unwrap();
1129        let mut wal = SegmentedWal::open(test_config(dir.path())).unwrap();
1130        wal.append(RecordType::Put as u32, 1, 0, 0, b"a").unwrap();
1131        wal.sync().unwrap();
1132        let before = wal.list_segments().unwrap();
1133        {
1134            let _guard = FailGuard::fail("wal::roll_before_seal", "injected");
1135            assert!(wal.roll_segment().is_err());
1136        }
1137        assert_eq!(wal.list_segments().unwrap(), before);
1138        assert_eq!(wal.active_segment_first_lsn(), 1);
1139
1140        wal.append(RecordType::Put as u32, 1, 0, 0, b"b").unwrap();
1141        wal.roll_segment().unwrap();
1142        assert_eq!(wal.list_segments().unwrap().len(), 2);
1143        assert_eq!(wal.active_segment_first_lsn(), 3);
1144    }
1145
1146    /// A segment file left by an interrupted roll starts inside LSNs the
1147    /// previous segment holds. Opening the WAL refuses it instead of
1148    /// resuming there and reissuing those LSNs.
1149    #[test]
1150    fn open_refuses_a_segment_left_by_an_interrupted_roll() {
1151        let dir = tempfile::tempdir().unwrap();
1152        {
1153            let mut wal = SegmentedWal::open(test_config(dir.path())).unwrap();
1154            for i in 0..5u8 {
1155                wal.append(RecordType::Put as u32, 1, 0, 0, &[i]).unwrap();
1156            }
1157            wal.sync().unwrap();
1158        }
1159        let orphan = segment_path(dir.path(), 3);
1160        WalWriter::open_with_start_lsn(&orphan, test_config(dir.path()).writer_config, 3).unwrap();
1161
1162        match SegmentedWal::open(test_config(dir.path())) {
1163            Err(WalError::SegmentOverlapsPrevious {
1164                first_lsn,
1165                previous_last_lsn,
1166                ..
1167            }) => {
1168                assert_eq!(first_lsn, 3);
1169                assert_eq!(previous_last_lsn, 5);
1170            }
1171            Err(other) => panic!("expected SegmentOverlapsPrevious, got {other}"),
1172            Ok(_) => panic!("opened a WAL whose last segment reissues LSNs"),
1173        }
1174    }
1175
1176    /// A newest segment whose first record has the record magic and a zeroed
1177    /// version is a torn write, not a format this build cannot read. Opening
1178    /// over it must succeed, and the writer must keep working, because the
1179    /// writer's open is the first thing a boot does.
1180    #[test]
1181    fn open_accepts_a_newest_segment_with_a_zeroed_version() {
1182        let dir = tempfile::tempdir().unwrap();
1183        let next = {
1184            let mut wal = SegmentedWal::open(test_config(dir.path())).unwrap();
1185            wal.append(RecordType::Put as u32, 1, 0, 0, b"a").unwrap();
1186            wal.sync().unwrap();
1187            wal.next_lsn()
1188        };
1189
1190        // A rollover that crashed before its first record: the new segment is
1191        // named for the next LSN and holds the magic with nothing behind it.
1192        // Written after the close, so the open has to read this state back.
1193        let mut bytes = vec![0u8; 64 * 1024];
1194        bytes[0..4].copy_from_slice(&crate::record::WAL_MAGIC.to_le_bytes());
1195        std::fs::write(segment_path(dir.path(), next), &bytes).unwrap();
1196
1197        let mut wal = SegmentedWal::open(test_config(dir.path()))
1198            .expect("a torn newest segment is not a format this build cannot read");
1199        let lsn = wal.append(RecordType::Put as u32, 1, 0, 0, b"b").unwrap();
1200        assert_eq!(
1201            lsn, next,
1202            "the writer has to keep working over the torn tail, not just open"
1203        );
1204        wal.sync().unwrap();
1205        drop(wal);
1206        let (records, _) = replay_from_limit_dir(dir.path(), 0, usize::MAX, None)
1207            .expect("the resumed log replays without a gap");
1208        assert_eq!(records.len(), 2, "both records replay");
1209    }
1210}