Skip to main content

vyre_runtime/
replay.rs

1//! Differential megakernel replay log.
2//!
3//! Every slot the host publishes into the megakernel ring is also
4//! appended to a circular log on disk. A later replay run can feed
5//! the log into a fresh megakernel + backend pair and diff the
6//! epoch-by-epoch observable stream against the original. This
7//! catches schedule-dependent bugs  -  GPU nondeterminism, atomic
8//! ordering hazards, cache-line races  -  that unit tests cannot hit
9//! by construction.
10//!
11//! ## Layout
12//!
13//! ```text
14//! header (32 bytes, aligned to 4 KiB):
15//!     magic:          b"VRRL0001"        (8 bytes)    -  "Vyre Ring-Replay Log"
16//!     version:        u32 = 1            (4 bytes)
17//!     flags:          u32 = 0            (4 bytes)
18//!     capacity:       u64                (8 bytes)    -  total record slots
19//!     next_slot:      u64                (8 bytes)    -  write cursor (mod capacity)
20//! records:                                          (capacity × RECORD_BYTES)
21//!     magic:          u32 = 0xDEADBEEF  (4 bytes)   -  sync marker for forward scan
22//!     timestamp_ns:   u64                (8 bytes)
23//!     slot_idx:       u32                (4 bytes)
24//!     tenant_id:      u32                (4 bytes)
25//!     opcode:         u32                (4 bytes)
26//!     args:           [u32; 4]           (16 bytes)
27//!     epoch:          u32                (4 bytes)   -  observed at publish time
28//!     slot_status:    u32                (4 bytes)   -  terminal ring status, zero when unknown
29//!     failure_class:  u32                (4 bytes)   -  [`ReplayFailureClass`] discriminant
30//!     backend_code:   u32                (4 bytes)   -  stable [`vyre_driver::backend::ErrorCode`]
31//!     output_digest:  u64                (8 bytes)   -  digest of output bytes observed at failure
32//! ```
33//!
34//! Record size = 52 bytes ≤ 64. Aligning to 64 by padding the reserved
35//! tail keeps records cache-line aligned so a consumer can `mmap` the
36//! log and read records without tearing.
37//!
38//! ## Rollover
39//!
40//! The log is a fixed-capacity ring. `next_slot = (next_slot + 1) %
41//! capacity`; a replay iterates from `next_slot` through all records
42//! that have a live magic word. Records that predate the first wrap
43//! are overwritten in publish order.
44
45use std::fs::{File, OpenOptions};
46use std::io::{Read, Seek, SeekFrom, Write};
47use std::path::Path;
48use std::sync::Arc;
49
50use crate::recovery::classify_backend_error;
51use crate::PipelineError;
52use vyre_driver::backend::BackendError;
53use vyre_foundation::diagnostics::RetryClass;
54
55const LOG_MAGIC: &[u8; 8] = b"VRRL0001";
56const LOG_VERSION: u32 = 1;
57const RECORD_MAGIC: u32 = 0xDEAD_BEEF;
58const RECORD_BYTES: u64 = 64;
59const HEADER_BYTES: u64 = 32;
60const MAX_REPLAY_RECORDS: u64 = 1_048_576;
61
62/// One published ring slot as captured by the replay log.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct RecordedSlot {
65    /// Host wall-clock timestamp, nanoseconds since UNIX epoch.
66    pub timestamp_ns: u64,
67    /// Ring slot index the host published into.
68    pub slot_idx: u32,
69    /// Tenant id from the slot's TENANT_WORD.
70    pub tenant_id: u32,
71    /// Opcode from the slot's OPCODE_WORD.
72    pub opcode: u32,
73    /// First four argument words (the rest of the 13-word arg space
74    /// lives in a packed-slot extension and is captured separately).
75    pub args: [u32; 4],
76    /// Megakernel EPOCH word observed at publish time. A replay run
77    /// on the same backend must reach the same epoch in the same
78    /// order  -  divergence is the load-bearing signal.
79    pub epoch: u32,
80}
81
82/// One replay record including optional failure evidence.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct ReplayRecord {
85    /// Published ring slot.
86    pub slot: RecordedSlot,
87    /// Backend/runtime failure evidence captured for this slot.
88    pub failure: Option<ReplayFailureEvidence>,
89}
90
91/// Backend/runtime failure class encoded into the replay record tail.
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub enum ReplayFailureClass {
94    /// No failure evidence was recorded for this published slot.
95    #[default]
96    None,
97    /// Backend context, adapter, or compiled-pipeline state was lost or stale.
98    DeviceLoss,
99    /// Queue/resource pressure that can be retried without recompilation.
100    TransientQueue,
101    /// Program/lowering/kernel-source failure that should not be retried as-is.
102    ProgramBug,
103    /// Failure did not match a known automated recovery class.
104    Unclassified,
105}
106
107impl ReplayFailureClass {
108    const NONE: u32 = 0;
109    const DEVICE_LOSS: u32 = 1;
110    const TRANSIENT_QUEUE: u32 = 2;
111    const PROGRAM_BUG: u32 = 3;
112    const UNCLASSIFIED: u32 = 4;
113
114    const fn encode(self) -> u32 {
115        match self {
116            Self::None => Self::NONE,
117            Self::DeviceLoss => Self::DEVICE_LOSS,
118            Self::TransientQueue => Self::TRANSIENT_QUEUE,
119            Self::ProgramBug => Self::PROGRAM_BUG,
120            Self::Unclassified => Self::UNCLASSIFIED,
121        }
122    }
123
124    const fn decode(raw: u32) -> Self {
125        match raw {
126            Self::NONE => Self::None,
127            Self::DEVICE_LOSS => Self::DeviceLoss,
128            Self::TRANSIENT_QUEUE => Self::TransientQueue,
129            Self::PROGRAM_BUG => Self::ProgramBug,
130            Self::UNCLASSIFIED => Self::Unclassified,
131            _ => Self::Unclassified,
132        }
133    }
134
135    const fn from_retry_class(class: RetryClass) -> Self {
136        match class {
137            RetryClass::NewDevice => Self::DeviceLoss,
138            RetryClass::SameDevice => Self::TransientQueue,
139            RetryClass::Never | RetryClass::RecompileSource => Self::ProgramBug,
140            _ => Self::Unclassified,
141        }
142    }
143}
144
145/// Failure evidence captured in a replay record.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct ReplayFailureEvidence {
148    /// Terminal or observed ring status word for the failed slot.
149    pub slot_status: u32,
150    /// Recovery-oriented failure class.
151    pub failure_class: ReplayFailureClass,
152    /// Stable backend error code. Zero means no backend error was known.
153    pub backend_error_code: u32,
154    /// Stable digest over output bytes observed before/at failure.
155    pub output_digest: u64,
156}
157
158impl ReplayFailureEvidence {
159    /// Build replay failure evidence from a backend error and observed output bytes.
160    #[must_use]
161    pub fn from_backend_error(slot_status: u32, error: &BackendError, output_bytes: &[u8]) -> Self {
162        Self {
163            slot_status,
164            failure_class: ReplayFailureClass::from_retry_class(classify_backend_error(error)),
165            backend_error_code: error.code().stable_id(),
166            output_digest: output_digest(output_bytes),
167        }
168    }
169
170    fn from_words(
171        slot_status: u32,
172        failure_class: u32,
173        backend_error_code: u32,
174        output_digest: u64,
175    ) -> Option<Self> {
176        if slot_status == 0 && failure_class == 0 && backend_error_code == 0 && output_digest == 0 {
177            return None;
178        }
179        Some(Self {
180            slot_status,
181            failure_class: ReplayFailureClass::decode(failure_class),
182            backend_error_code,
183            output_digest,
184        })
185    }
186}
187
188/// Errors surfaced by the replay-log surface. Every variant carries
189/// an actionable `Fix:` hint.
190#[derive(Debug, thiserror::Error)]
191#[non_exhaustive]
192pub enum ReplayLogError {
193    /// I/O syscall on the log file failed.
194    #[error("replay log {op} on `{path}` failed: {source}. Fix: check disk space + permissions.")]
195    Io {
196        /// Syscall name (`open`, `seek`, `read`, `write`).
197        op: &'static str,
198        /// Path the syscall was issued against.
199        path: Arc<str>,
200        /// Underlying io::Error.
201        #[source]
202        source: std::io::Error,
203    },
204    /// Log header magic or version mismatch.
205    #[error("replay log `{path}` header mismatch. Fix: regenerate the log; VRRL format may have changed.")]
206    HeaderMismatch {
207        /// Log path.
208        path: Arc<str>,
209    },
210    /// Capacity of `0` is rejected  -  a zero-capacity log never accepts writes.
211    #[error("replay log capacity must be > 0. Fix: construct with at least one slot.")]
212    ZeroCapacity,
213    /// Record capacity exceeds the replay-log bound. Capping here
214    /// prevents malformed log headers from forcing host OOM during
215    /// replay and keeps record offsets within checked arithmetic.
216    #[error("replay log capacity {count} exceeds max {max}. Fix: shard replay into smaller logs.")]
217    CapacityOverflow {
218        /// Requested capacity.
219        count: u64,
220        /// Maximum accepted capacity.
221        max: u64,
222    },
223}
224
225fn io_err(op: &'static str, path: &Path, source: std::io::Error) -> ReplayLogError {
226    ReplayLogError::Io {
227        op,
228        path: Arc::from(path.to_string_lossy().as_ref()),
229        source,
230    }
231}
232
233/// Append-only circular replay log backed by a real file. Callers
234/// drive `append` on every host-side `publish_slot` and `replay_all`
235/// at cert-time to walk the captured slot stream.
236#[derive(Debug)]
237pub struct RingLog {
238    file: File,
239    path_repr: Arc<str>,
240    capacity: u64,
241    next_slot: u64,
242}
243
244impl RingLog {
245    /// Open a log at `path`, creating + preallocating one with
246    /// `capacity` records if no file exists yet.
247    ///
248    /// # Errors
249    ///
250    /// - [`ReplayLogError::ZeroCapacity`] if `capacity == 0`.
251    /// - [`ReplayLogError::CapacityOverflow`] if `capacity > u32::MAX`.
252    /// - [`ReplayLogError::Io`] on any syscall failure.
253    /// - [`ReplayLogError::HeaderMismatch`] when an existing file
254    ///   has the wrong magic or version.
255    pub fn open(path: impl AsRef<Path>, capacity: u64) -> Result<Self, ReplayLogError> {
256        if capacity == 0 {
257            return Err(ReplayLogError::ZeroCapacity);
258        }
259        validate_capacity(capacity)?;
260
261        let path = path.as_ref();
262        let path_repr: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
263        let existed = path.exists();
264        let mut file = OpenOptions::new()
265            .create(true)
266            .truncate(false)
267            .read(true)
268            .write(true)
269            .open(path)
270            .map_err(|e| io_err("open", path, e))?;
271
272        if existed {
273            let mut magic = [0u8; 8];
274            file.read_exact(&mut magic)
275                .map_err(|e| io_err("read", path, e))?;
276            if &magic != LOG_MAGIC {
277                return Err(ReplayLogError::HeaderMismatch {
278                    path: Arc::clone(&path_repr),
279                });
280            }
281            let mut version_bytes = [0u8; 4];
282            file.read_exact(&mut version_bytes)
283                .map_err(|e| io_err("read", path, e))?;
284            if u32::from_le_bytes(version_bytes) != LOG_VERSION {
285                return Err(ReplayLogError::HeaderMismatch {
286                    path: Arc::clone(&path_repr),
287                });
288            }
289            let mut _flags = [0u8; 4];
290            file.read_exact(&mut _flags)
291                .map_err(|e| io_err("read", path, e))?;
292            let mut cap_bytes = [0u8; 8];
293            file.read_exact(&mut cap_bytes)
294                .map_err(|e| io_err("read", path, e))?;
295            let mut cursor_bytes = [0u8; 8];
296            file.read_exact(&mut cursor_bytes)
297                .map_err(|e| io_err("read", path, e))?;
298            let existing_cap = u64::from_le_bytes(cap_bytes);
299            validate_capacity(existing_cap)?;
300            let cursor = u64::from_le_bytes(cursor_bytes);
301            return Ok(Self {
302                file,
303                path_repr,
304                capacity: existing_cap,
305                next_slot: cursor % existing_cap,
306            });
307        }
308
309        // Fresh log: write the header + zero the body so every record
310        // magic starts at `0` (the uninitialised sentinel the replay
311        // scanner treats as EMPTY).
312        let total_bytes = log_file_len(capacity)?;
313        file.set_len(total_bytes)
314            .map_err(|e| io_err("set_len", path, e))?;
315        file.seek(SeekFrom::Start(0))
316            .map_err(|e| io_err("seek", path, e))?;
317        file.write_all(LOG_MAGIC)
318            .map_err(|e| io_err("write", path, e))?;
319        file.write_all(&LOG_VERSION.to_le_bytes())
320            .map_err(|e| io_err("write", path, e))?;
321        file.write_all(&0u32.to_le_bytes())
322            .map_err(|e| io_err("write", path, e))?; // flags
323        file.write_all(&capacity.to_le_bytes())
324            .map_err(|e| io_err("write", path, e))?;
325        file.write_all(&0u64.to_le_bytes())
326            .map_err(|e| io_err("write", path, e))?; // cursor
327
328        Ok(Self {
329            file,
330            path_repr,
331            capacity,
332            next_slot: 0,
333        })
334    }
335
336    /// Number of record slots in the log. Records past this capacity
337    /// wrap and overwrite the oldest entry.
338    #[must_use]
339    pub fn capacity(&self) -> u64 {
340        self.capacity
341    }
342
343    /// Current write cursor (next slot to be overwritten).
344    #[must_use]
345    pub fn cursor(&self) -> u64 {
346        self.next_slot
347    }
348
349    /// Path representation this log was opened against.
350    #[must_use]
351    pub fn path(&self) -> &str {
352        self.path_repr.as_ref()
353    }
354
355    /// Append a record. Overwrites the oldest slot when the log
356    /// wraps. The cursor is persisted to disk on every append so a
357    /// crash mid-session does not desynchronise the replay.
358    ///
359    /// # Errors
360    ///
361    /// Propagates [`ReplayLogError::Io`] on any file I/O failure.
362    pub fn append(&mut self, slot: RecordedSlot) -> Result<(), ReplayLogError> {
363        self.append_record(ReplayRecord {
364            slot,
365            failure: None,
366        })
367    }
368
369    /// Append a record with backend/runtime failure evidence.
370    ///
371    /// # Errors
372    ///
373    /// Propagates [`ReplayLogError::Io`] on any file I/O failure.
374    pub fn append_with_failure(
375        &mut self,
376        slot: RecordedSlot,
377        failure: ReplayFailureEvidence,
378    ) -> Result<(), ReplayLogError> {
379        self.append_record(ReplayRecord {
380            slot,
381            failure: Some(failure),
382        })
383    }
384
385    fn append_record(&mut self, record: ReplayRecord) -> Result<(), ReplayLogError> {
386        let record_offset = log_record_offset(self.next_slot)?;
387        self.file
388            .seek(SeekFrom::Start(record_offset))
389            .map_err(|e| self.io_err("seek", e))?;
390
391        let mut buf = [0u8; RECORD_BYTES as usize];
392        buf[0..4].copy_from_slice(&RECORD_MAGIC.to_le_bytes());
393        buf[4..12].copy_from_slice(&record.slot.timestamp_ns.to_le_bytes());
394        buf[12..16].copy_from_slice(&record.slot.slot_idx.to_le_bytes());
395        buf[16..20].copy_from_slice(&record.slot.tenant_id.to_le_bytes());
396        buf[20..24].copy_from_slice(&record.slot.opcode.to_le_bytes());
397        buf[24..28].copy_from_slice(&record.slot.args[0].to_le_bytes());
398        buf[28..32].copy_from_slice(&record.slot.args[1].to_le_bytes());
399        buf[32..36].copy_from_slice(&record.slot.args[2].to_le_bytes());
400        buf[36..40].copy_from_slice(&record.slot.args[3].to_le_bytes());
401        buf[40..44].copy_from_slice(&record.slot.epoch.to_le_bytes());
402        if let Some(failure) = record.failure {
403            buf[44..48].copy_from_slice(&failure.slot_status.to_le_bytes());
404            buf[48..52].copy_from_slice(&failure.failure_class.encode().to_le_bytes());
405            buf[52..56].copy_from_slice(&failure.backend_error_code.to_le_bytes());
406            buf[56..64].copy_from_slice(&failure.output_digest.to_le_bytes());
407        }
408        self.file
409            .write_all(&buf)
410            .map_err(|e| self.io_err("write", e))?;
411
412        // Persist the advanced cursor. Readers that mmap the log see
413        // this value and use it to know how far to scan.
414        self.next_slot = (self.next_slot + 1) % self.capacity;
415        self.file
416            .seek(SeekFrom::Start(24)) // header cursor offset
417            .map_err(|e| self.io_err("seek", e))?;
418        self.file
419            .write_all(&self.next_slot.to_le_bytes())
420            .map_err(|e| self.io_err("write", e))?;
421
422        Ok(())
423    }
424
425    /// Walk the log in publish order starting at the record
426    /// immediately after the current cursor (oldest still-live
427    /// record). Stops at the first record whose magic differs from
428    /// the crate-private `RECORD_MAGIC` sentinel  -  meaning the log
429    /// is still before wraparound at that position  -  unless every record
430    /// has been written.
431    ///
432    /// # Errors
433    ///
434    /// Propagates [`ReplayLogError::Io`] on read failure.
435    pub fn replay_all(&mut self) -> Result<Vec<RecordedSlot>, ReplayLogError> {
436        Ok(self
437            .replay_records()?
438            .into_iter()
439            .map(|record| record.slot)
440            .collect())
441    }
442
443    /// Walk the log in publish order and return full records, including
444    /// optional failure evidence.
445    ///
446    /// # Errors
447    ///
448    /// Propagates [`ReplayLogError::Io`] on read failure.
449    pub fn replay_records(&mut self) -> Result<Vec<ReplayRecord>, ReplayLogError> {
450        let capacity =
451            usize::try_from(self.capacity).map_err(|_| ReplayLogError::CapacityOverflow {
452                count: self.capacity,
453                max: MAX_REPLAY_RECORDS,
454            })?;
455        let mut out = Vec::with_capacity(capacity);
456        for step in 0..self.capacity {
457            let slot_index = (self.next_slot + step) % self.capacity;
458            let offset = log_record_offset(slot_index)?;
459            self.file
460                .seek(SeekFrom::Start(offset))
461                .map_err(|e| self.io_err("seek", e))?;
462            let mut buf = [0u8; RECORD_BYTES as usize];
463            self.file
464                .read_exact(&mut buf)
465                .map_err(|e| self.io_err("read", e))?;
466            let magic = read_u32(&buf, 0);
467            if magic == 0 {
468                // Zero-magic means the slot was never written (pre-wrap sentinel).
469                // In a ring that has not yet wrapped, zero-magic slots at the scan
470                // frontier are expected and skipped. However, if the ring HAS
471                // wrapped, a zero-magic slot is a corruption gap (sector fault,
472                // partial crash, or explicit zeroing of a live record), the log
473                // has no wrapped-flag field to distinguish these cases.
474                //
475                // Emit a warning so post-wrap corruption is operator-visible
476                // rather than silently producing a shorter-than-expected replay.
477                // A differential replay run comparing epoch sequences must treat
478                // a warning here as a potential corruption event.
479                tracing::warn!(
480                    slot_index,
481                    next_slot = self.next_slot,
482                    log_capacity = self.capacity,
483                    step,
484                    "replay_records: zero-magic record at slot_index {slot_index} (step {step}). \
485                     If the log has wrapped this is a corruption gap, the replay will be shorter than expected. \
486                     Fix: ensure the replay-log file is not subject to external zeroing or partial-write truncation."
487                );
488                continue;
489            }
490            if magic != RECORD_MAGIC {
491                return Err(ReplayLogError::HeaderMismatch {
492                    path: self.path_repr.clone(),
493                });
494            }
495            let slot = RecordedSlot {
496                timestamp_ns: read_u64(&buf, 4),
497                slot_idx: read_u32(&buf, 12),
498                tenant_id: read_u32(&buf, 16),
499                opcode: read_u32(&buf, 20),
500                args: [
501                    read_u32(&buf, 24),
502                    read_u32(&buf, 28),
503                    read_u32(&buf, 32),
504                    read_u32(&buf, 36),
505                ],
506                epoch: read_u32(&buf, 40),
507            };
508            out.push(ReplayRecord {
509                slot,
510                failure: ReplayFailureEvidence::from_words(
511                    read_u32(&buf, 44),
512                    read_u32(&buf, 48),
513                    read_u32(&buf, 52),
514                    read_u64(&buf, 56),
515                ),
516            });
517        }
518        Ok(out)
519    }
520
521    /// Flush + sync the file to durable storage. Callers invoke this
522    /// when they want the log guaranteed on disk  -  the hot-path
523    /// `append` does not fsync per-record.
524    ///
525    /// # Errors
526    ///
527    /// Propagates [`ReplayLogError::Io`] on fsync failure.
528    pub fn sync(&mut self) -> Result<(), ReplayLogError> {
529        self.file.sync_all().map_err(|e| self.io_err("sync", e))?;
530        Ok(())
531    }
532
533    fn io_err(&self, op: &'static str, source: std::io::Error) -> ReplayLogError {
534        ReplayLogError::Io {
535            op,
536            path: self.path_repr.clone(),
537            source,
538        }
539    }
540}
541
542fn validate_capacity(capacity: u64) -> Result<(), ReplayLogError> {
543    if capacity == 0 {
544        return Err(ReplayLogError::ZeroCapacity);
545    }
546    if capacity > MAX_REPLAY_RECORDS {
547        return Err(ReplayLogError::CapacityOverflow {
548            count: capacity,
549            max: MAX_REPLAY_RECORDS,
550        });
551    }
552    Ok(())
553}
554
555fn log_file_len(capacity: u64) -> Result<u64, ReplayLogError> {
556    log_record_position(capacity)
557}
558
559fn log_record_offset(slot_index: u64) -> Result<u64, ReplayLogError> {
560    log_record_position(slot_index)
561}
562
563fn log_record_position(record_index: u64) -> Result<u64, ReplayLogError> {
564    let record_bytes =
565        vyre_driver::accounting::checked_mul_u64_lazy(record_index, RECORD_BYTES, || {
566            replay_capacity_overflow(record_index)
567        })?;
568    vyre_driver::accounting::checked_add_u64_lazy(HEADER_BYTES, record_bytes, || {
569        replay_capacity_overflow(record_index)
570    })
571}
572
573fn replay_capacity_overflow(count: u64) -> ReplayLogError {
574    ReplayLogError::CapacityOverflow {
575        count,
576        max: MAX_REPLAY_RECORDS,
577    }
578}
579
580fn read_u32(buf: &[u8], offset: usize) -> u32 {
581    let mut bytes = [0u8; 4];
582    bytes.copy_from_slice(&buf[offset..offset + 4]);
583    u32::from_le_bytes(bytes)
584}
585
586fn read_u64(buf: &[u8], offset: usize) -> u64 {
587    let mut bytes = [0u8; 8];
588    bytes.copy_from_slice(&buf[offset..offset + 8]);
589    u64::from_le_bytes(bytes)
590}
591
592fn output_digest(bytes: &[u8]) -> u64 {
593    let digest = blake3::hash(bytes);
594    let mut out = [0u8; 8];
595    out.copy_from_slice(&digest.as_bytes()[..8]);
596    u64::from_le_bytes(out)
597}
598
599/// Let callers bridge ReplayLogError into the unified PipelineError
600/// surface when driving the log from the megakernel pump loop.
601impl From<ReplayLogError> for PipelineError {
602    fn from(err: ReplayLogError) -> Self {
603        PipelineError::Backend(err.to_string())
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    fn rec(slot_idx: u32, epoch: u32) -> RecordedSlot {
612        RecordedSlot {
613            timestamp_ns: 1_000_000 + slot_idx as u64,
614            slot_idx,
615            tenant_id: 0,
616            opcode: 0x4000_0000 + slot_idx,
617            args: [slot_idx, slot_idx * 2, slot_idx * 3, slot_idx * 4],
618            epoch,
619        }
620    }
621
622    #[test]
623    fn open_rejects_zero_capacity() {
624        let dir = tempfile::tempdir().unwrap();
625        let path = dir.path().join("log.vrrl");
626        let err = RingLog::open(&path, 0).expect_err("zero capacity must reject");
627        assert!(matches!(err, ReplayLogError::ZeroCapacity));
628    }
629
630    #[test]
631    fn append_and_replay_round_trip() {
632        let dir = tempfile::tempdir().unwrap();
633        let path = dir.path().join("log.vrrl");
634        let mut log = RingLog::open(&path, 4)
635            .expect("Fix: open fresh log; restore this invariant before continuing.");
636        log.append(rec(1, 10)).unwrap();
637        log.append(rec(2, 11)).unwrap();
638        log.sync().unwrap();
639
640        let replay = log
641            .replay_all()
642            .expect("Fix: replay; restore this invariant before continuing.");
643        assert_eq!(replay.len(), 2);
644        assert_eq!(replay[0].slot_idx, 1);
645        assert_eq!(replay[0].epoch, 10);
646        assert_eq!(replay[1].slot_idx, 2);
647        assert_eq!(replay[1].epoch, 11);
648    }
649
650    #[test]
651    fn append_with_failure_round_trips_reproduction_evidence() {
652        let dir = tempfile::tempdir().unwrap();
653        let path = dir.path().join("log.vrrl");
654        let mut log = RingLog::open(&path, 4)
655            .expect("Fix: open fresh log; restore this invariant before continuing.");
656        let backend_error = BackendError::DeviceLost {
657            backend: "fixture".to_string(),
658            device: "fixture-0".to_string(),
659            generation: 7,
660            message: "device loss after queue submit".to_string(),
661        };
662        let failure =
663            ReplayFailureEvidence::from_backend_error(3, &backend_error, b"partial-output");
664
665        assert_eq!(failure.failure_class, ReplayFailureClass::DeviceLoss);
666        assert_eq!(failure.backend_error_code, backend_error.code().stable_id());
667        assert_ne!(failure.output_digest, 0);
668
669        log.append_with_failure(rec(7, 44), failure).unwrap();
670        log.sync().unwrap();
671
672        let replay = log
673            .replay_records()
674            .expect("Fix: replay records; restore this invariant before continuing.");
675        assert_eq!(replay.len(), 1);
676        assert_eq!(replay[0].slot.slot_idx, 7);
677        assert_eq!(replay[0].slot.epoch, 44);
678        assert_eq!(replay[0].failure, Some(failure));
679    }
680
681    #[test]
682    fn append_without_failure_has_no_failure_evidence() {
683        let dir = tempfile::tempdir().unwrap();
684        let path = dir.path().join("log.vrrl");
685        let mut log = RingLog::open(&path, 2)
686            .expect("Fix: open fresh log; restore this invariant before continuing.");
687
688        log.append(rec(1, 10)).unwrap();
689
690        let replay = log
691            .replay_records()
692            .expect("Fix: replay records; restore this invariant before continuing.");
693        assert_eq!(replay.len(), 1);
694        assert_eq!(replay[0].slot.slot_idx, 1);
695        assert_eq!(replay[0].failure, None);
696    }
697
698    #[test]
699    fn log_rollover_preserves_most_recent() {
700        let dir = tempfile::tempdir().unwrap();
701        let path = dir.path().join("log.vrrl");
702        let mut log =
703            RingLog::open(&path, 3).expect("Fix: open; restore this invariant before continuing.");
704        for i in 0..5 {
705            log.append(rec(i, 100 + i)).unwrap();
706        }
707        let replay = log
708            .replay_all()
709            .expect("Fix: replay; restore this invariant before continuing.");
710        assert_eq!(replay.len(), 3, "capacity=3 must retain exactly 3 records");
711        let slot_ids: Vec<u32> = replay.iter().map(|r| r.slot_idx).collect();
712        // Publish order: 0, 1, 2, 3, 4. After 2 wraps, live records
713        // are [3, 4, 2] in ring-physical order; replay starts at
714        // next_slot = 2 so the visible order is [2, 3, 4].
715        assert_eq!(slot_ids, vec![2, 3, 4]);
716    }
717
718    #[test]
719    fn reopen_restores_cursor() {
720        let dir = tempfile::tempdir().unwrap();
721        let path = dir.path().join("log.vrrl");
722        {
723            let mut log = RingLog::open(&path, 4)
724                .expect("Fix: open fresh; restore this invariant before continuing.");
725            log.append(rec(1, 10)).unwrap();
726            log.append(rec(2, 11)).unwrap();
727            log.sync().unwrap();
728        }
729        let mut reopened = RingLog::open(&path, 4)
730            .expect("Fix: reopen; restore this invariant before continuing.");
731        assert_eq!(reopened.cursor(), 2);
732        let replay = reopened.replay_all().unwrap();
733        assert_eq!(replay.len(), 2);
734    }
735
736    #[test]
737    fn corrupted_magic_rejected() {
738        use std::io::Write as _;
739
740        let dir = tempfile::tempdir().unwrap();
741        let path = dir.path().join("log.vrrl");
742        {
743            // Create a "log" file with the wrong magic.
744            let mut f = std::fs::File::create(&path).unwrap();
745            f.write_all(b"XXXX0001").unwrap();
746            f.write_all(&1u32.to_le_bytes()).unwrap();
747            f.write_all(&0u32.to_le_bytes()).unwrap();
748            f.write_all(&4u64.to_le_bytes()).unwrap();
749            f.write_all(&0u64.to_le_bytes()).unwrap();
750            // Ensure enough bytes for the subsequent reads in open() (headers ≥ 32 B).
751            f.set_len(HEADER_BYTES + 4 * RECORD_BYTES).unwrap();
752        }
753        let err = RingLog::open(&path, 4).expect_err("wrong magic must reject");
754        assert!(matches!(err, ReplayLogError::HeaderMismatch { .. }));
755    }
756
757    fn write_header(path: &Path, capacity: u64, cursor: u64) {
758        use std::io::Write as _;
759
760        let mut f = std::fs::File::create(path).unwrap();
761        f.write_all(LOG_MAGIC).unwrap();
762        f.write_all(&LOG_VERSION.to_le_bytes()).unwrap();
763        f.write_all(&0u32.to_le_bytes()).unwrap();
764        f.write_all(&capacity.to_le_bytes()).unwrap();
765        f.write_all(&cursor.to_le_bytes()).unwrap();
766    }
767
768    #[test]
769    fn existing_log_zero_capacity_rejected_before_cursor_modulo() {
770        let dir = tempfile::tempdir().unwrap();
771        let path = dir.path().join("log.vrrl");
772        write_header(&path, 0, 0);
773
774        let err = RingLog::open(&path, 4).expect_err("header capacity=0 must reject");
775        assert!(matches!(err, ReplayLogError::ZeroCapacity));
776    }
777
778    #[test]
779    fn existing_log_huge_capacity_rejected_before_replay_allocation() {
780        let dir = tempfile::tempdir().unwrap();
781        let path = dir.path().join("log.vrrl");
782        write_header(&path, MAX_REPLAY_RECORDS + 1, 0);
783
784        let err = RingLog::open(&path, 4).expect_err("huge header capacity must reject");
785        assert!(matches!(
786            err,
787            ReplayLogError::CapacityOverflow {
788                count,
789                max: MAX_REPLAY_RECORDS
790            } if count == MAX_REPLAY_RECORDS + 1
791        ));
792    }
793
794    #[test]
795    fn capacity_overflow_rejected() {
796        let dir = tempfile::tempdir().unwrap();
797        let path = dir.path().join("log.vrrl");
798        let err = RingLog::open(&path, MAX_REPLAY_RECORDS + 1)
799            .expect_err("over-size capacity must reject");
800        assert!(matches!(
801            err,
802            ReplayLogError::CapacityOverflow {
803                count,
804                max: MAX_REPLAY_RECORDS
805            } if count == MAX_REPLAY_RECORDS + 1
806        ));
807    }
808
809    /// Regression test for the P1 zero-magic skip behavior.
810    ///
811    /// Before the fix the skip was completely silent, an operator observing a
812    /// replay shorter than expected had no signal that a zero-magic record had
813    /// been encountered. After the fix the skip emits `tracing::warn!`. We
814    /// cannot assert tracing output in a unit test, but we CAN assert the
815    /// observable contract: a zero-magic slot in the middle of the scan range
816    /// must NOT produce an Err (it must still be skipped gracefully), AND the
817    /// replay result must be shorter than the number of appended records,
818    /// confirming the gap is present and observable to the caller through the
819    /// length discrepancy.
820    #[test]
821    fn replay_zero_magic_mid_sequence_skips_gracefully_and_produces_shorter_result() {
822        use std::io::{Seek, SeekFrom, Write as _};
823
824        let dir = tempfile::tempdir().unwrap();
825        let path = dir.path().join("log.vrrl");
826        let mut log = RingLog::open(&path, 4)
827            .expect("Fix: open fresh log; restore this invariant before continuing.");
828
829        // Append 3 records into a 4-slot capacity log.
830        log.append(rec(10, 100)).unwrap();
831        log.append(rec(20, 200)).unwrap();
832        log.append(rec(30, 300)).unwrap();
833        log.sync().unwrap();
834
835        // Verify a clean replay first: cursor = 3, scan starts at slot 3 (empty),
836        // then wraps to 0, 1, 2 (so we get exactly 3 records).
837        {
838            let records = log
839                .replay_all()
840                .expect("Fix: replay of 3 records must succeed");
841            assert_eq!(records.len(), 3, "Fix: 3 appended records must all replay");
842        }
843
844        // Now zero out the record at slot 1 (record 20) directly via file I/O.
845        // This simulates a sector fault / partial crash zeroing a live slot.
846        let slot1_offset = HEADER_BYTES + RECORD_BYTES; // slot 0 is at HEADER_BYTES; slot 1 follows
847        {
848            let mut f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
849            f.seek(SeekFrom::Start(slot1_offset)).unwrap();
850            f.write_all(&[0u8; RECORD_BYTES as usize]).unwrap();
851            f.sync_all().unwrap();
852        }
853
854        // Re-open the log to pick up the zeroed slot.
855        let mut log2 = RingLog::open(&path, 4).expect("Fix: reopen after zeroing must succeed");
856
857        // Replay must not return Err (the zero-magic skip is graceful).
858        let records = log2
859            .replay_all()
860            .expect("Fix: replay with a zeroed slot must not error");
861
862        // We should now see only 2 records (slot 0 = rec(10) and slot 2 = rec(30)).
863        // The scan order from cursor=3: slots 3 (empty), 0 (rec 10), 1 (zeroed → skip), 2 (rec 30).
864        assert_eq!(
865            records.len(),
866            2,
867            "Fix: zeroed slot must be skipped, yielding 2 out of 3 records; got: {:?}",
868            records.iter().map(|r| r.slot_idx).collect::<Vec<_>>()
869        );
870        // Record 10 must come before record 30 in publish order.
871        assert_eq!(
872            records[0].slot_idx, 10,
873            "Fix: first replayed record must be slot_idx=10"
874        );
875        assert_eq!(
876            records[1].slot_idx, 30,
877            "Fix: second replayed record must be slot_idx=30"
878        );
879    }
880}