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 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 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}