Skip to main content

rhiza_log/
lib.rs

1use std::{
2    fmt, fs,
3    io::Write,
4    path::{Path, PathBuf},
5    sync::{
6        atomic::{AtomicU64, Ordering},
7        Mutex, MutexGuard,
8    },
9};
10
11use rhiza_core::{
12    ConfigurationState, EntryType, LogAnchor, LogEntry, LogHash, LogIndex, RecoveryAnchor,
13    SnapshotIdentity, StopBinding, SuccessorDescriptor, RECOVERY_ANCHOR_FORMAT_VERSION,
14    RECOVERY_ANCHOR_V1_FORMAT_VERSION,
15};
16
17pub const QLOG_MAGIC: [u8; 4] = *b"QLOG";
18pub const QLOG_FORMAT_VERSION: u16 = 1;
19pub const QLOG_HEADER_LEN: usize = 76;
20pub const QLOG_FRAME_MAGIC: [u8; 4] = *b"QFRM";
21pub const QLOG_FOOTER_MAGIC: [u8; 4] = *b"QEND";
22pub const OPEN_SEGMENT_MAX_BYTES: usize = 8 * 1024 * 1024;
23pub const OPEN_SEGMENT_MAX_ENTRIES: usize = 4096;
24
25const HEADER_WITHOUT_CRC_LEN: usize = 72;
26const FRAME_PREFIX_LEN: usize = 108;
27const FRAME_MIN_LEN: usize = 144;
28const FOOTER_LEN: usize = 88;
29const TRUNCATE_INTENT_FILE_NAME: &str = ".truncate-intent";
30const TRUNCATE_INTENT_MAGIC: [u8; 4] = *b"QTRN";
31const TRUNCATE_INTENT_VERSION: u16 = 1;
32const TRUNCATE_INTENT_REPLACEMENT: u16 = 1;
33const ANCHOR_FILE_NAME: &str = "recovery.anchor";
34const ANCHOR_MAGIC: [u8; 4] = *b"QANC";
35const ANCHOR_VERSION: u16 = 4;
36const ANCHOR_V3_VERSION: u16 = 3;
37const ANCHOR_V2_VERSION: u16 = 2;
38const ANCHOR_V1_VERSION: u16 = 1;
39const COMPACT_INTENT_FILE_NAME: &str = ".compact-intent";
40const COMPACT_INTENT_MAGIC: [u8; 4] = *b"QCMP";
41const COMPACT_INTENT_VERSION: u16 = 1;
42const COMPACT_INTENT_PREVIOUS_ANCHOR: u16 = 1;
43const COMPACT_INTENT_REPLACEMENT: u16 = 2;
44static NEXT_TEMP_FILE_ID: AtomicU64 = AtomicU64::new(0);
45
46pub type Result<T> = std::result::Result<T, Error>;
47
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub enum Error {
50    InvalidIndexRange {
51        start: LogIndex,
52        end: LogIndex,
53    },
54    CompactionUnsupported,
55    CompactionAboveTip {
56        target: LogIndex,
57        tip: Option<LogIndex>,
58    },
59    CompactionHashMismatch {
60        index: LogIndex,
61    },
62    CompactionRegression {
63        target: LogIndex,
64        anchor: LogIndex,
65    },
66    CompactionConflict {
67        index: LogIndex,
68    },
69    TruncateCompactedPrefix {
70        from: LogIndex,
71        anchor: LogIndex,
72    },
73    Decode(String),
74    Io(String),
75}
76
77impl fmt::Display for Error {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        match self {
80            Self::InvalidIndexRange { start, end } => {
81                write!(f, "invalid index range: start {start} is after end {end}")
82            }
83            Self::CompactionUnsupported => {
84                write!(f, "prefix compaction is unsupported by qlog v1")
85            }
86            Self::CompactionAboveTip { target, tip } => {
87                write!(f, "compaction target {target} is above log tip {tip:?}")
88            }
89            Self::CompactionHashMismatch { index } => {
90                write!(f, "compaction hash does not match log entry {index}")
91            }
92            Self::CompactionRegression { target, anchor } => write!(
93                f,
94                "compaction target {target} regresses persisted anchor {anchor}"
95            ),
96            Self::CompactionConflict { index } => {
97                write!(f, "compaction replay conflicts at index {index}")
98            }
99            Self::TruncateCompactedPrefix { from, anchor } => write!(
100                f,
101                "cannot truncate from {from} at or below compacted anchor {anchor}"
102            ),
103            Self::Decode(message) => write!(f, "qlog decode failed: {message}"),
104            Self::Io(message) => write!(f, "qlog io failed: {message}"),
105        }
106    }
107}
108
109impl std::error::Error for Error {}
110
111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112pub struct IndexRange {
113    start: LogIndex,
114    end: LogIndex,
115}
116
117impl IndexRange {
118    pub fn new(start: LogIndex, end: LogIndex) -> Result<Self> {
119        if start > end {
120            return Err(Error::InvalidIndexRange { start, end });
121        }
122
123        Ok(Self { start, end })
124    }
125
126    pub const fn start(&self) -> LogIndex {
127        self.start
128    }
129
130    pub const fn end(&self) -> LogIndex {
131        self.end
132    }
133}
134
135pub fn segment_file_name(range: IndexRange) -> String {
136    format!("{:020}-{:020}.qlog", range.start(), range.end())
137}
138
139pub fn encode_segment(entries: &[LogEntry]) -> Vec<u8> {
140    encode_segment_inner(entries, true)
141}
142
143pub fn encode_open_segment(entries: &[LogEntry]) -> Vec<u8> {
144    encode_segment_inner(entries, false)
145}
146
147fn encode_segment_inner(entries: &[LogEntry], closed: bool) -> Vec<u8> {
148    let Some(first) = entries.first() else {
149        return Vec::new();
150    };
151    let mut out = encode_header(first);
152    let mut entry_hashes = Vec::with_capacity(entries.len() * 32);
153    for entry in entries {
154        out.extend_from_slice(&encode_frame(entry));
155        entry_hashes.extend_from_slice(entry.hash.as_bytes());
156    }
157    if closed {
158        let last = entries.last().expect("non-empty entries");
159        out.extend_from_slice(&encode_footer(
160            &out[..QLOG_HEADER_LEN],
161            &entry_hashes,
162            last.index,
163            entries.len() as u64,
164            last.hash,
165        ));
166    }
167    out
168}
169
170pub fn decode_segment(bytes: &[u8]) -> Result<Vec<LogEntry>> {
171    decode_segment_for_cluster(bytes, "")
172}
173
174pub fn decode_segment_for_cluster(bytes: &[u8], cluster_id: &str) -> Result<Vec<LogEntry>> {
175    let (header, mut offset) = decode_header(bytes, cluster_id)?;
176    let cluster_id = if cluster_id.is_empty() {
177        header.cluster_id_hash.to_hex()
178    } else {
179        cluster_id.to_string()
180    };
181    let mut entries = Vec::new();
182    let mut entry_hashes = Vec::new();
183
184    loop {
185        if offset >= bytes.len() {
186            return Err(Error::Decode("missing qlog footer".into()));
187        }
188        if bytes.len() - offset >= 4 && bytes[offset..offset + 4] == QLOG_FOOTER_MAGIC {
189            decode_footer(
190                bytes,
191                offset,
192                &bytes[..QLOG_HEADER_LEN],
193                &entry_hashes,
194                &entries,
195            )?;
196            validate_entries(&header, &entries)?;
197            return Ok(entries);
198        }
199        let (entry, next_offset) = decode_frame(bytes, offset, &cluster_id)?;
200        entry_hashes.extend_from_slice(entry.hash.as_bytes());
201        entries.push(entry);
202        offset = next_offset;
203    }
204}
205
206pub fn write_segment_file(dir: impl Into<PathBuf>, entries: &[LogEntry]) -> Result<PathBuf> {
207    let dir = dir.into();
208    fs::create_dir_all(&dir).map_err(|err| Error::Io(err.to_string()))?;
209    publish_closed_segment(&dir, entries)
210}
211
212pub fn read_segment_file(path: impl Into<PathBuf>) -> Result<Vec<LogEntry>> {
213    let bytes = fs::read(path.into()).map_err(|err| Error::Io(err.to_string()))?;
214    decode_segment(&bytes)
215}
216
217pub fn recover_open_segment_file(
218    path: impl AsRef<Path>,
219    cluster_id: &str,
220) -> Result<Vec<LogEntry>> {
221    let path = path.as_ref();
222    let name = path
223        .file_name()
224        .and_then(|name| name.to_str())
225        .unwrap_or_default();
226    if !name.ends_with("-open.qlog") {
227        return Err(Error::Decode(
228            "refusing to recover non-open qlog segment".into(),
229        ));
230    }
231    let bytes = fs::read(path).map_err(|err| Error::Io(err.to_string()))?;
232    let (entries, valid_len) = recover_open_segment_prefix(&bytes, cluster_id)?;
233    if valid_len < bytes.len() {
234        let file = fs::OpenOptions::new()
235            .write(true)
236            .open(path)
237            .map_err(|err| Error::Io(err.to_string()))?;
238        file.set_len(valid_len as u64)
239            .map_err(|err| Error::Io(err.to_string()))?;
240        file.sync_all().map_err(|err| Error::Io(err.to_string()))?;
241    }
242    Ok(entries)
243}
244
245fn recover_open_segment_prefix(bytes: &[u8], cluster_id: &str) -> Result<(Vec<LogEntry>, usize)> {
246    let (header, mut offset) = decode_header(bytes, cluster_id)?;
247    let mut entries = Vec::new();
248    let mut valid_len = offset;
249    while offset < bytes.len() {
250        let (entry, next_offset) = match decode_frame(bytes, offset, cluster_id) {
251            Ok(frame) => frame,
252            Err(_) if final_frame_is_incomplete(bytes, offset)? => break,
253            Err(err) => return Err(err),
254        };
255        validate_decoded_entry(&header, entries.len(), entries.last(), &entry)?;
256        entries.push(entry);
257        offset = next_offset;
258        valid_len = offset;
259    }
260    Ok((entries, valid_len))
261}
262
263fn final_frame_is_incomplete(bytes: &[u8], offset: usize) -> Result<bool> {
264    let tail = &bytes[offset..];
265    let magic_prefix_len = tail.len().min(QLOG_FRAME_MAGIC.len());
266    if tail[..magic_prefix_len] != QLOG_FRAME_MAGIC[..magic_prefix_len] {
267        return Ok(false);
268    }
269    if tail.len() < 8 {
270        return Ok(true);
271    }
272
273    let frame_len = read_u32(tail, 4)? as usize;
274    if frame_len < FRAME_MIN_LEN {
275        return Ok(false);
276    }
277    if tail.len() >= FRAME_PREFIX_LEN {
278        let payload_len = read_u32(tail, 104)? as usize;
279        if FRAME_MIN_LEN.checked_add(payload_len) != Some(frame_len) {
280            return Ok(false);
281        }
282    }
283    Ok(frame_len > tail.len())
284}
285
286fn encode_header(first: &LogEntry) -> Vec<u8> {
287    let mut out = Vec::with_capacity(QLOG_HEADER_LEN);
288    out.extend_from_slice(&QLOG_MAGIC);
289    put_u16(&mut out, QLOG_FORMAT_VERSION);
290    put_u16(&mut out, QLOG_HEADER_LEN as u16);
291    put_u64(&mut out, first.index);
292    put_u64(&mut out, first.epoch);
293    put_u64(&mut out, first.config_id);
294    out.extend_from_slice(LogHash::digest(&[first.cluster_id.as_bytes()]).as_bytes());
295    put_u64(&mut out, 0);
296    let crc = crc32c(&out[..HEADER_WITHOUT_CRC_LEN]);
297    put_u32(&mut out, crc);
298    out
299}
300
301fn decode_header(bytes: &[u8], cluster_id: &str) -> Result<(SegmentHeader, usize)> {
302    if bytes.len() < QLOG_HEADER_LEN {
303        return Err(Error::Decode("short qlog header".into()));
304    }
305    if bytes[0..4] != QLOG_MAGIC {
306        return Err(Error::Decode("wrong qlog magic".into()));
307    }
308    let version = read_u16(bytes, 4)?;
309    if version != QLOG_FORMAT_VERSION {
310        return Err(Error::Decode("unsupported qlog version".into()));
311    }
312    let header_len = read_u16(bytes, 6)? as usize;
313    if header_len != QLOG_HEADER_LEN {
314        return Err(Error::Decode("invalid qlog header_len".into()));
315    }
316    let expected_crc = read_u32(bytes, HEADER_WITHOUT_CRC_LEN)?;
317    if crc32c(&bytes[..HEADER_WITHOUT_CRC_LEN]) != expected_crc {
318        return Err(Error::Decode("qlog header crc mismatch".into()));
319    }
320    let cluster_id_hash = read_hash(bytes, 32)?;
321    if !cluster_id.is_empty() && LogHash::digest(&[cluster_id.as_bytes()]) != cluster_id_hash {
322        return Err(Error::Decode("qlog cluster_id hash mismatch".into()));
323    }
324    Ok((
325        SegmentHeader::new_with_config(
326            cluster_id_hash,
327            read_u64(bytes, 16)?,
328            read_u64(bytes, 24)?,
329            read_u64(bytes, 8)?,
330            read_u64(bytes, 64)?,
331        ),
332        QLOG_HEADER_LEN,
333    ))
334}
335
336fn encode_frame(entry: &LogEntry) -> Vec<u8> {
337    let frame_len = FRAME_MIN_LEN + entry.payload.len();
338    let mut out = Vec::with_capacity(frame_len);
339    out.extend_from_slice(&QLOG_FRAME_MAGIC);
340    put_u32(&mut out, frame_len as u32);
341    put_u64(&mut out, entry.index);
342    put_u64(&mut out, entry.epoch);
343    put_u64(&mut out, entry.config_id);
344    out.push(entry.entry_type.as_u8());
345    out.extend_from_slice(&[0; 7]);
346    out.extend_from_slice(entry.prev_hash.as_bytes());
347    out.extend_from_slice(LogHash::digest(&[&entry.payload]).as_bytes());
348    put_u32(&mut out, entry.payload.len() as u32);
349    out.extend_from_slice(&entry.payload);
350    out.extend_from_slice(entry.hash.as_bytes());
351    let crc = crc32c(&out);
352    put_u32(&mut out, crc);
353    out
354}
355
356fn decode_frame(bytes: &[u8], offset: usize, cluster_id: &str) -> Result<(LogEntry, usize)> {
357    if bytes.len().saturating_sub(offset) < FRAME_MIN_LEN {
358        return Err(Error::Decode("short qlog frame".into()));
359    }
360    if bytes[offset..offset + 4] != QLOG_FRAME_MAGIC {
361        return Err(Error::Decode("wrong qlog frame magic".into()));
362    }
363    let frame_len = read_u32(bytes, offset + 4)? as usize;
364    let Some(frame_end) = offset.checked_add(frame_len) else {
365        return Err(Error::Decode("invalid qlog frame_len".into()));
366    };
367    if frame_len < FRAME_MIN_LEN || frame_end > bytes.len() {
368        return Err(Error::Decode("invalid qlog frame_len".into()));
369    }
370    let crc_offset = frame_end - 4;
371    let expected_crc = read_u32(bytes, crc_offset)?;
372    if crc32c(&bytes[offset..crc_offset]) != expected_crc {
373        return Err(Error::Decode("qlog frame crc mismatch".into()));
374    }
375    let index = read_u64(bytes, offset + 8)?;
376    let epoch = read_u64(bytes, offset + 16)?;
377    let config_id = read_u64(bytes, offset + 24)?;
378    let entry_type = EntryType::from_u8(bytes[offset + 32])
379        .ok_or_else(|| Error::Decode("invalid qlog entry_type".into()))?;
380    let prev_hash = read_hash(bytes, offset + 40)?;
381    let payload_hash = read_hash(bytes, offset + 72)?;
382    let payload_len = read_u32(bytes, offset + 104)? as usize;
383    if FRAME_MIN_LEN.checked_add(payload_len) != Some(frame_len) {
384        return Err(Error::Decode(
385            "qlog frame_len does not match payload_len".into(),
386        ));
387    }
388    let payload_start = offset + FRAME_PREFIX_LEN;
389    let payload_end = payload_start
390        .checked_add(payload_len)
391        .ok_or_else(|| Error::Decode("invalid qlog payload_len".into()))?;
392    let payload = bytes[payload_start..payload_end].to_vec();
393    if LogHash::digest(&[&payload]) != payload_hash {
394        return Err(Error::Decode("qlog payload_hash mismatch".into()));
395    }
396    let hash = read_hash(bytes, payload_end)?;
397    let entry = LogEntry {
398        cluster_id: cluster_id.to_string(),
399        epoch,
400        config_id,
401        index,
402        entry_type,
403        payload,
404        prev_hash,
405        hash,
406    };
407    if entry.recompute_hash() != hash {
408        return Err(Error::Decode("qlog entry_hash mismatch".into()));
409    }
410    Ok((entry, frame_end))
411}
412
413fn encode_footer(
414    header: &[u8],
415    entry_hashes: &[u8],
416    end_index: LogIndex,
417    entry_count: u64,
418    last_entry_hash: LogHash,
419) -> Vec<u8> {
420    let mut prefix = Vec::with_capacity(52);
421    prefix.extend_from_slice(&QLOG_FOOTER_MAGIC);
422    put_u64(&mut prefix, end_index);
423    put_u64(&mut prefix, entry_count);
424    prefix.extend_from_slice(last_entry_hash.as_bytes());
425    let segment_hash = LogHash::digest(&[header, entry_hashes, &prefix]);
426    let mut out = prefix;
427    out.extend_from_slice(segment_hash.as_bytes());
428    let crc = crc32c(&out);
429    put_u32(&mut out, crc);
430    out
431}
432
433fn decode_footer(
434    bytes: &[u8],
435    offset: usize,
436    header: &[u8],
437    entry_hashes: &[u8],
438    entries: &[LogEntry],
439) -> Result<()> {
440    if bytes.len().saturating_sub(offset) != FOOTER_LEN {
441        return Err(Error::Decode("invalid qlog footer length".into()));
442    }
443    let footer = &bytes[offset..offset + FOOTER_LEN];
444    let expected_crc = read_u32(footer, FOOTER_LEN - 4)?;
445    if crc32c(&footer[..FOOTER_LEN - 4]) != expected_crc {
446        return Err(Error::Decode("qlog footer crc mismatch".into()));
447    }
448    let end_index = read_u64(footer, 4)?;
449    let entry_count = read_u64(footer, 12)?;
450    let last_hash = read_hash(footer, 20)?;
451    let segment_hash = read_hash(footer, 52)?;
452    let expected_segment_hash = LogHash::digest(&[header, entry_hashes, &footer[..52]]);
453    if segment_hash != expected_segment_hash {
454        return Err(Error::Decode("qlog segment_hash mismatch".into()));
455    }
456    if entries.len() as u64 != entry_count {
457        return Err(Error::Decode("qlog footer entry_count mismatch".into()));
458    }
459    if let Some(last) = entries.last() {
460        if last.index != end_index || last.hash != last_hash {
461            return Err(Error::Decode("qlog footer last entry mismatch".into()));
462        }
463    } else if entry_count != 0 {
464        return Err(Error::Decode("qlog empty footer mismatch".into()));
465    }
466    Ok(())
467}
468
469fn validate_entries(header: &SegmentHeader, entries: &[LogEntry]) -> Result<()> {
470    for (position, entry) in entries.iter().enumerate() {
471        validate_decoded_entry(
472            header,
473            position,
474            position.checked_sub(1).map(|i| &entries[i]),
475            entry,
476        )?;
477    }
478    Ok(())
479}
480
481fn validate_decoded_entry(
482    header: &SegmentHeader,
483    position: usize,
484    previous: Option<&LogEntry>,
485    entry: &LogEntry,
486) -> Result<()> {
487    let expected_index = u64::try_from(position)
488        .ok()
489        .and_then(|position| header.start_index.checked_add(position));
490    if expected_index != Some(entry.index) {
491        return Err(Error::Decode("qlog index gap".into()));
492    }
493    if entry.epoch != header.epoch || entry.config_id != header.config_id {
494        return Err(Error::Decode("qlog epoch/config mismatch".into()));
495    }
496    if previous.is_some_and(|previous| entry.prev_hash != previous.hash) {
497        return Err(Error::Decode("qlog hash chain mismatch".into()));
498    }
499    Ok(())
500}
501
502fn read_hash(bytes: &[u8], offset: usize) -> Result<LogHash> {
503    let slice = bytes
504        .get(offset..offset + 32)
505        .ok_or_else(|| Error::Decode("short qlog hash".into()))?;
506    let mut out = [0; 32];
507    out.copy_from_slice(slice);
508    Ok(LogHash::from_bytes(out))
509}
510
511fn read_u16(bytes: &[u8], offset: usize) -> Result<u16> {
512    let slice = bytes
513        .get(offset..offset + 2)
514        .ok_or_else(|| Error::Decode("short qlog u16".into()))?;
515    Ok(u16::from_be_bytes(
516        slice.try_into().expect("u16 slice length"),
517    ))
518}
519
520fn read_u32(bytes: &[u8], offset: usize) -> Result<u32> {
521    let slice = bytes
522        .get(offset..offset + 4)
523        .ok_or_else(|| Error::Decode("short qlog u32".into()))?;
524    Ok(u32::from_be_bytes(
525        slice.try_into().expect("u32 slice length"),
526    ))
527}
528
529fn read_u64(bytes: &[u8], offset: usize) -> Result<u64> {
530    let slice = bytes
531        .get(offset..offset + 8)
532        .ok_or_else(|| Error::Decode("short qlog u64".into()))?;
533    Ok(u64::from_be_bytes(
534        slice.try_into().expect("u64 slice length"),
535    ))
536}
537
538fn put_u16(out: &mut Vec<u8>, value: u16) {
539    out.extend_from_slice(&value.to_be_bytes());
540}
541
542fn put_u32(out: &mut Vec<u8>, value: u32) {
543    out.extend_from_slice(&value.to_be_bytes());
544}
545
546fn put_u64(out: &mut Vec<u8>, value: u64) {
547    out.extend_from_slice(&value.to_be_bytes());
548}
549
550fn crc32c(bytes: &[u8]) -> u32 {
551    let mut crc = !0u32;
552    for byte in bytes {
553        crc ^= u32::from(*byte);
554        for _ in 0..8 {
555            let mask = (crc & 1).wrapping_neg();
556            crc = (crc >> 1) ^ (0x82f6_3b78 & mask);
557        }
558    }
559    !crc
560}
561
562#[derive(Clone, Copy, Debug, Eq, PartialEq)]
563pub struct SegmentHeader {
564    magic: [u8; 4],
565    cluster_id_hash: LogHash,
566    epoch: u64,
567    config_id: u64,
568    start_index: LogIndex,
569    created_at_unix_ms: u64,
570}
571
572impl SegmentHeader {
573    pub const fn new(
574        cluster_id_hash: LogHash,
575        epoch: u64,
576        start_index: LogIndex,
577        created_at_unix_ms: u64,
578    ) -> Self {
579        Self {
580            magic: QLOG_MAGIC,
581            cluster_id_hash,
582            epoch,
583            config_id: 0,
584            start_index,
585            created_at_unix_ms,
586        }
587    }
588
589    pub const fn new_with_config(
590        cluster_id_hash: LogHash,
591        epoch: u64,
592        config_id: u64,
593        start_index: LogIndex,
594        created_at_unix_ms: u64,
595    ) -> Self {
596        Self {
597            magic: QLOG_MAGIC,
598            cluster_id_hash,
599            epoch,
600            config_id,
601            start_index,
602            created_at_unix_ms,
603        }
604    }
605
606    pub const fn magic(&self) -> [u8; 4] {
607        self.magic
608    }
609
610    pub const fn epoch(&self) -> u64 {
611        self.epoch
612    }
613
614    pub const fn config_id(&self) -> u64 {
615        self.config_id
616    }
617
618    pub const fn start_index(&self) -> LogIndex {
619        self.start_index
620    }
621}
622
623#[derive(Clone, Debug, Eq, PartialEq)]
624pub struct SegmentFile {
625    range: IndexRange,
626    bytes: Vec<u8>,
627}
628
629impl SegmentFile {
630    pub fn new(range: IndexRange, bytes: Vec<u8>) -> Self {
631        Self { range, bytes }
632    }
633
634    pub const fn range(&self) -> IndexRange {
635        self.range
636    }
637
638    pub fn bytes(&self) -> &[u8] {
639        &self.bytes
640    }
641}
642
643pub trait LogStore {
644    fn append(&self, entry: &LogEntry) -> Result<()>;
645    fn append_batch(&self, entries: &[LogEntry]) -> Result<()>;
646    fn read(&self, index: LogIndex) -> Result<Option<LogEntry>>;
647    fn read_range(&self, range: IndexRange) -> Result<Vec<LogEntry>>;
648    fn last_index(&self) -> Result<Option<LogIndex>>;
649    fn truncate_suffix(&self, from: LogIndex) -> Result<()>;
650    fn compact_prefix(&self, verified_snapshot_anchor: &RecoveryAnchor) -> Result<()>;
651}
652
653#[derive(Clone, Debug, Eq, PartialEq)]
654pub struct LogState {
655    pub anchor: Option<RecoveryAnchor>,
656    pub first_retained_index: LogIndex,
657    pub tip: Option<LogAnchor>,
658}
659
660#[derive(Debug)]
661pub struct FileLogStore {
662    inner: Mutex<FileLogStoreInner>,
663}
664
665impl FileLogStore {
666    pub fn open(
667        dir: impl Into<PathBuf>,
668        cluster_id: impl Into<String>,
669        epoch: u64,
670        config_id: u64,
671    ) -> Result<Self> {
672        Self::open_with_configuration(
673            dir,
674            cluster_id,
675            epoch,
676            ConfigurationState::active(config_id, LogHash::ZERO),
677        )
678    }
679
680    pub fn open_with_configuration(
681        dir: impl Into<PathBuf>,
682        cluster_id: impl Into<String>,
683        epoch: u64,
684        initial_configuration: ConfigurationState,
685    ) -> Result<Self> {
686        let dir = dir.into();
687        let cluster_id = cluster_id.into();
688        if cluster_id.is_empty() {
689            return Err(Error::Decode("cluster_id must not be empty".into()));
690        }
691
692        let existed = dir.exists();
693        fs::create_dir_all(&dir).map_err(|err| Error::Io(err.to_string()))?;
694        if !existed {
695            if let Some(parent) = dir.parent() {
696                sync_directory(parent)?;
697            }
698        }
699        recover_truncate_intent(&dir)?;
700        recover_compact_intent(&dir)?;
701        let anchor = read_anchor(&dir)?;
702        validate_anchor_identity(anchor.as_ref(), &cluster_id, epoch)?;
703        let (segments, configuration_state) = scan_closed_segments(
704            &dir,
705            &cluster_id,
706            epoch,
707            &initial_configuration,
708            anchor.as_ref(),
709        )?;
710        let (open_segment, configuration_state) = scan_open_segment(
711            &dir,
712            &cluster_id,
713            epoch,
714            anchor.as_ref(),
715            &segments,
716            configuration_state,
717        )?;
718
719        Ok(Self {
720            inner: Mutex::new(FileLogStoreInner {
721                dir,
722                cluster_id,
723                epoch,
724                initial_configuration,
725                configuration_state,
726                anchor,
727                segments,
728                open_segment,
729            }),
730        })
731    }
732
733    fn lock(&self) -> Result<MutexGuard<'_, FileLogStoreInner>> {
734        self.inner
735            .lock()
736            .map_err(|_| Error::Io("file log store lock poisoned".into()))
737    }
738
739    pub fn logical_state(&self) -> Result<LogState> {
740        let inner = self.lock()?;
741        let first_retained_index = match &inner.anchor {
742            Some(anchor) => anchor
743                .compacted()
744                .index()
745                .checked_add(1)
746                .ok_or_else(|| Error::Decode("qlog anchor index overflow".into()))?,
747            None => 1,
748        };
749        let tip = inner
750            .open_segment
751            .as_ref()
752            .and_then(|segment| segment.entries.last())
753            .map(|entry| LogAnchor::new(entry.index, entry.hash))
754            .or_else(|| {
755                inner.segments.last().map(|segment| {
756                    let entry = segment.entries.last().expect("non-empty segment");
757                    LogAnchor::new(entry.index, entry.hash)
758                })
759            })
760            .or_else(|| inner.anchor.as_ref().map(|anchor| *anchor.compacted()));
761        Ok(LogState {
762            anchor: inner.anchor.clone(),
763            first_retained_index,
764            tip,
765        })
766    }
767
768    pub fn configuration_state(&self) -> Result<ConfigurationState> {
769        Ok(self.lock()?.configuration_state.clone())
770    }
771
772    pub fn install_recovery_anchor(
773        &self,
774        verified_anchor: &RecoveryAnchor,
775        expected_recovery_generation: u64,
776        expected_configuration: &ConfigurationState,
777    ) -> Result<()> {
778        let mut inner = self.lock()?;
779        validate_anchor(verified_anchor)?;
780        validate_anchor_identity(Some(verified_anchor), &inner.cluster_id, inner.epoch)?;
781        if verified_anchor.recovery_generation() != expected_recovery_generation {
782            return Err(Error::Decode(
783                "recovery anchor generation does not match expected generation".into(),
784            ));
785        }
786        if verified_anchor.configuration_state() != expected_configuration {
787            return Err(Error::Decode(
788                "recovery anchor configuration state does not match expected state".into(),
789            ));
790        }
791        if inner.anchor.is_some() || !inner.segments.is_empty() || inner.open_segment.is_some() {
792            return Err(Error::Decode(
793                "recovery anchor installation requires an empty qlog store".into(),
794            ));
795        }
796
797        install_anchor(
798            &inner.dir,
799            &CompactIntent {
800                previous_anchor: None,
801                anchor: verified_anchor.clone(),
802                old_segment_names: Vec::new(),
803                replacement: None,
804            },
805        )?;
806        sync_directory(&inner.dir)?;
807        inner.anchor = Some(verified_anchor.clone());
808        inner.configuration_state = verified_anchor.configuration_state().clone();
809        Ok(())
810    }
811
812    /// Appends validated entries without issuing a data sync.
813    ///
814    /// Call [`Self::sync`] before making the appended entries externally durable.
815    pub fn append_batch_buffered(&self, entries: &[LogEntry]) -> Result<()> {
816        let mut inner = self.lock()?;
817        append_batch_to_open_segment(&mut inner, entries, false)
818    }
819
820    /// Syncs all buffered appends and returns the durable logical tip.
821    pub fn sync(&self) -> Result<Option<LogIndex>> {
822        let inner = self.lock()?;
823        if let Some(open) = &inner.open_segment {
824            open.file
825                .sync_data()
826                .map_err(|err| Error::Io(err.to_string()))?;
827        }
828        Ok(inner.last_index())
829    }
830}
831
832impl LogStore for FileLogStore {
833    fn append(&self, entry: &LogEntry) -> Result<()> {
834        self.append_batch(std::slice::from_ref(entry))
835    }
836
837    fn append_batch(&self, entries: &[LogEntry]) -> Result<()> {
838        let mut inner = self.lock()?;
839        append_batch_to_open_segment(&mut inner, entries, true)
840    }
841
842    fn read(&self, index: LogIndex) -> Result<Option<LogEntry>> {
843        let inner = self.lock()?;
844        Ok(inner
845            .segments
846            .iter()
847            .find(|segment| segment.start() <= index && index <= segment.end())
848            .map(|segment| segment.entries[(index - segment.start()) as usize].clone())
849            .or_else(|| {
850                inner.open_segment.as_ref().and_then(|segment| {
851                    segment
852                        .entries
853                        .first()
854                        .filter(|first| first.index <= index)
855                        .and_then(|first| segment.entries.get((index - first.index) as usize))
856                        .cloned()
857                })
858            }))
859    }
860
861    fn read_range(&self, range: IndexRange) -> Result<Vec<LogEntry>> {
862        let inner = self.lock()?;
863        Ok(inner
864            .segments
865            .iter()
866            .flat_map(|segment| segment.entries.iter())
867            .chain(
868                inner
869                    .open_segment
870                    .iter()
871                    .flat_map(|segment| segment.entries.iter()),
872            )
873            .filter(|entry| range.start() <= entry.index && entry.index <= range.end())
874            .cloned()
875            .collect())
876    }
877
878    fn last_index(&self) -> Result<Option<LogIndex>> {
879        let inner = self.lock()?;
880        Ok(inner.last_index())
881    }
882
883    fn truncate_suffix(&self, from: LogIndex) -> Result<()> {
884        let mut inner = self.lock()?;
885        if let Some(anchor) = &inner.anchor {
886            if from <= anchor.compacted().index() {
887                return Err(Error::TruncateCompactedPrefix {
888                    from,
889                    anchor: anchor.compacted().index(),
890                });
891            }
892        }
893        seal_open_segment(&mut inner)?;
894        truncate_suffix_with_hook(&mut inner, from, &mut |_| Ok(()))?;
895        let (segments, configuration_state) = scan_closed_segments(
896            &inner.dir,
897            &inner.cluster_id,
898            inner.epoch,
899            &inner.initial_configuration,
900            inner.anchor.as_ref(),
901        )?;
902        inner.segments = segments;
903        inner.configuration_state = configuration_state;
904        Ok(())
905    }
906
907    fn compact_prefix(&self, verified_snapshot_anchor: &RecoveryAnchor) -> Result<()> {
908        let mut inner = self.lock()?;
909        if !validate_compaction(&inner, verified_snapshot_anchor)? {
910            return Ok(());
911        }
912        seal_open_segment(&mut inner)?;
913        compact_prefix_with_hook(&mut inner, verified_snapshot_anchor, &mut |_| Ok(()))?;
914        inner.anchor = read_anchor(&inner.dir)?;
915        let (segments, configuration_state) = scan_closed_segments(
916            &inner.dir,
917            &inner.cluster_id,
918            inner.epoch,
919            &inner.initial_configuration,
920            inner.anchor.as_ref(),
921        )?;
922        inner.segments = segments;
923        inner.configuration_state = configuration_state;
924        Ok(())
925    }
926}
927
928#[derive(Debug)]
929struct FileLogStoreInner {
930    dir: PathBuf,
931    cluster_id: String,
932    epoch: u64,
933    initial_configuration: ConfigurationState,
934    configuration_state: ConfigurationState,
935    anchor: Option<RecoveryAnchor>,
936    segments: Vec<ClosedSegment>,
937    open_segment: Option<OpenSegment>,
938}
939
940#[derive(Clone, Debug)]
941struct ClosedSegment {
942    entries: Vec<LogEntry>,
943}
944
945#[derive(Debug)]
946struct OpenSegment {
947    config_id: u64,
948    path: PathBuf,
949    file: fs::File,
950    bytes_len: usize,
951    entries: Vec<LogEntry>,
952}
953
954#[derive(Clone, Debug, Eq, PartialEq)]
955struct TruncateIntent {
956    old_segment_names: Vec<String>,
957    replacement: Option<TruncateReplacement>,
958}
959
960#[derive(Clone, Debug, Eq, PartialEq)]
961struct TruncateReplacement {
962    temp_name: String,
963    final_name: String,
964}
965
966#[derive(Clone, Debug, Eq, PartialEq)]
967struct CompactIntent {
968    previous_anchor: Option<RecoveryAnchor>,
969    anchor: RecoveryAnchor,
970    old_segment_names: Vec<String>,
971    replacement: Option<TruncateReplacement>,
972}
973
974#[derive(Clone, Copy, Debug, Eq, PartialEq)]
975enum CompactPhase {
976    ReplacementPrepared,
977    IntentRenamed,
978    IntentDurable,
979    AnchorInstalled,
980    AnchorDurable,
981    OldSegmentRemoved(usize),
982    ReplacementInstalled,
983    AppliedDirectorySynced,
984    IntentRemoved,
985    CompleteDirectorySynced,
986}
987
988#[derive(Clone, Copy, Debug, Eq, PartialEq)]
989enum TruncatePhase {
990    ReplacementPrepared,
991    IntentRenamed,
992    IntentDurable,
993    OldSegmentRemoved(usize),
994    ReplacementInstalled,
995    AppliedDirectorySynced,
996    IntentRemoved,
997    CompleteDirectorySynced,
998}
999
1000impl ClosedSegment {
1001    fn start(&self) -> LogIndex {
1002        self.entries.first().expect("non-empty segment").index
1003    }
1004
1005    fn end(&self) -> LogIndex {
1006        self.entries.last().expect("non-empty segment").index
1007    }
1008}
1009
1010impl FileLogStoreInner {
1011    fn last_index(&self) -> Option<LogIndex> {
1012        self.open_segment
1013            .as_ref()
1014            .and_then(|segment| segment.entries.last().map(|entry| entry.index))
1015            .or_else(|| self.segments.last().map(ClosedSegment::end))
1016            .or_else(|| {
1017                self.anchor
1018                    .as_ref()
1019                    .map(|anchor| anchor.compacted().index())
1020            })
1021    }
1022}
1023
1024fn scan_closed_segments(
1025    dir: &Path,
1026    cluster_id: &str,
1027    epoch: u64,
1028    initial_configuration: &ConfigurationState,
1029    anchor: Option<&RecoveryAnchor>,
1030) -> Result<(Vec<ClosedSegment>, ConfigurationState)> {
1031    let mut paths = Vec::new();
1032    for entry in fs::read_dir(dir).map_err(|err| Error::Io(err.to_string()))? {
1033        let entry = entry.map_err(|err| Error::Io(err.to_string()))?;
1034        let name = entry.file_name();
1035        let name = name.to_string_lossy();
1036        if name.ends_with("-open.qlog") || !name.ends_with(".qlog") {
1037            continue;
1038        }
1039        let range = parse_closed_segment_name(&name)?;
1040        paths.push((range, entry.path()));
1041    }
1042    paths.sort_by_key(|(range, _)| (range.start(), range.end()));
1043
1044    let mut segments = Vec::with_capacity(paths.len());
1045    let mut configuration_state = anchor
1046        .map(|anchor| anchor.configuration_state().clone())
1047        .unwrap_or_else(|| initial_configuration.clone());
1048    for (range, path) in paths {
1049        let bytes = fs::read(&path).map_err(|err| Error::Io(err.to_string()))?;
1050        let entries = decode_segment_for_cluster(&bytes, cluster_id)?;
1051        let first = entries
1052            .first()
1053            .ok_or_else(|| Error::Decode("closed qlog segment is empty".into()))?;
1054        let last = entries.last().expect("non-empty segment");
1055        if first.index != range.start() || last.index != range.end() {
1056            return Err(Error::Decode(
1057                "qlog segment filename range does not match entries".into(),
1058            ));
1059        }
1060        if first.epoch != epoch {
1061            return Err(Error::Decode(
1062                "qlog segment does not match configured epoch".into(),
1063            ));
1064        }
1065        if segments.is_empty() {
1066            let (expected_index, expected_prev_hash) = match anchor {
1067                Some(anchor) => (
1068                    anchor
1069                        .compacted()
1070                        .index()
1071                        .checked_add(1)
1072                        .ok_or_else(|| Error::Decode("qlog anchor index overflow".into()))?,
1073                    anchor.compacted().hash(),
1074                ),
1075                None => (1, LogHash::ZERO),
1076            };
1077            if first.index != expected_index || first.prev_hash != expected_prev_hash {
1078                return Err(Error::Decode(
1079                    "qlog first retained entry does not match recovery anchor".into(),
1080                ));
1081            }
1082        }
1083        if let Some(previous) = segments.last() {
1084            let previous: &ClosedSegment = previous;
1085            if previous.end().checked_add(1) != Some(first.index) {
1086                return Err(Error::Decode("qlog index gap across segments".into()));
1087            }
1088            if first.prev_hash != previous.entries.last().expect("non-empty segment").hash {
1089                return Err(Error::Decode(
1090                    "qlog hash chain mismatch across segments".into(),
1091                ));
1092            }
1093        }
1094        for entry in &entries {
1095            configuration_state = configuration_state
1096                .validate_entry(entry)
1097                .map_err(|err| Error::Decode(err.to_string()))?;
1098        }
1099        segments.push(ClosedSegment { entries });
1100    }
1101    Ok((segments, configuration_state))
1102}
1103
1104fn scan_open_segment(
1105    dir: &Path,
1106    cluster_id: &str,
1107    epoch: u64,
1108    anchor: Option<&RecoveryAnchor>,
1109    segments: &[ClosedSegment],
1110    mut configuration_state: ConfigurationState,
1111) -> Result<(Option<OpenSegment>, ConfigurationState)> {
1112    let mut paths = fs::read_dir(dir)
1113        .map_err(|err| Error::Io(err.to_string()))?
1114        .filter_map(|entry| entry.ok())
1115        .filter_map(|entry| {
1116            let name = entry.file_name().into_string().ok()?;
1117            name.ends_with("-open.qlog").then_some((name, entry.path()))
1118        })
1119        .collect::<Vec<_>>();
1120    paths.sort_by(|left, right| left.0.cmp(&right.0));
1121    if paths.len() > 1 {
1122        return Err(Error::Decode("multiple open qlog segments".into()));
1123    }
1124    let Some((name, path)) = paths.pop() else {
1125        return Ok((None, configuration_state));
1126    };
1127    let start = parse_open_segment_name(&name)?;
1128    let bytes = fs::read(&path).map_err(|err| Error::Io(err.to_string()))?;
1129    let (header, _) = decode_header(&bytes, cluster_id)?;
1130    if header.start_index != start || header.epoch != epoch {
1131        return Err(Error::Decode(
1132            "open qlog filename or epoch does not match header".into(),
1133        ));
1134    }
1135    let entries = recover_open_segment_file(&path, cluster_id)?;
1136
1137    if let Some(segment) = segments
1138        .iter()
1139        .find(|segment| segment.start() == start && segment.entries == entries)
1140    {
1141        if segment.end() != entries.last().map_or(start, |entry| entry.index) {
1142            return Err(Error::Decode("open qlog duplicate range mismatch".into()));
1143        }
1144        fs::remove_file(&path).map_err(|err| Error::Io(err.to_string()))?;
1145        sync_directory(dir)?;
1146        return Ok((None, configuration_state));
1147    }
1148
1149    let (expected_index, expected_prev_hash) = match segments.last() {
1150        Some(segment) => (
1151            segment
1152                .end()
1153                .checked_add(1)
1154                .ok_or_else(|| Error::Decode("qlog index overflow".into()))?,
1155            segment.entries.last().expect("non-empty segment").hash,
1156        ),
1157        None => match anchor {
1158            Some(anchor) => (
1159                anchor
1160                    .compacted()
1161                    .index()
1162                    .checked_add(1)
1163                    .ok_or_else(|| Error::Decode("qlog anchor index overflow".into()))?,
1164                anchor.compacted().hash(),
1165            ),
1166            None => (1, LogHash::ZERO),
1167        },
1168    };
1169    if start != expected_index {
1170        return Err(Error::Decode(
1171            "open qlog does not start at the retained tip".into(),
1172        ));
1173    }
1174    if let Some(first) = entries.first() {
1175        if first.prev_hash != expected_prev_hash {
1176            return Err(Error::Decode(
1177                "qlog hash chain mismatch before open segment".into(),
1178            ));
1179        }
1180    }
1181    for entry in &entries {
1182        configuration_state = configuration_state
1183            .validate_entry(entry)
1184            .map_err(|err| Error::Decode(err.to_string()))?;
1185    }
1186    let file = fs::OpenOptions::new()
1187        .append(true)
1188        .open(&path)
1189        .map_err(|err| Error::Io(err.to_string()))?;
1190    let bytes_len = usize::try_from(
1191        file.metadata()
1192            .map_err(|err| Error::Io(err.to_string()))?
1193            .len(),
1194    )
1195    .map_err(|_| Error::Io("open qlog segment is too large".into()))?;
1196    Ok((
1197        Some(OpenSegment {
1198            config_id: header.config_id,
1199            path,
1200            file,
1201            bytes_len,
1202            entries,
1203        }),
1204        configuration_state,
1205    ))
1206}
1207
1208fn parse_closed_segment_name(name: &str) -> Result<IndexRange> {
1209    let stem = name
1210        .strip_suffix(".qlog")
1211        .ok_or_else(|| Error::Decode("invalid closed qlog segment filename".into()))?;
1212    let (start, end) = stem
1213        .split_once('-')
1214        .ok_or_else(|| Error::Decode("invalid closed qlog segment filename".into()))?;
1215    if start.len() != 20
1216        || end.len() != 20
1217        || !start.bytes().all(|byte| byte.is_ascii_digit())
1218        || !end.bytes().all(|byte| byte.is_ascii_digit())
1219    {
1220        return Err(Error::Decode("invalid closed qlog segment filename".into()));
1221    }
1222    let start = start
1223        .parse()
1224        .map_err(|_| Error::Decode("invalid closed qlog segment filename".into()))?;
1225    let end = end
1226        .parse()
1227        .map_err(|_| Error::Decode("invalid closed qlog segment filename".into()))?;
1228    IndexRange::new(start, end)
1229}
1230
1231fn parse_open_segment_name(name: &str) -> Result<LogIndex> {
1232    let start = name
1233        .strip_suffix("-open.qlog")
1234        .ok_or_else(|| Error::Decode("invalid open qlog segment filename".into()))?;
1235    if start.len() != 20 || !start.bytes().all(|byte| byte.is_ascii_digit()) {
1236        return Err(Error::Decode("invalid open qlog segment filename".into()));
1237    }
1238    start
1239        .parse()
1240        .map_err(|_| Error::Decode("invalid open qlog segment filename".into()))
1241}
1242
1243fn open_segment_file_name(start: LogIndex) -> String {
1244    format!("{start:020}-open.qlog")
1245}
1246
1247fn validate_append(inner: &FileLogStoreInner, entries: &[LogEntry]) -> Result<ConfigurationState> {
1248    let open_tip = inner
1249        .open_segment
1250        .as_ref()
1251        .and_then(|segment| segment.entries.last());
1252    let (mut expected_index, mut expected_prev_hash) = match open_tip {
1253        Some(entry) => (
1254            entry
1255                .index
1256                .checked_add(1)
1257                .ok_or_else(|| Error::Decode("qlog index overflow".into()))?,
1258            entry.hash,
1259        ),
1260        None => match inner.segments.last() {
1261            Some(segment) => (
1262                segment
1263                    .end()
1264                    .checked_add(1)
1265                    .ok_or_else(|| Error::Decode("qlog index overflow".into()))?,
1266                segment.entries.last().expect("non-empty segment").hash,
1267            ),
1268            None => match &inner.anchor {
1269                Some(anchor) => (
1270                    anchor
1271                        .compacted()
1272                        .index()
1273                        .checked_add(1)
1274                        .ok_or_else(|| Error::Decode("qlog anchor index overflow".into()))?,
1275                    anchor.compacted().hash(),
1276                ),
1277                None => (1, LogHash::ZERO),
1278            },
1279        },
1280    };
1281
1282    let mut configuration_state = inner.configuration_state.clone();
1283    for (position, entry) in entries.iter().enumerate() {
1284        if entry.cluster_id != inner.cluster_id || entry.epoch != inner.epoch {
1285            return Err(Error::Decode(
1286                "qlog entry does not match configured cluster/epoch".into(),
1287            ));
1288        }
1289        if entry.index != expected_index {
1290            return Err(Error::Decode("qlog append index is not contiguous".into()));
1291        }
1292        if entry.prev_hash != expected_prev_hash {
1293            return Err(Error::Decode("qlog append prev_hash mismatch".into()));
1294        }
1295        if entry.recompute_hash() != entry.hash {
1296            return Err(Error::Decode("qlog append entry_hash mismatch".into()));
1297        }
1298        configuration_state = configuration_state
1299            .validate_entry(entry)
1300            .map_err(|err| Error::Decode(err.to_string()))?;
1301        expected_prev_hash = entry.hash;
1302        if position + 1 < entries.len() {
1303            expected_index = expected_index
1304                .checked_add(1)
1305                .ok_or_else(|| Error::Decode("qlog index overflow".into()))?;
1306        }
1307    }
1308    Ok(configuration_state)
1309}
1310
1311fn append_batch_to_open_segment(
1312    inner: &mut FileLogStoreInner,
1313    entries: &[LogEntry],
1314    sync: bool,
1315) -> Result<()> {
1316    if entries.is_empty() {
1317        return Ok(());
1318    }
1319    validate_append(inner, entries)?;
1320    for homogeneous in entries.chunk_by(|left, right| left.config_id == right.config_id) {
1321        let mut offset = 0;
1322        while offset < homogeneous.len() {
1323            ensure_open_segment(inner, &homogeneous[offset])?;
1324            let chunk_len = open_segment_chunk_len(
1325                inner.open_segment.as_ref().expect("open segment exists"),
1326                &homogeneous[offset..],
1327            )?;
1328            if chunk_len == 0 {
1329                seal_open_segment(inner)?;
1330                continue;
1331            }
1332
1333            let chunk = &homogeneous[offset..offset + chunk_len];
1334            let bytes = chunk.iter().flat_map(encode_frame).collect::<Vec<_>>();
1335            let open = inner.open_segment.as_mut().expect("open segment exists");
1336            let old_len = open.bytes_len as u64;
1337            if let Err(write_err) = open.file.write_all(&bytes) {
1338                if let Err(rollback_err) = open.file.set_len(old_len) {
1339                    return Err(Error::Io(format!(
1340                        "{write_err}; failed to roll back partial qlog append: {rollback_err}"
1341                    )));
1342                }
1343                return Err(Error::Io(write_err.to_string()));
1344            }
1345            open.bytes_len += bytes.len();
1346            open.entries.extend_from_slice(chunk);
1347            for entry in chunk {
1348                inner.configuration_state = inner
1349                    .configuration_state
1350                    .validate_entry(entry)
1351                    .map_err(|err| Error::Decode(err.to_string()))?;
1352            }
1353            offset += chunk_len;
1354        }
1355    }
1356    if sync {
1357        inner
1358            .open_segment
1359            .as_ref()
1360            .expect("non-empty append has open segment")
1361            .file
1362            .sync_data()
1363            .map_err(|err| Error::Io(err.to_string()))?;
1364    }
1365    Ok(())
1366}
1367
1368fn open_segment_chunk_len(open: &OpenSegment, entries: &[LogEntry]) -> Result<usize> {
1369    let mut bytes_len = open.bytes_len;
1370    let mut entry_count = open.entries.len();
1371    let mut chunk_len = 0;
1372    for entry in entries {
1373        let frame_len = FRAME_MIN_LEN
1374            .checked_add(entry.payload.len())
1375            .ok_or_else(|| Error::Decode("qlog frame length overflow".into()))?;
1376        let next_bytes_len = bytes_len
1377            .checked_add(frame_len)
1378            .ok_or_else(|| Error::Decode("qlog segment length overflow".into()))?;
1379        let next_entry_count = entry_count
1380            .checked_add(1)
1381            .ok_or_else(|| Error::Decode("qlog segment entry count overflow".into()))?;
1382        let oversized_first_entry = open.entries.is_empty() && chunk_len == 0;
1383        if !oversized_first_entry
1384            && (next_bytes_len > OPEN_SEGMENT_MAX_BYTES
1385                || next_entry_count > OPEN_SEGMENT_MAX_ENTRIES)
1386        {
1387            break;
1388        }
1389        bytes_len = next_bytes_len;
1390        entry_count = next_entry_count;
1391        chunk_len += 1;
1392    }
1393    Ok(chunk_len)
1394}
1395
1396fn ensure_open_segment(inner: &mut FileLogStoreInner, first: &LogEntry) -> Result<()> {
1397    if inner
1398        .open_segment
1399        .as_ref()
1400        .is_some_and(|segment| segment.config_id == first.config_id)
1401    {
1402        return Ok(());
1403    }
1404    seal_open_segment(inner)?;
1405
1406    let final_path = inner.dir.join(open_segment_file_name(first.index));
1407    if final_path.exists() {
1408        return Err(Error::Io(format!(
1409            "open qlog segment already exists: {}",
1410            final_path.display()
1411        )));
1412    }
1413    let (temp_path, mut file) = create_unique_temp_file(&inner.dir, &final_path)?;
1414    file.write_all(&encode_header(first))
1415        .and_then(|_| file.sync_all())
1416        .map_err(|err| Error::Io(err.to_string()))?;
1417    fs::rename(&temp_path, &final_path).map_err(|err| Error::Io(err.to_string()))?;
1418    sync_directory(&inner.dir)?;
1419    let file = fs::OpenOptions::new()
1420        .append(true)
1421        .open(&final_path)
1422        .map_err(|err| Error::Io(err.to_string()))?;
1423    inner.open_segment = Some(OpenSegment {
1424        config_id: first.config_id,
1425        path: final_path,
1426        file,
1427        bytes_len: QLOG_HEADER_LEN,
1428        entries: Vec::new(),
1429    });
1430    Ok(())
1431}
1432
1433fn seal_open_segment(inner: &mut FileLogStoreInner) -> Result<()> {
1434    let Some(open) = &inner.open_segment else {
1435        return Ok(());
1436    };
1437    open.file
1438        .sync_data()
1439        .map_err(|err| Error::Io(err.to_string()))?;
1440    let entries = open.entries.clone();
1441    let open_path = open.path.clone();
1442
1443    if entries.is_empty() {
1444        fs::remove_file(&open_path).map_err(|err| Error::Io(err.to_string()))?;
1445        inner.open_segment = None;
1446        sync_directory(&inner.dir)?;
1447        return Ok(());
1448    }
1449
1450    let range = IndexRange::new(entries[0].index, entries.last().expect("non-empty").index)?;
1451    let final_path = inner.dir.join(segment_file_name(range));
1452    if final_path.exists() {
1453        let existing = fs::read(&final_path).map_err(|err| Error::Io(err.to_string()))?;
1454        if decode_segment_for_cluster(&existing, &inner.cluster_id)? != entries {
1455            return Err(Error::Decode(
1456                "open and closed qlog segments disagree".into(),
1457            ));
1458        }
1459    } else {
1460        publish_closed_segment(&inner.dir, &entries)?;
1461    }
1462    fs::remove_file(&open_path).map_err(|err| Error::Io(err.to_string()))?;
1463    inner.open_segment = None;
1464    inner.segments.push(ClosedSegment { entries });
1465    sync_directory(&inner.dir)
1466}
1467
1468fn compact_prefix_with_hook(
1469    inner: &mut FileLogStoreInner,
1470    anchor: &RecoveryAnchor,
1471    hook: &mut impl FnMut(CompactPhase) -> Result<()>,
1472) -> Result<()> {
1473    if !validate_compaction(inner, anchor)? {
1474        return Ok(());
1475    }
1476    let target = anchor.compacted().index();
1477
1478    let old_segments = inner
1479        .segments
1480        .iter()
1481        .take_while(|segment| segment.start() <= target)
1482        .collect::<Vec<_>>();
1483    let old_segment_names = old_segments
1484        .iter()
1485        .map(|segment| {
1486            segment_file_name(
1487                IndexRange::new(segment.start(), segment.end())
1488                    .expect("closed segment range is valid"),
1489            )
1490        })
1491        .collect::<Vec<_>>();
1492    let replacement_entries = old_segments
1493        .last()
1494        .filter(|segment| segment.end() > target)
1495        .map(|segment| {
1496            segment
1497                .entries
1498                .iter()
1499                .filter(|entry| entry.index > target)
1500                .cloned()
1501                .collect::<Vec<_>>()
1502        });
1503    let replacement = match replacement_entries {
1504        Some(entries) if !entries.is_empty() => {
1505            let first = entries.first().expect("replacement is non-empty");
1506            let last = entries.last().expect("replacement is non-empty");
1507            let final_name = segment_file_name(IndexRange::new(first.index, last.index)?);
1508            let final_path = inner.dir.join(&final_name);
1509            let (temp_path, mut file) = create_unique_temp_file(&inner.dir, &final_path)?;
1510            file.write_all(&encode_segment(&entries))
1511                .and_then(|_| file.sync_all())
1512                .map_err(|err| Error::Io(err.to_string()))?;
1513            drop(file);
1514            sync_directory(&inner.dir)?;
1515            hook(CompactPhase::ReplacementPrepared)?;
1516            Some(TruncateReplacement {
1517                temp_name: file_name(&temp_path)?,
1518                final_name,
1519            })
1520        }
1521        _ => None,
1522    };
1523    let intent = CompactIntent {
1524        previous_anchor: inner.anchor.clone(),
1525        anchor: anchor.clone(),
1526        old_segment_names,
1527        replacement,
1528    };
1529    publish_compact_intent(&inner.dir, &intent, hook)?;
1530    apply_compact_intent(&inner.dir, &intent, hook)
1531}
1532
1533fn validate_compaction(inner: &FileLogStoreInner, anchor: &RecoveryAnchor) -> Result<bool> {
1534    validate_anchor(anchor)?;
1535    validate_anchor_identity(Some(anchor), &inner.cluster_id, inner.epoch)?;
1536    let target = anchor.compacted().index();
1537    let tip = inner.last_index();
1538    if tip.is_none_or(|tip| target > tip) {
1539        return Err(Error::CompactionAboveTip { target, tip });
1540    }
1541    if configuration_state_at(inner, target)? != *anchor.configuration_state() {
1542        return Err(Error::CompactionConflict { index: target });
1543    }
1544
1545    if let Some(current) = &inner.anchor {
1546        let current_index = current.compacted().index();
1547        if target < current_index {
1548            return Err(Error::CompactionRegression {
1549                target,
1550                anchor: current_index,
1551            });
1552        }
1553        if target == current_index {
1554            return if current == anchor {
1555                Ok(false)
1556            } else {
1557                Err(Error::CompactionConflict { index: target })
1558            };
1559        }
1560        if anchor.recovery_generation() != current.recovery_generation() {
1561            return Err(Error::CompactionConflict { index: target });
1562        }
1563    }
1564
1565    let entry = inner
1566        .segments
1567        .iter()
1568        .flat_map(|segment| &segment.entries)
1569        .chain(
1570            inner
1571                .open_segment
1572                .iter()
1573                .flat_map(|segment| &segment.entries),
1574        )
1575        .find(|entry| entry.index == target)
1576        .ok_or(Error::CompactionAboveTip { target, tip })?;
1577    if entry.hash != anchor.compacted().hash() {
1578        return Err(Error::CompactionHashMismatch { index: target });
1579    }
1580    Ok(true)
1581}
1582
1583fn configuration_state_at(
1584    inner: &FileLogStoreInner,
1585    target: LogIndex,
1586) -> Result<ConfigurationState> {
1587    let (mut state, start) = match &inner.anchor {
1588        Some(anchor) => (
1589            anchor.configuration_state().clone(),
1590            anchor.compacted().index(),
1591        ),
1592        None => (inner.initial_configuration.clone(), 0),
1593    };
1594    if target == start {
1595        return Ok(state);
1596    }
1597    let mut found = false;
1598    for entry in inner
1599        .segments
1600        .iter()
1601        .flat_map(|segment| &segment.entries)
1602        .chain(
1603            inner
1604                .open_segment
1605                .iter()
1606                .flat_map(|segment| &segment.entries),
1607        )
1608    {
1609        if entry.index > target {
1610            break;
1611        }
1612        state = state
1613            .validate_entry(entry)
1614            .map_err(|err| Error::Decode(err.to_string()))?;
1615        found = entry.index == target;
1616    }
1617    if found {
1618        Ok(state)
1619    } else {
1620        Err(Error::CompactionAboveTip {
1621            target,
1622            tip: inner.last_index(),
1623        })
1624    }
1625}
1626
1627fn read_anchor(dir: &Path) -> Result<Option<RecoveryAnchor>> {
1628    let path = dir.join(ANCHOR_FILE_NAME);
1629    if !path.exists() {
1630        return Ok(None);
1631    }
1632    let bytes = fs::read(path).map_err(|err| Error::Io(err.to_string()))?;
1633    decode_anchor(&bytes).map(Some)
1634}
1635
1636fn validate_anchor_identity(
1637    anchor: Option<&RecoveryAnchor>,
1638    cluster_id: &str,
1639    epoch: u64,
1640) -> Result<()> {
1641    if let Some(anchor) = anchor {
1642        validate_anchor(anchor)?;
1643        if anchor.cluster_id() != cluster_id || anchor.epoch() != epoch {
1644            return Err(Error::Decode(
1645                "recovery anchor does not match configured cluster/epoch".into(),
1646            ));
1647        }
1648    }
1649    Ok(())
1650}
1651
1652fn validate_anchor(anchor: &RecoveryAnchor) -> Result<()> {
1653    if !matches!(
1654        anchor.format_version(),
1655        RECOVERY_ANCHOR_V1_FORMAT_VERSION | RECOVERY_ANCHOR_FORMAT_VERSION
1656    ) {
1657        return Err(Error::Decode("unsupported recovery anchor version".into()));
1658    }
1659    if anchor.cluster_id().is_empty()
1660        || anchor.recovery_generation() == 0
1661        || anchor.compacted().index() == 0
1662        || anchor.snapshot().snapshot_id().is_empty()
1663        || anchor.snapshot().size_bytes() == 0
1664    {
1665        return Err(Error::Decode("invalid recovery anchor identity".into()));
1666    }
1667    if anchor.configuration_state().config_id() != anchor.config_id()
1668        || anchor
1669            .configuration_state()
1670            .stop()
1671            .is_some_and(|stop| stop != anchor.compacted())
1672    {
1673        return Err(Error::Decode(
1674            "invalid recovery anchor configuration state".into(),
1675        ));
1676    }
1677    Ok(())
1678}
1679
1680fn encode_anchor(anchor: &RecoveryAnchor) -> Result<Vec<u8>> {
1681    validate_anchor(anchor)?;
1682    let mut out = Vec::new();
1683    out.extend_from_slice(&ANCHOR_MAGIC);
1684    put_u16(
1685        &mut out,
1686        if anchor.format_version() == RECOVERY_ANCHOR_V1_FORMAT_VERSION {
1687            ANCHOR_V1_VERSION
1688        } else {
1689            ANCHOR_VERSION
1690        },
1691    );
1692    put_u16(&mut out, 0);
1693    put_u64(&mut out, anchor.epoch());
1694    put_u64(&mut out, anchor.config_id());
1695    put_u64(&mut out, anchor.recovery_generation());
1696    put_u64(&mut out, anchor.compacted().index());
1697    out.extend_from_slice(anchor.compacted().hash().as_bytes());
1698    out.extend_from_slice(anchor.snapshot().digest().as_bytes());
1699    put_u64(&mut out, anchor.snapshot().size_bytes());
1700    match anchor.executor_fingerprint() {
1701        Some(fingerprint) => {
1702            out.push(1);
1703            out.extend_from_slice(fingerprint.as_bytes());
1704        }
1705        None => {
1706            out.push(0);
1707            out.extend_from_slice(LogHash::ZERO.as_bytes());
1708        }
1709    }
1710    if anchor.format_version() == RECOVERY_ANCHOR_FORMAT_VERSION {
1711        encode_configuration_state(&mut out, anchor.configuration_state())?;
1712    }
1713    put_string(&mut out, anchor.cluster_id(), "anchor cluster_id")?;
1714    put_string(
1715        &mut out,
1716        anchor.snapshot().snapshot_id(),
1717        "anchor snapshot_id",
1718    )?;
1719    let crc = crc32c(&out);
1720    put_u32(&mut out, crc);
1721    Ok(out)
1722}
1723
1724fn decode_anchor(bytes: &[u8]) -> Result<RecoveryAnchor> {
1725    if bytes.len() < 120 || bytes.get(..4) != Some(ANCHOR_MAGIC.as_slice()) {
1726        return Err(Error::Decode("invalid recovery anchor magic".into()));
1727    }
1728    let crc_offset = bytes.len() - 4;
1729    if crc32c(&bytes[..crc_offset]) != read_u32(bytes, crc_offset)? {
1730        return Err(Error::Decode("recovery anchor crc mismatch".into()));
1731    }
1732    let version = read_u16(bytes, 4)?;
1733    if !matches!(
1734        version,
1735        ANCHOR_V1_VERSION | ANCHOR_V2_VERSION | ANCHOR_V3_VERSION | ANCHOR_VERSION
1736    ) || read_u16(bytes, 6)? != 0
1737    {
1738        return Err(Error::Decode("unsupported recovery anchor version".into()));
1739    }
1740    let executor_fingerprint = if matches!(version, ANCHOR_V3_VERSION | ANCHOR_VERSION) {
1741        let present = *bytes
1742            .get(112)
1743            .ok_or_else(|| Error::Decode("truncated executor fingerprint flag".into()))?;
1744        let fingerprint = read_hash(bytes, 113)?;
1745        match present {
1746            0 if fingerprint == LogHash::ZERO => None,
1747            1 => Some(fingerprint),
1748            _ => {
1749                return Err(Error::Decode(
1750                    "invalid executor fingerprint encoding".into(),
1751                ))
1752            }
1753        }
1754    } else {
1755        None
1756    };
1757    let mut cursor = if matches!(version, ANCHOR_V3_VERSION | ANCHOR_VERSION) {
1758        145
1759    } else {
1760        112
1761    };
1762    let config_id = read_u64(bytes, 16)?;
1763    let configuration_state = if version != ANCHOR_V1_VERSION {
1764        decode_configuration_state(bytes, &mut cursor, crc_offset, config_id, version)?
1765    } else {
1766        ConfigurationState::active(config_id, LogHash::ZERO)
1767    };
1768    let cluster_id = read_string(bytes, &mut cursor, crc_offset, "anchor cluster_id")?;
1769    let snapshot_id = read_string(bytes, &mut cursor, crc_offset, "anchor snapshot_id")?;
1770    if cursor != crc_offset {
1771        return Err(Error::Decode("trailing recovery anchor bytes".into()));
1772    }
1773    let compacted = LogAnchor::new(read_u64(bytes, 32)?, read_hash(bytes, 40)?);
1774    let mut snapshot =
1775        SnapshotIdentity::new(snapshot_id, read_hash(bytes, 72)?, read_u64(bytes, 104)?);
1776    if let Some(fingerprint) = executor_fingerprint {
1777        snapshot = snapshot.with_executor_fingerprint(fingerprint);
1778    }
1779    let anchor = if version == ANCHOR_V1_VERSION {
1780        RecoveryAnchor::from_v1(
1781            cluster_id,
1782            read_u64(bytes, 8)?,
1783            config_id,
1784            read_u64(bytes, 24)?,
1785            compacted,
1786            snapshot,
1787        )
1788    } else {
1789        RecoveryAnchor::new_with_configuration(
1790            cluster_id,
1791            read_u64(bytes, 8)?,
1792            configuration_state,
1793            read_u64(bytes, 24)?,
1794            compacted,
1795            snapshot,
1796        )
1797    };
1798    validate_anchor(&anchor)?;
1799    Ok(anchor)
1800}
1801
1802fn encode_configuration_state(out: &mut Vec<u8>, state: &ConfigurationState) -> Result<()> {
1803    match state {
1804        ConfigurationState::Active { digest, .. } => {
1805            out.push(1);
1806            out.extend_from_slice(digest.as_bytes());
1807        }
1808        ConfigurationState::Stopped {
1809            digest,
1810            stop,
1811            binding,
1812            ..
1813        } => {
1814            out.push(2);
1815            out.extend_from_slice(digest.as_bytes());
1816            put_u64(out, stop.index());
1817            out.extend_from_slice(stop.hash().as_bytes());
1818            match binding {
1819                StopBinding::Unknown => out.push(0),
1820                StopBinding::Unbound => out.push(1),
1821                StopBinding::Bound {
1822                    successor,
1823                    stop_command_hash,
1824                } => {
1825                    out.push(2);
1826                    encode_successor_descriptor(out, successor)?;
1827                    out.extend_from_slice(stop_command_hash.as_bytes());
1828                }
1829            }
1830        }
1831    }
1832    Ok(())
1833}
1834
1835fn decode_configuration_state(
1836    bytes: &[u8],
1837    cursor: &mut usize,
1838    end: usize,
1839    config_id: u64,
1840    version: u16,
1841) -> Result<ConfigurationState> {
1842    let kind = *bytes
1843        .get(*cursor)
1844        .filter(|_| *cursor < end)
1845        .ok_or_else(|| Error::Decode("short recovery anchor configuration state".into()))?;
1846    *cursor += 1;
1847    let digest = read_state_hash(bytes, cursor, end)?;
1848    match kind {
1849        1 => Ok(ConfigurationState::active(config_id, digest)),
1850        2 => {
1851            let stop_index = read_state_u64(bytes, cursor, end)?;
1852            let stop_hash = read_state_hash(bytes, cursor, end)?;
1853            let binding = if version == ANCHOR_VERSION {
1854                decode_stop_binding(bytes, cursor, end)?
1855            } else {
1856                StopBinding::Unknown
1857            };
1858            Ok(ConfigurationState::Stopped {
1859                config_id,
1860                digest,
1861                stop: LogAnchor::new(stop_index, stop_hash),
1862                binding,
1863            })
1864        }
1865        _ => Err(Error::Decode(
1866            "invalid recovery anchor configuration state".into(),
1867        )),
1868    }
1869}
1870
1871fn encode_successor_descriptor(out: &mut Vec<u8>, successor: &SuccessorDescriptor) -> Result<()> {
1872    put_string(out, successor.cluster_id(), "successor cluster_id")?;
1873    put_u64(out, successor.predecessor_config_id());
1874    out.extend_from_slice(successor.predecessor_config_digest().as_bytes());
1875    put_u64(out, successor.config_id());
1876    out.extend_from_slice(successor.digest().as_bytes());
1877    put_u16(out, successor.members().len() as u16);
1878    for member in successor.members() {
1879        put_string(out, member, "successor member")?;
1880    }
1881    Ok(())
1882}
1883
1884fn decode_stop_binding(bytes: &[u8], cursor: &mut usize, end: usize) -> Result<StopBinding> {
1885    let kind = read_state_u8(bytes, cursor, end)?;
1886    match kind {
1887        0 => Ok(StopBinding::Unknown),
1888        1 => Ok(StopBinding::Unbound),
1889        2 => {
1890            let cluster_id = read_string(bytes, cursor, end, "successor cluster_id")?;
1891            let predecessor_config_id = read_state_u64(bytes, cursor, end)?;
1892            let predecessor_config_digest = read_state_hash(bytes, cursor, end)?;
1893            let successor_config_id = read_state_u64(bytes, cursor, end)?;
1894            let encoded_digest = read_state_hash(bytes, cursor, end)?;
1895            let member_count = usize::from(read_state_u16(bytes, cursor, end)?);
1896            let mut members = Vec::with_capacity(member_count);
1897            for _ in 0..member_count {
1898                members.push(read_string(bytes, cursor, end, "successor member")?);
1899            }
1900            let successor = SuccessorDescriptor::new(
1901                cluster_id,
1902                predecessor_config_id,
1903                predecessor_config_digest,
1904                successor_config_id,
1905                members,
1906            )
1907            .map_err(|_| Error::Decode("invalid recovery anchor successor descriptor".into()))?;
1908            if successor.digest() != encoded_digest {
1909                return Err(Error::Decode(
1910                    "recovery anchor successor digest mismatch".into(),
1911                ));
1912            }
1913            let stop_command_hash = read_state_hash(bytes, cursor, end)?;
1914            Ok(StopBinding::Bound {
1915                successor,
1916                stop_command_hash,
1917            })
1918        }
1919        _ => Err(Error::Decode("invalid recovery anchor stop binding".into())),
1920    }
1921}
1922
1923fn read_state_u8(bytes: &[u8], cursor: &mut usize, end: usize) -> Result<u8> {
1924    let value = *bytes
1925        .get(*cursor)
1926        .filter(|_| *cursor < end)
1927        .ok_or_else(|| Error::Decode("short recovery anchor configuration state".into()))?;
1928    *cursor += 1;
1929    Ok(value)
1930}
1931
1932fn read_state_u16(bytes: &[u8], cursor: &mut usize, end: usize) -> Result<u16> {
1933    let next = cursor
1934        .checked_add(2)
1935        .filter(|next| *next <= end)
1936        .ok_or_else(|| Error::Decode("short recovery anchor configuration state".into()))?;
1937    let value = read_u16(bytes, *cursor)?;
1938    *cursor = next;
1939    Ok(value)
1940}
1941
1942fn read_state_u64(bytes: &[u8], cursor: &mut usize, end: usize) -> Result<u64> {
1943    let next = cursor
1944        .checked_add(8)
1945        .filter(|next| *next <= end)
1946        .ok_or_else(|| Error::Decode("short recovery anchor configuration state".into()))?;
1947    let value = read_u64(bytes, *cursor)?;
1948    *cursor = next;
1949    Ok(value)
1950}
1951
1952fn read_state_hash(bytes: &[u8], cursor: &mut usize, end: usize) -> Result<LogHash> {
1953    let next = cursor
1954        .checked_add(32)
1955        .filter(|next| *next <= end)
1956        .ok_or_else(|| Error::Decode("short recovery anchor configuration state".into()))?;
1957    let value = read_hash(bytes, *cursor)?;
1958    *cursor = next;
1959    Ok(value)
1960}
1961
1962fn recover_compact_intent(dir: &Path) -> Result<()> {
1963    let path = dir.join(COMPACT_INTENT_FILE_NAME);
1964    if !path.exists() {
1965        return Ok(());
1966    }
1967    let bytes = fs::read(path).map_err(|err| Error::Io(err.to_string()))?;
1968    let intent = decode_compact_intent(&bytes)?;
1969    apply_compact_intent(dir, &intent, &mut |_| Ok(()))
1970}
1971
1972fn publish_compact_intent(
1973    dir: &Path,
1974    intent: &CompactIntent,
1975    hook: &mut impl FnMut(CompactPhase) -> Result<()>,
1976) -> Result<()> {
1977    let final_path = dir.join(COMPACT_INTENT_FILE_NAME);
1978    if final_path.exists() {
1979        return Err(Error::Io("compact intent already exists".into()));
1980    }
1981    let (temp_path, mut file) = create_unique_temp_file(dir, &final_path)?;
1982    let bytes = encode_compact_intent(intent)?;
1983    if let Err(err) = file
1984        .write_all(&bytes)
1985        .and_then(|_| file.sync_all())
1986        .map_err(|err| Error::Io(err.to_string()))
1987    {
1988        drop(file);
1989        let _ = fs::remove_file(&temp_path);
1990        return Err(err);
1991    }
1992    drop(file);
1993    fs::rename(&temp_path, &final_path).map_err(|err| Error::Io(err.to_string()))?;
1994    hook(CompactPhase::IntentRenamed)?;
1995    sync_directory(dir)?;
1996    hook(CompactPhase::IntentDurable)
1997}
1998
1999fn apply_compact_intent(
2000    dir: &Path,
2001    intent: &CompactIntent,
2002    hook: &mut impl FnMut(CompactPhase) -> Result<()>,
2003) -> Result<()> {
2004    validate_compact_intent(intent)?;
2005    install_anchor(dir, intent)?;
2006    hook(CompactPhase::AnchorInstalled)?;
2007    sync_directory(dir)?;
2008    hook(CompactPhase::AnchorDurable)?;
2009
2010    for (position, name) in intent.old_segment_names.iter().enumerate() {
2011        let path = dir.join(name);
2012        if path.exists() {
2013            fs::remove_file(path).map_err(|err| Error::Io(err.to_string()))?;
2014        }
2015        hook(CompactPhase::OldSegmentRemoved(position))?;
2016    }
2017    install_replacement(dir, intent.replacement.as_ref(), "compact")?;
2018    hook(CompactPhase::ReplacementInstalled)?;
2019    sync_directory(dir)?;
2020    hook(CompactPhase::AppliedDirectorySynced)?;
2021
2022    let path = dir.join(COMPACT_INTENT_FILE_NAME);
2023    if path.exists() {
2024        fs::remove_file(path).map_err(|err| Error::Io(err.to_string()))?;
2025    }
2026    hook(CompactPhase::IntentRemoved)?;
2027    sync_directory(dir)?;
2028    hook(CompactPhase::CompleteDirectorySynced)
2029}
2030
2031fn install_anchor(dir: &Path, intent: &CompactIntent) -> Result<()> {
2032    let current = read_anchor(dir)?;
2033    if current.as_ref() == Some(&intent.anchor) {
2034        return Ok(());
2035    }
2036    if current != intent.previous_anchor {
2037        return Err(Error::CompactionConflict {
2038            index: intent.anchor.compacted().index(),
2039        });
2040    }
2041    let final_path = dir.join(ANCHOR_FILE_NAME);
2042    let (temp_path, mut file) = create_unique_temp_file(dir, &final_path)?;
2043    file.write_all(&encode_anchor(&intent.anchor)?)
2044        .and_then(|_| file.sync_all())
2045        .map_err(|err| Error::Io(err.to_string()))?;
2046    drop(file);
2047    fs::rename(temp_path, final_path).map_err(|err| Error::Io(err.to_string()))
2048}
2049
2050fn install_replacement(
2051    dir: &Path,
2052    replacement: Option<&TruncateReplacement>,
2053    operation: &str,
2054) -> Result<()> {
2055    let Some(replacement) = replacement else {
2056        return Ok(());
2057    };
2058    let temp_path = dir.join(&replacement.temp_name);
2059    let final_path = dir.join(&replacement.final_name);
2060    match (temp_path.exists(), final_path.exists()) {
2061        (true, false) => {
2062            fs::rename(temp_path, final_path).map_err(|err| Error::Io(err.to_string()))?;
2063        }
2064        (true, true) => {
2065            let temp = fs::read(&temp_path).map_err(|err| Error::Io(err.to_string()))?;
2066            let final_bytes = fs::read(&final_path).map_err(|err| Error::Io(err.to_string()))?;
2067            if temp != final_bytes {
2068                return Err(Error::Decode(format!(
2069                    "{operation} replacement files disagree"
2070                )));
2071            }
2072            fs::remove_file(temp_path).map_err(|err| Error::Io(err.to_string()))?;
2073        }
2074        (false, true) => {}
2075        (false, false) => {
2076            return Err(Error::Decode(format!(
2077                "{operation} replacement file is missing"
2078            )));
2079        }
2080    }
2081    Ok(())
2082}
2083
2084fn truncate_suffix_with_hook(
2085    inner: &mut FileLogStoreInner,
2086    from: LogIndex,
2087    hook: &mut impl FnMut(TruncatePhase) -> Result<()>,
2088) -> Result<()> {
2089    let Some(position) = inner
2090        .segments
2091        .iter()
2092        .position(|segment| segment.end() >= from)
2093    else {
2094        return Ok(());
2095    };
2096
2097    let old_segment_names = inner.segments[position..]
2098        .iter()
2099        .map(|segment| {
2100            segment_file_name(
2101                IndexRange::new(segment.start(), segment.end())
2102                    .expect("closed segment range is valid"),
2103            )
2104        })
2105        .collect::<Vec<_>>();
2106
2107    let replacement_entries = (inner.segments[position].start() < from).then(|| {
2108        inner.segments[position]
2109            .entries
2110            .iter()
2111            .take_while(|entry| entry.index < from)
2112            .cloned()
2113            .collect::<Vec<_>>()
2114    });
2115    let replacement = match replacement_entries {
2116        Some(entries) if !entries.is_empty() => {
2117            let first = entries.first().expect("replacement is non-empty");
2118            let last = entries.last().expect("replacement is non-empty");
2119            let final_name = segment_file_name(IndexRange::new(first.index, last.index)?);
2120            let final_path = inner.dir.join(&final_name);
2121            let (temp_path, mut file) = create_unique_temp_file(&inner.dir, &final_path)?;
2122            file.write_all(&encode_segment(&entries))
2123                .and_then(|_| file.sync_all())
2124                .map_err(|err| Error::Io(err.to_string()))?;
2125            drop(file);
2126            sync_directory(&inner.dir)?;
2127            hook(TruncatePhase::ReplacementPrepared)?;
2128            Some(TruncateReplacement {
2129                temp_name: file_name(&temp_path)?,
2130                final_name,
2131            })
2132        }
2133        _ => None,
2134    };
2135
2136    let intent = TruncateIntent {
2137        old_segment_names,
2138        replacement,
2139    };
2140    publish_truncate_intent(&inner.dir, &intent, hook)?;
2141    apply_truncate_intent(&inner.dir, &intent, hook)
2142}
2143
2144fn recover_truncate_intent(dir: &Path) -> Result<()> {
2145    let path = dir.join(TRUNCATE_INTENT_FILE_NAME);
2146    if !path.exists() {
2147        return Ok(());
2148    }
2149    let bytes = fs::read(&path).map_err(|err| Error::Io(err.to_string()))?;
2150    let intent = decode_truncate_intent(&bytes)?;
2151    apply_truncate_intent(dir, &intent, &mut |_| Ok(()))
2152}
2153
2154fn publish_truncate_intent(
2155    dir: &Path,
2156    intent: &TruncateIntent,
2157    hook: &mut impl FnMut(TruncatePhase) -> Result<()>,
2158) -> Result<()> {
2159    let final_path = dir.join(TRUNCATE_INTENT_FILE_NAME);
2160    if final_path.exists() {
2161        return Err(Error::Io("truncate intent already exists".into()));
2162    }
2163    let (temp_path, mut file) = create_unique_temp_file(dir, &final_path)?;
2164    let bytes = encode_truncate_intent(intent)?;
2165    if let Err(err) = file
2166        .write_all(&bytes)
2167        .and_then(|_| file.sync_all())
2168        .map_err(|err| Error::Io(err.to_string()))
2169    {
2170        drop(file);
2171        let _ = fs::remove_file(&temp_path);
2172        return Err(err);
2173    }
2174    drop(file);
2175    fs::rename(&temp_path, &final_path).map_err(|err| Error::Io(err.to_string()))?;
2176    hook(TruncatePhase::IntentRenamed)?;
2177    sync_directory(dir)?;
2178    hook(TruncatePhase::IntentDurable)
2179}
2180
2181fn apply_truncate_intent(
2182    dir: &Path,
2183    intent: &TruncateIntent,
2184    hook: &mut impl FnMut(TruncatePhase) -> Result<()>,
2185) -> Result<()> {
2186    validate_truncate_intent(intent)?;
2187    for (position, name) in intent.old_segment_names.iter().enumerate() {
2188        let path = dir.join(name);
2189        if path.exists() {
2190            fs::remove_file(&path).map_err(|err| Error::Io(err.to_string()))?;
2191        }
2192        hook(TruncatePhase::OldSegmentRemoved(position))?;
2193    }
2194
2195    if let Some(replacement) = &intent.replacement {
2196        let temp_path = dir.join(&replacement.temp_name);
2197        let final_path = dir.join(&replacement.final_name);
2198        match (temp_path.exists(), final_path.exists()) {
2199            (true, false) => {
2200                fs::rename(&temp_path, &final_path).map_err(|err| Error::Io(err.to_string()))?;
2201            }
2202            (true, true) => {
2203                let temp = fs::read(&temp_path).map_err(|err| Error::Io(err.to_string()))?;
2204                let final_bytes =
2205                    fs::read(&final_path).map_err(|err| Error::Io(err.to_string()))?;
2206                if temp != final_bytes {
2207                    return Err(Error::Decode("truncate replacement files disagree".into()));
2208                }
2209                fs::remove_file(&temp_path).map_err(|err| Error::Io(err.to_string()))?;
2210            }
2211            (false, true) => {}
2212            (false, false) => {
2213                return Err(Error::Decode("truncate replacement file is missing".into()));
2214            }
2215        }
2216    }
2217    hook(TruncatePhase::ReplacementInstalled)?;
2218    sync_directory(dir)?;
2219    hook(TruncatePhase::AppliedDirectorySynced)?;
2220
2221    let intent_path = dir.join(TRUNCATE_INTENT_FILE_NAME);
2222    if intent_path.exists() {
2223        fs::remove_file(&intent_path).map_err(|err| Error::Io(err.to_string()))?;
2224    }
2225    hook(TruncatePhase::IntentRemoved)?;
2226    sync_directory(dir)?;
2227    hook(TruncatePhase::CompleteDirectorySynced)
2228}
2229
2230fn encode_truncate_intent(intent: &TruncateIntent) -> Result<Vec<u8>> {
2231    validate_truncate_intent(intent)?;
2232    let mut out = Vec::new();
2233    out.extend_from_slice(&TRUNCATE_INTENT_MAGIC);
2234    put_u16(&mut out, TRUNCATE_INTENT_VERSION);
2235    put_u16(
2236        &mut out,
2237        if intent.replacement.is_some() {
2238            TRUNCATE_INTENT_REPLACEMENT
2239        } else {
2240            0
2241        },
2242    );
2243    let count = u32::try_from(intent.old_segment_names.len())
2244        .map_err(|_| Error::Decode("too many truncate segments".into()))?;
2245    put_u32(&mut out, count);
2246    for name in &intent.old_segment_names {
2247        put_intent_name(&mut out, name)?;
2248    }
2249    if let Some(replacement) = &intent.replacement {
2250        put_intent_name(&mut out, &replacement.temp_name)?;
2251        put_intent_name(&mut out, &replacement.final_name)?;
2252    }
2253    let crc = crc32c(&out);
2254    put_u32(&mut out, crc);
2255    Ok(out)
2256}
2257
2258fn decode_truncate_intent(bytes: &[u8]) -> Result<TruncateIntent> {
2259    if bytes.len() < 16 || bytes.get(..4) != Some(TRUNCATE_INTENT_MAGIC.as_slice()) {
2260        return Err(Error::Decode("invalid truncate intent magic".into()));
2261    }
2262    let crc_offset = bytes.len() - 4;
2263    if crc32c(&bytes[..crc_offset]) != read_u32(bytes, crc_offset)? {
2264        return Err(Error::Decode("truncate intent crc mismatch".into()));
2265    }
2266    if read_u16(bytes, 4)? != TRUNCATE_INTENT_VERSION {
2267        return Err(Error::Decode("unsupported truncate intent version".into()));
2268    }
2269    let flags = read_u16(bytes, 6)?;
2270    if flags & !TRUNCATE_INTENT_REPLACEMENT != 0 {
2271        return Err(Error::Decode("invalid truncate intent flags".into()));
2272    }
2273    let count = read_u32(bytes, 8)? as usize;
2274    let mut cursor = 12;
2275    let mut old_segment_names = Vec::with_capacity(count);
2276    for _ in 0..count {
2277        old_segment_names.push(read_intent_name(bytes, &mut cursor, crc_offset)?);
2278    }
2279    let replacement = if flags & TRUNCATE_INTENT_REPLACEMENT != 0 {
2280        Some(TruncateReplacement {
2281            temp_name: read_intent_name(bytes, &mut cursor, crc_offset)?,
2282            final_name: read_intent_name(bytes, &mut cursor, crc_offset)?,
2283        })
2284    } else {
2285        None
2286    };
2287    if cursor != crc_offset {
2288        return Err(Error::Decode("trailing truncate intent bytes".into()));
2289    }
2290    let intent = TruncateIntent {
2291        old_segment_names,
2292        replacement,
2293    };
2294    validate_truncate_intent(&intent)?;
2295    Ok(intent)
2296}
2297
2298fn encode_compact_intent(intent: &CompactIntent) -> Result<Vec<u8>> {
2299    validate_compact_intent(intent)?;
2300    let mut flags = 0;
2301    if intent.previous_anchor.is_some() {
2302        flags |= COMPACT_INTENT_PREVIOUS_ANCHOR;
2303    }
2304    if intent.replacement.is_some() {
2305        flags |= COMPACT_INTENT_REPLACEMENT;
2306    }
2307    let mut out = Vec::new();
2308    out.extend_from_slice(&COMPACT_INTENT_MAGIC);
2309    put_u16(&mut out, COMPACT_INTENT_VERSION);
2310    put_u16(&mut out, flags);
2311    put_u32(
2312        &mut out,
2313        u32::try_from(intent.old_segment_names.len())
2314            .map_err(|_| Error::Decode("too many compact segments".into()))?,
2315    );
2316    put_blob(&mut out, &encode_anchor(&intent.anchor)?, "compact anchor")?;
2317    if let Some(previous) = &intent.previous_anchor {
2318        put_blob(
2319            &mut out,
2320            &encode_anchor(previous)?,
2321            "compact previous anchor",
2322        )?;
2323    }
2324    for name in &intent.old_segment_names {
2325        put_intent_name(&mut out, name)?;
2326    }
2327    if let Some(replacement) = &intent.replacement {
2328        put_intent_name(&mut out, &replacement.temp_name)?;
2329        put_intent_name(&mut out, &replacement.final_name)?;
2330    }
2331    let crc = crc32c(&out);
2332    put_u32(&mut out, crc);
2333    Ok(out)
2334}
2335
2336fn decode_compact_intent(bytes: &[u8]) -> Result<CompactIntent> {
2337    if bytes.len() < 20 || bytes.get(..4) != Some(COMPACT_INTENT_MAGIC.as_slice()) {
2338        return Err(Error::Decode("invalid compact intent magic".into()));
2339    }
2340    let crc_offset = bytes.len() - 4;
2341    if crc32c(&bytes[..crc_offset]) != read_u32(bytes, crc_offset)? {
2342        return Err(Error::Decode("compact intent crc mismatch".into()));
2343    }
2344    if read_u16(bytes, 4)? != COMPACT_INTENT_VERSION {
2345        return Err(Error::Decode("unsupported compact intent version".into()));
2346    }
2347    let flags = read_u16(bytes, 6)?;
2348    if flags & !(COMPACT_INTENT_PREVIOUS_ANCHOR | COMPACT_INTENT_REPLACEMENT) != 0 {
2349        return Err(Error::Decode("invalid compact intent flags".into()));
2350    }
2351    let count = read_u32(bytes, 8)? as usize;
2352    let mut cursor = 12;
2353    let anchor = decode_anchor(read_blob(bytes, &mut cursor, crc_offset, "compact anchor")?)?;
2354    let previous_anchor = if flags & COMPACT_INTENT_PREVIOUS_ANCHOR != 0 {
2355        Some(decode_anchor(read_blob(
2356            bytes,
2357            &mut cursor,
2358            crc_offset,
2359            "compact previous anchor",
2360        )?)?)
2361    } else {
2362        None
2363    };
2364    let mut old_segment_names = Vec::with_capacity(count);
2365    for _ in 0..count {
2366        old_segment_names.push(read_intent_name(bytes, &mut cursor, crc_offset)?);
2367    }
2368    let replacement = if flags & COMPACT_INTENT_REPLACEMENT != 0 {
2369        Some(TruncateReplacement {
2370            temp_name: read_intent_name(bytes, &mut cursor, crc_offset)?,
2371            final_name: read_intent_name(bytes, &mut cursor, crc_offset)?,
2372        })
2373    } else {
2374        None
2375    };
2376    if cursor != crc_offset {
2377        return Err(Error::Decode("trailing compact intent bytes".into()));
2378    }
2379    let intent = CompactIntent {
2380        previous_anchor,
2381        anchor,
2382        old_segment_names,
2383        replacement,
2384    };
2385    validate_compact_intent(&intent)?;
2386    Ok(intent)
2387}
2388
2389fn validate_compact_intent(intent: &CompactIntent) -> Result<()> {
2390    validate_anchor(&intent.anchor)?;
2391    if intent.old_segment_names.is_empty() {
2392        return Err(Error::Decode("compact intent has no old segments".into()));
2393    }
2394    if let Some(previous) = &intent.previous_anchor {
2395        validate_anchor(previous)?;
2396        if previous.cluster_id() != intent.anchor.cluster_id()
2397            || previous.epoch() != intent.anchor.epoch()
2398            || previous.recovery_generation() != intent.anchor.recovery_generation()
2399            || previous.compacted().index() >= intent.anchor.compacted().index()
2400        {
2401            return Err(Error::Decode(
2402                "compact intent anchor regression or conflict".into(),
2403            ));
2404        }
2405    }
2406    for (position, name) in intent.old_segment_names.iter().enumerate() {
2407        validate_closed_segment_name(name)?;
2408        if intent.old_segment_names[..position].contains(name) {
2409            return Err(Error::Decode("duplicate compact segment name".into()));
2410        }
2411    }
2412    if let Some(replacement) = &intent.replacement {
2413        validate_temp_name(&replacement.temp_name)?;
2414        validate_closed_segment_name(&replacement.final_name)?;
2415        if intent.old_segment_names.contains(&replacement.final_name) {
2416            return Err(Error::Decode(
2417                "compact replacement overlaps old segment name".into(),
2418            ));
2419        }
2420        let replacement_range = parse_closed_segment_name(&replacement.final_name)?;
2421        if replacement_range.start()
2422            != intent
2423                .anchor
2424                .compacted()
2425                .index()
2426                .checked_add(1)
2427                .ok_or_else(|| Error::Decode("compact anchor index overflow".into()))?
2428        {
2429            return Err(Error::Decode(
2430                "compact replacement does not start after anchor".into(),
2431            ));
2432        }
2433    }
2434    Ok(())
2435}
2436
2437fn validate_truncate_intent(intent: &TruncateIntent) -> Result<()> {
2438    if intent.old_segment_names.is_empty() {
2439        return Err(Error::Decode("truncate intent has no old segments".into()));
2440    }
2441    for (position, name) in intent.old_segment_names.iter().enumerate() {
2442        validate_closed_segment_name(name)?;
2443        if intent.old_segment_names[..position].contains(name) {
2444            return Err(Error::Decode("duplicate truncate segment name".into()));
2445        }
2446    }
2447    if let Some(replacement) = &intent.replacement {
2448        validate_temp_name(&replacement.temp_name)?;
2449        validate_closed_segment_name(&replacement.final_name)?;
2450        if intent.old_segment_names.contains(&replacement.final_name) {
2451            return Err(Error::Decode(
2452                "truncate replacement overlaps old segment name".into(),
2453            ));
2454        }
2455    }
2456    Ok(())
2457}
2458
2459fn validate_closed_segment_name(name: &str) -> Result<()> {
2460    validate_relative_name(name)?;
2461    let range = parse_closed_segment_name(name)?;
2462    if segment_file_name(range) != name {
2463        return Err(Error::Decode("non-canonical truncate segment name".into()));
2464    }
2465    Ok(())
2466}
2467
2468fn validate_temp_name(name: &str) -> Result<()> {
2469    validate_relative_name(name)?;
2470    if !name.starts_with('.') || !name.ends_with(".tmp") {
2471        return Err(Error::Decode("invalid truncate temp name".into()));
2472    }
2473    Ok(())
2474}
2475
2476fn validate_relative_name(name: &str) -> Result<()> {
2477    let mut components = Path::new(name).components();
2478    if name.is_empty()
2479        || !matches!(components.next(), Some(std::path::Component::Normal(_)))
2480        || components.next().is_some()
2481    {
2482        return Err(Error::Decode("unsafe truncate intent path".into()));
2483    }
2484    Ok(())
2485}
2486
2487fn put_intent_name(out: &mut Vec<u8>, name: &str) -> Result<()> {
2488    let len = u16::try_from(name.len())
2489        .map_err(|_| Error::Decode("truncate intent name is too long".into()))?;
2490    put_u16(out, len);
2491    out.extend_from_slice(name.as_bytes());
2492    Ok(())
2493}
2494
2495fn put_string(out: &mut Vec<u8>, value: &str, field: &str) -> Result<()> {
2496    let len =
2497        u16::try_from(value.len()).map_err(|_| Error::Decode(format!("{field} is too long")))?;
2498    put_u16(out, len);
2499    out.extend_from_slice(value.as_bytes());
2500    Ok(())
2501}
2502
2503fn read_string(bytes: &[u8], cursor: &mut usize, end: usize, field: &str) -> Result<String> {
2504    let value = read_intent_name(bytes, cursor, end)?;
2505    if value.is_empty() {
2506        return Err(Error::Decode(format!("{field} is empty")));
2507    }
2508    Ok(value)
2509}
2510
2511fn put_blob(out: &mut Vec<u8>, bytes: &[u8], field: &str) -> Result<()> {
2512    let len =
2513        u32::try_from(bytes.len()).map_err(|_| Error::Decode(format!("{field} is too large")))?;
2514    put_u32(out, len);
2515    out.extend_from_slice(bytes);
2516    Ok(())
2517}
2518
2519fn read_blob<'a>(bytes: &'a [u8], cursor: &mut usize, end: usize, field: &str) -> Result<&'a [u8]> {
2520    let len = read_u32(bytes, *cursor)? as usize;
2521    *cursor = cursor
2522        .checked_add(4)
2523        .ok_or_else(|| Error::Decode(format!("{field} cursor overflow")))?;
2524    let value_end = cursor
2525        .checked_add(len)
2526        .ok_or_else(|| Error::Decode(format!("{field} length overflow")))?;
2527    if value_end > end {
2528        return Err(Error::Decode(format!("short {field}")));
2529    }
2530    let value = &bytes[*cursor..value_end];
2531    *cursor = value_end;
2532    Ok(value)
2533}
2534
2535fn read_intent_name(bytes: &[u8], cursor: &mut usize, end: usize) -> Result<String> {
2536    let len = read_u16(bytes, *cursor)? as usize;
2537    *cursor = cursor
2538        .checked_add(2)
2539        .ok_or_else(|| Error::Decode("truncate intent cursor overflow".into()))?;
2540    let name_end = cursor
2541        .checked_add(len)
2542        .ok_or_else(|| Error::Decode("truncate intent name overflow".into()))?;
2543    if name_end > end {
2544        return Err(Error::Decode("short truncate intent name".into()));
2545    }
2546    let name = std::str::from_utf8(&bytes[*cursor..name_end])
2547        .map_err(|err| Error::Decode(err.to_string()))?
2548        .to_string();
2549    *cursor = name_end;
2550    Ok(name)
2551}
2552
2553fn file_name(path: &Path) -> Result<String> {
2554    path.file_name()
2555        .and_then(|name| name.to_str())
2556        .map(str::to_owned)
2557        .ok_or_else(|| Error::Decode("qlog temp filename is not UTF-8".into()))
2558}
2559
2560fn publish_closed_segment(dir: &Path, entries: &[LogEntry]) -> Result<PathBuf> {
2561    let first = entries
2562        .first()
2563        .ok_or_else(|| Error::Decode("cannot write empty segment".into()))?;
2564    let last = entries
2565        .last()
2566        .ok_or_else(|| Error::Decode("cannot write empty segment".into()))?;
2567    let range = IndexRange::new(first.index, last.index)?;
2568    let final_path = dir.join(segment_file_name(range));
2569    if final_path.exists() {
2570        return Err(Error::Io(format!(
2571            "qlog segment already exists: {}",
2572            final_path.display()
2573        )));
2574    }
2575
2576    let bytes = encode_segment(entries);
2577    let (temp_path, mut file) = create_unique_temp_file(dir, &final_path)?;
2578    if let Err(err) = file
2579        .write_all(&bytes)
2580        .and_then(|_| file.sync_all())
2581        .map_err(|err| Error::Io(err.to_string()))
2582    {
2583        drop(file);
2584        let _ = fs::remove_file(&temp_path);
2585        return Err(err);
2586    }
2587    drop(file);
2588    if let Err(err) = fs::rename(&temp_path, &final_path) {
2589        let _ = fs::remove_file(&temp_path);
2590        return Err(Error::Io(err.to_string()));
2591    }
2592    sync_directory(dir)?;
2593    Ok(final_path)
2594}
2595
2596fn create_unique_temp_file(dir: &Path, final_path: &Path) -> Result<(PathBuf, fs::File)> {
2597    let final_name = final_path
2598        .file_name()
2599        .and_then(|name| name.to_str())
2600        .expect("generated qlog filename is UTF-8");
2601    loop {
2602        let id = NEXT_TEMP_FILE_ID.fetch_add(1, Ordering::Relaxed);
2603        let path = dir.join(format!(".{final_name}.{}.{}.tmp", std::process::id(), id));
2604        match fs::OpenOptions::new()
2605            .write(true)
2606            .create_new(true)
2607            .open(&path)
2608        {
2609            Ok(file) => return Ok((path, file)),
2610            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
2611            Err(err) => return Err(Error::Io(err.to_string())),
2612        }
2613    }
2614}
2615
2616fn sync_directory(dir: &Path) -> Result<()> {
2617    fs::File::open(dir)
2618        .and_then(|directory| directory.sync_all())
2619        .map_err(|err| Error::Io(err.to_string()))
2620}
2621
2622#[cfg(test)]
2623mod tests {
2624    use super::*;
2625    use rhiza_core::{ConfigChange, StopBinding};
2626
2627    const INJECTED_CRASH: &str = "injected crash";
2628
2629    #[test]
2630    fn legacy_stopped_anchor_decodes_with_unknown_binding() {
2631        let digest = LogHash::from_bytes([4; 32]);
2632        let stop = LogAnchor::new(10, LogHash::from_bytes([5; 32]));
2633        let bound_stop = ConfigChange::bound_stop(
2634            "cluster-a",
2635            4,
2636            digest,
2637            5,
2638            vec!["node-a".into(), "node-b".into(), "node-c".into()],
2639        )
2640        .unwrap();
2641        let anchor = RecoveryAnchor::new_with_configuration(
2642            "cluster-a",
2643            1,
2644            ConfigurationState::Stopped {
2645                config_id: 4,
2646                digest,
2647                stop,
2648                binding: StopBinding::Bound {
2649                    successor: bound_stop.successor().unwrap().clone(),
2650                    stop_command_hash: bound_stop.to_stored_command().hash(),
2651                },
2652            },
2653            7,
2654            stop,
2655            SnapshotIdentity::new("snapshot-stop", LogHash::from_bytes([9; 32]), 4096),
2656        );
2657
2658        let decoded = decode_anchor(&encode_legacy_v3_anchor(&anchor)).unwrap();
2659
2660        assert_eq!(
2661            decoded.configuration_state(),
2662            &ConfigurationState::Stopped {
2663                config_id: 4,
2664                digest,
2665                stop,
2666                binding: StopBinding::Unknown,
2667            }
2668        );
2669    }
2670
2671    #[test]
2672    fn compact_crash_before_durable_intent_preserves_genesis_log() {
2673        let dir = tempfile::tempdir().unwrap();
2674        let entries = chain(&[b"one", b"two", b"three", b"four", b"five", b"six"]);
2675        let store = segmented_store(dir.path(), &entries);
2676        let anchor = recovery_anchor(&entries[2]);
2677
2678        inject_compact_crash(&store, &anchor, CompactPhase::ReplacementPrepared);
2679        drop(store);
2680
2681        assert_reopens_with(dir.path(), &entries);
2682        assert!(read_anchor(dir.path()).unwrap().is_none());
2683        assert!(!dir.path().join(COMPACT_INTENT_FILE_NAME).exists());
2684    }
2685
2686    #[test]
2687    fn compact_rolls_forward_from_every_committed_phase() {
2688        let phases = [
2689            CompactPhase::IntentRenamed,
2690            CompactPhase::IntentDurable,
2691            CompactPhase::AnchorInstalled,
2692            CompactPhase::AnchorDurable,
2693            CompactPhase::OldSegmentRemoved(0),
2694            CompactPhase::OldSegmentRemoved(1),
2695            CompactPhase::ReplacementInstalled,
2696            CompactPhase::AppliedDirectorySynced,
2697            CompactPhase::IntentRemoved,
2698            CompactPhase::CompleteDirectorySynced,
2699        ];
2700
2701        for crash_phase in phases {
2702            let dir = tempfile::tempdir().unwrap();
2703            let entries = chain(&[b"one", b"two", b"three", b"four", b"five", b"six"]);
2704            let store = segmented_store(dir.path(), &entries);
2705            let anchor = recovery_anchor(&entries[2]);
2706
2707            inject_compact_crash(&store, &anchor, crash_phase);
2708            drop(store);
2709
2710            let reopened = FileLogStore::open(dir.path(), "cluster-a", 1, 1).unwrap();
2711            assert_eq!(read_anchor(dir.path()).unwrap(), Some(anchor.clone()));
2712            assert_eq!(
2713                reopened.read_range(IndexRange::new(1, 6).unwrap()).unwrap(),
2714                entries[3..]
2715            );
2716            assert_eq!(reopened.last_index().unwrap(), Some(6));
2717            assert_no_overlapping_closed_segments(dir.path());
2718            assert!(!dir.path().join(COMPACT_INTENT_FILE_NAME).exists());
2719        }
2720    }
2721
2722    #[test]
2723    fn corrupted_compact_intent_is_fatal_without_deleting_prefix() {
2724        let dir = tempfile::tempdir().unwrap();
2725        let entries = chain(&[b"one", b"two", b"three", b"four"]);
2726        let store = segmented_store(dir.path(), &entries);
2727        let anchor = recovery_anchor(&entries[2]);
2728        inject_compact_crash(&store, &anchor, CompactPhase::IntentDurable);
2729        drop(store);
2730        let files_before = closed_segment_bytes(dir.path());
2731        let intent_path = dir.path().join(COMPACT_INTENT_FILE_NAME);
2732        let mut bytes = fs::read(&intent_path).unwrap();
2733        bytes[4] ^= 1;
2734        fs::write(&intent_path, bytes).unwrap();
2735
2736        assert!(FileLogStore::open(dir.path(), "cluster-a", 1, 1).is_err());
2737        assert_eq!(closed_segment_bytes(dir.path()), files_before);
2738        assert!(read_anchor(dir.path()).unwrap().is_none());
2739    }
2740
2741    #[test]
2742    fn reopen_preserves_original_log_when_crash_precedes_durable_intent() {
2743        let dir = tempfile::tempdir().unwrap();
2744        let entries = chain(&[b"one", b"two", b"three", b"four", b"five", b"six"]);
2745        let store = segmented_store(dir.path(), &entries);
2746
2747        inject_truncate_crash(&store, 4, TruncatePhase::ReplacementPrepared);
2748        drop(store);
2749
2750        assert_reopens_with(dir.path(), &entries);
2751        assert_no_overlapping_closed_segments(dir.path());
2752        assert!(!dir.path().join(TRUNCATE_INTENT_FILE_NAME).exists());
2753    }
2754
2755    #[test]
2756    fn reopen_recovers_exact_prefix_from_every_durable_intent_phase() {
2757        let phases = [
2758            TruncatePhase::IntentRenamed,
2759            TruncatePhase::IntentDurable,
2760            TruncatePhase::OldSegmentRemoved(0),
2761            TruncatePhase::OldSegmentRemoved(1),
2762            TruncatePhase::ReplacementInstalled,
2763            TruncatePhase::AppliedDirectorySynced,
2764            TruncatePhase::IntentRemoved,
2765            TruncatePhase::CompleteDirectorySynced,
2766        ];
2767
2768        for crash_phase in phases {
2769            let dir = tempfile::tempdir().unwrap();
2770            let entries = chain(&[b"one", b"two", b"three", b"four", b"five", b"six"]);
2771            let store = segmented_store(dir.path(), &entries);
2772
2773            inject_truncate_crash(&store, 4, crash_phase);
2774            drop(store);
2775
2776            assert_reopens_with(dir.path(), &entries[..3]);
2777            assert_no_overlapping_closed_segments(dir.path());
2778            assert!(!dir.path().join(TRUNCATE_INTENT_FILE_NAME).exists());
2779        }
2780    }
2781
2782    #[test]
2783    fn corrupted_truncate_intent_is_fatal_without_removing_segments() {
2784        let dir = tempfile::tempdir().unwrap();
2785        let entries = chain(&[b"one", b"two", b"three", b"four"]);
2786        let store = segmented_store(dir.path(), &entries);
2787        inject_truncate_crash(&store, 3, TruncatePhase::IntentDurable);
2788        drop(store);
2789        let files_before = closed_segment_bytes(dir.path());
2790        let intent_path = dir.path().join(TRUNCATE_INTENT_FILE_NAME);
2791        let mut bytes = fs::read(&intent_path).unwrap();
2792        bytes[4] ^= 1;
2793        fs::write(&intent_path, bytes).unwrap();
2794
2795        assert!(FileLogStore::open(dir.path(), "cluster-a", 1, 1).is_err());
2796        assert_eq!(closed_segment_bytes(dir.path()), files_before);
2797    }
2798
2799    #[test]
2800    fn unsafe_truncate_intent_name_is_fatal_without_path_traversal() {
2801        let root = tempfile::tempdir().unwrap();
2802        let dir = root.path().join("log");
2803        fs::create_dir(&dir).unwrap();
2804        let victim = root.path().join("victim.qlog");
2805        fs::write(&victim, b"keep").unwrap();
2806        let bytes = encode_unchecked_test_intent("../victim.qlog");
2807        fs::write(dir.join(TRUNCATE_INTENT_FILE_NAME), bytes).unwrap();
2808
2809        assert!(FileLogStore::open(&dir, "cluster-a", 1, 1).is_err());
2810        assert_eq!(fs::read(victim).unwrap(), b"keep");
2811    }
2812
2813    fn inject_truncate_crash(store: &FileLogStore, from: LogIndex, crash_phase: TruncatePhase) {
2814        let mut inner = store.lock().unwrap();
2815        let err = truncate_suffix_with_hook(&mut inner, from, &mut |phase| {
2816            if phase == crash_phase {
2817                Err(Error::Io(INJECTED_CRASH.into()))
2818            } else {
2819                Ok(())
2820            }
2821        })
2822        .unwrap_err();
2823        assert_eq!(err, Error::Io(INJECTED_CRASH.into()));
2824    }
2825
2826    fn inject_compact_crash(
2827        store: &FileLogStore,
2828        anchor: &RecoveryAnchor,
2829        crash_phase: CompactPhase,
2830    ) {
2831        let mut inner = store.lock().unwrap();
2832        let err = compact_prefix_with_hook(&mut inner, anchor, &mut |phase| {
2833            if phase == crash_phase {
2834                Err(Error::Io(INJECTED_CRASH.into()))
2835            } else {
2836                Ok(())
2837            }
2838        })
2839        .unwrap_err();
2840        assert_eq!(err, Error::Io(INJECTED_CRASH.into()));
2841    }
2842
2843    fn segmented_store(dir: &Path, entries: &[LogEntry]) -> FileLogStore {
2844        fs::create_dir_all(dir).unwrap();
2845        publish_closed_segment(dir, &entries[..2]).unwrap();
2846        publish_closed_segment(dir, &entries[2..4]).unwrap();
2847        if entries.len() > 4 {
2848            publish_closed_segment(dir, &entries[4..]).unwrap();
2849        }
2850        FileLogStore::open(dir, "cluster-a", 1, 1).unwrap()
2851    }
2852
2853    fn assert_reopens_with(dir: &Path, expected: &[LogEntry]) {
2854        let reopened = FileLogStore::open(dir, "cluster-a", 1, 1).unwrap();
2855        assert_eq!(
2856            reopened.read_range(IndexRange::new(1, 6).unwrap()).unwrap(),
2857            expected
2858        );
2859        assert_eq!(
2860            reopened.last_index().unwrap(),
2861            expected.last().map(|entry| entry.index)
2862        );
2863    }
2864
2865    fn assert_no_overlapping_closed_segments(dir: &Path) {
2866        let mut ranges = fs::read_dir(dir)
2867            .unwrap()
2868            .filter_map(std::result::Result::ok)
2869            .filter_map(|entry| entry.file_name().into_string().ok())
2870            .filter(|name| name.ends_with(".qlog") && !name.ends_with("-open.qlog"))
2871            .map(|name| parse_closed_segment_name(&name).unwrap())
2872            .collect::<Vec<_>>();
2873        ranges.sort_by_key(IndexRange::start);
2874        assert!(ranges
2875            .windows(2)
2876            .all(|pair| pair[0].end() < pair[1].start()));
2877    }
2878
2879    fn closed_segment_bytes(dir: &Path) -> Vec<(String, Vec<u8>)> {
2880        let mut files = fs::read_dir(dir)
2881            .unwrap()
2882            .filter_map(std::result::Result::ok)
2883            .filter_map(|entry| {
2884                entry
2885                    .file_name()
2886                    .into_string()
2887                    .ok()
2888                    .map(|name| (name, entry.path()))
2889            })
2890            .filter(|(name, _)| name.ends_with(".qlog") && !name.ends_with("-open.qlog"))
2891            .map(|(name, path)| (name, fs::read(path).unwrap()))
2892            .collect::<Vec<_>>();
2893        files.sort_by(|left, right| left.0.cmp(&right.0));
2894        files
2895    }
2896
2897    fn encode_unchecked_test_intent(old_name: &str) -> Vec<u8> {
2898        let mut out = Vec::new();
2899        out.extend_from_slice(&TRUNCATE_INTENT_MAGIC);
2900        put_u16(&mut out, TRUNCATE_INTENT_VERSION);
2901        put_u16(&mut out, 0);
2902        put_u32(&mut out, 1);
2903        put_u16(&mut out, old_name.len() as u16);
2904        out.extend_from_slice(old_name.as_bytes());
2905        let crc = crc32c(&out);
2906        put_u32(&mut out, crc);
2907        out
2908    }
2909
2910    fn encode_legacy_v3_anchor(anchor: &RecoveryAnchor) -> Vec<u8> {
2911        let mut out = Vec::new();
2912        out.extend_from_slice(&ANCHOR_MAGIC);
2913        put_u16(&mut out, 3);
2914        put_u16(&mut out, 0);
2915        put_u64(&mut out, anchor.epoch());
2916        put_u64(&mut out, anchor.config_id());
2917        put_u64(&mut out, anchor.recovery_generation());
2918        put_u64(&mut out, anchor.compacted().index());
2919        out.extend_from_slice(anchor.compacted().hash().as_bytes());
2920        out.extend_from_slice(anchor.snapshot().digest().as_bytes());
2921        put_u64(&mut out, anchor.snapshot().size_bytes());
2922        match anchor.executor_fingerprint() {
2923            Some(fingerprint) => {
2924                out.push(1);
2925                out.extend_from_slice(fingerprint.as_bytes());
2926            }
2927            None => {
2928                out.push(0);
2929                out.extend_from_slice(LogHash::ZERO.as_bytes());
2930            }
2931        }
2932        match anchor.configuration_state() {
2933            ConfigurationState::Active { digest, .. } => {
2934                out.push(1);
2935                out.extend_from_slice(digest.as_bytes());
2936            }
2937            ConfigurationState::Stopped { digest, stop, .. } => {
2938                out.push(2);
2939                out.extend_from_slice(digest.as_bytes());
2940                put_u64(&mut out, stop.index());
2941                out.extend_from_slice(stop.hash().as_bytes());
2942            }
2943        }
2944        put_string(&mut out, anchor.cluster_id(), "anchor cluster_id").unwrap();
2945        put_string(
2946            &mut out,
2947            anchor.snapshot().snapshot_id(),
2948            "anchor snapshot_id",
2949        )
2950        .unwrap();
2951        let crc = crc32c(&out);
2952        put_u32(&mut out, crc);
2953        out
2954    }
2955
2956    fn chain(payloads: &[&[u8]]) -> Vec<LogEntry> {
2957        let mut entries = Vec::new();
2958        let mut prev_hash = LogHash::ZERO;
2959        for (position, payload) in payloads.iter().enumerate() {
2960            let index = position as u64 + 1;
2961            let hash = LogEntry::calculate_hash(
2962                "cluster-a",
2963                index,
2964                1,
2965                1,
2966                EntryType::Command,
2967                prev_hash,
2968                payload,
2969            );
2970            entries.push(LogEntry {
2971                cluster_id: "cluster-a".into(),
2972                epoch: 1,
2973                config_id: 1,
2974                index,
2975                entry_type: EntryType::Command,
2976                payload: payload.to_vec(),
2977                prev_hash,
2978                hash,
2979            });
2980            prev_hash = hash;
2981        }
2982        entries
2983    }
2984
2985    fn recovery_anchor(entry: &LogEntry) -> RecoveryAnchor {
2986        RecoveryAnchor::new(
2987            entry.cluster_id.clone(),
2988            entry.epoch,
2989            entry.config_id,
2990            1,
2991            LogAnchor::new(entry.index, entry.hash),
2992            SnapshotIdentity::new(
2993                format!("snapshot-{:015}", entry.index),
2994                LogHash::digest(&[b"snapshot", &entry.index.to_be_bytes()]),
2995                4096,
2996            ),
2997        )
2998    }
2999}